From ac7571e7c218da70f285cdea27e0a21fe1dd869f Mon Sep 17 00:00:00 2001 From: seuuung Date: Fri, 2 Oct 2026 11:42:07 +0900 Subject: [PATCH] Improve Android Wi-Fi Direct, charts, calibration and test playback --- .gitignore | 1 + .../BackgroundMeasurementServiceTest.kt | 210 +++ .../dialog/ProjectDialogHoverTest.kt | 22 + .../monitor/components/FormulaColorUiTest.kt | 42 + .../components/FormulaColumnsUiTest.kt | 258 ++++ .../monitor/components/GraphSettingsUiTest.kt | 13 +- .../monitor/components/RibbonButtonUiTest.kt | 62 + .../WorkspaceTouchNavigationUiTest.kt | 331 +++- .../SignalTableTextAlignmentUiTest.kt | 85 ++ .../signaltree/TableAutoFitUiTest.kt | 163 ++ .../signaltree/TableTextOverflowUiTest.kt | 90 ++ Android/app/src/main/AndroidManifest.xml | 18 +- .../main/java/com/dct/app/DctApplication.kt | 15 + .../dct/app/background/DctSessionRuntime.kt | 125 ++ .../background/MeasurementExecutionState.kt | 48 + .../MeasurementForegroundService.kt | 161 ++ .../dct/app/core/common/PacketDistributor.kt | 9 +- .../app/core/common/PacketPipelineMetrics.kt | 137 ++ .../app/core/engine/XcpDaqStreamDecoder.kt | 19 +- .../java/com/dct/app/core/error/DctError.kt | 9 + .../dct/app/core/formula/FormulaEvaluator.kt | 46 +- .../core/formula/FormulaTelemetryEngine.kt | 111 ++ .../app/core/formula/FormulaVariableLookup.kt | 28 + .../dct/app/core/formula/SMLinEvaluator.kt | 147 +- .../java/com/dct/app/core/theme/DctColors.kt | 48 +- .../dct/app/core/theme/SignalColorPresets.kt | 11 + .../data/communication/OrderedPacketWriter.kt | 105 ++ .../data/communication/WifiDirectManager.kt | 794 +++++----- .../app/data/communication/WifiDirectRoute.kt | 40 + .../app/data/communication/WifiDirectState.kt | 55 + .../data/communication/WifiSocketManager.kt | 262 +++- .../app/data/logger/CsvMeasurementLogger.kt | 83 +- .../app/data/logger/MeasurementLogPaths.kt | 3 +- .../app/data/logger/MeasurementSafExports.kt | 58 + .../app/data/logger/Mf4MeasurementLogger.kt | 205 ++- .../dct/app/data/logger/PcMeasurementNames.kt | 18 + .../app/data/logger/PcOperationalLogger.kt | 220 +++ .../data/maintenance/MaintenanceService.kt | 94 ++ .../com/dct/app/data/mdb/MdbSchemaParser.kt | 3 +- .../java/com/dct/app/data/parser/A2lParser.kt | 126 +- .../data/protocol/DctCalibrationProtocol.kt | 2 +- .../dct/app/data/protocol/DctPacketCodec.kt | 2 +- .../app/data/protocol/DctProtocolRequests.kt | 70 + .../app/data/protocol/DctResponseValidator.kt | 56 +- .../data/protocol/DctStreamPayloadParser.kt | 29 +- .../dct/app/data/protocol/MdbRecordDecoder.kt | 116 +- .../data/protocol/SlidingRingBufferParser.kt | 51 +- .../app/data/repository/DpcProjectManager.kt | 340 ++++- .../app/data/repository/DpcWorkspaceJson.kt | 30 +- .../data/repository/PacketRepositoryImpl.kt | 546 ++++++- .../app/data/repository/PcDpcCompatibility.kt | 508 +++++++ .../data/ringbuffer/TimeSeriesRingBuffer.kt | 44 +- .../app/domain/model/chart/ChartAxisConfig.kt | 6 +- .../domain/model/chart/ChartDrawingRules.kt | 70 + .../domain/model/chart/ChartSignalSeries.kt | 2 +- .../model/chart/DownsamplingAlgorithms.kt | 91 +- .../chart/MultiAxisCoordinateTransformer.kt | 18 +- .../domain/model/chart/TimeAxisViewport.kt | 61 +- .../domain/model/chart/TimeWindowPreset.kt | 5 + .../domain/model/dashboard/TileWidgetItem.kt | 5 +- .../domain/model/formula/FormulaDefinition.kt | 5 +- .../dct/app/domain/model/mdb/CanSignalKey.kt | 40 + .../com/dct/app/domain/model/mdb/MdbModels.kt | 15 +- .../app/domain/model/project/DpcProject.kt | 3 +- .../domain/model/project/DpcWorkspaceState.kt | 5 +- .../app/domain/model/protocol/DctCommand.kt | 11 + .../protocol/DctMeasurementConfiguration.kt | 4 + .../app/domain/model/protocol/DctPacket.kt | 13 +- .../app/domain/repository/PacketRepository.kt | 11 + .../usecase/chart/ChartBatchRenderer.kt | 550 ++++++- .../usecase/chart/ChartTelemetryResolver.kt | 78 + .../usecase/chart/MultiAxisChartController.kt | 32 + .../presentation/chart/GraphYAxisDialog.kt | 137 +- .../presentation/chart/MultiXGraphCanvas.kt | 809 ++++++++-- .../app/presentation/chart/MultiXGraphView.kt | 6 + .../dialog/CommunicationDeviceListDialogs.kt | 549 +------ .../presentation/dialog/ProjectFileDialogs.kt | 19 +- .../dialog/WifiDirectDeviceListDialog.kt | 152 ++ .../dialog/WifiManualConnectionForm.kt | 2 +- .../BackgroundExecutionPermissionDialogs.kt | 95 ++ .../dct/app/presentation/main/DctTabletApp.kt | 39 +- .../dct/app/presentation/main/MainActivity.kt | 156 +- .../app/presentation/main/MainViewModel.kt | 1337 ++++++++++++++--- .../app/presentation/maintenance/LogTab.kt | 260 ++++ .../maintenance/MaintenanceDialog.kt | 35 +- .../app/presentation/maintenance/UdsDidTab.kt | 361 +++++ .../presentation/maintenance/VehicleDbTab.kt | 452 ++++++ .../dct/app/presentation/mdi/MdiViewModel.kt | 33 +- .../com/dct/app/presentation/mdi/MdiWindow.kt | 9 + .../dct/app/presentation/mdi/WindowReveal.kt | 13 + .../monitor/DiagnosticDataMonitorScreen.kt | 389 +++-- .../monitor/EmptyCanvasGuideView.kt | 10 +- .../monitor/components/AlphaSignal.kt | 75 +- .../components/AlphanumericalWindowView.kt | 129 +- .../monitor/components/FormulaEditDialog.kt | 55 +- .../monitor/components/FormulaWindowView.kt | 331 ++-- .../monitor/components/GraphWindowView.kt | 225 ++- .../monitor/components/MdiSubWindowFrame.kt | 106 +- .../components/SensorModuleWindowView.kt | 22 +- .../monitor/components/TwoFingerFling.kt | 39 +- .../monitor/components/WorkspaceRibbonBar.kt | 223 ++- .../components/WorkspaceTouchNavigation.kt | 331 +++- .../presentation/project/NewProjectDialog.kt | 146 +- .../signaltree/A2lSignalTableView.kt | 155 +- .../signaltree/CanSignalSorting.kt | 10 +- .../signaltree/CanSignalTableView.kt | 87 +- .../signaltree/CompensationMapView.kt | 15 +- .../signaltree/CompensationTable.kt | 59 +- .../signaltree/ConstantTableView.kt | 32 +- .../signaltree/MdbSignalTableView.kt | 64 +- .../signaltree/SignalTableTextStyles.kt | 25 + .../signaltree/SignalTreeModels.kt | 3 +- .../presentation/signaltree/TableAutoFit.kt | 48 + .../signaltree/TableCommonComponents.kt | 498 ++++-- .../presentation/signaltree/WindowUiState.kt | 28 +- .../signaltree/WindowsClassicTreeView.kt | 136 +- .../drawable/ic_measurement_notification.xml | 5 + .../maintenance/HeartbeatAndOtaParityTest.kt | 243 +++ .../app/data/protocol/DctOtaProtocolTest.kt | 81 + .../maintenance/MaintenanceUiParityTest.kt | 292 ++++ .../signaltree/SignalTableTextStylesTest.kt | 32 + .../signaltree/TableAutoFitTest.kt | 78 + .../app/tests/A2lCharacteristicLayoutTest.kt | 113 ++ .../java/com/dct/app/tests/A2lParserTest.kt | 44 + .../app/tests/AlphanumericalUpdatesTest.kt | 106 ++ .../com/dct/app/tests/CanSignalKeyTest.kt | 44 + .../app/tests/ChartContinuousScrollTest.kt | 159 +- .../dct/app/tests/ChartCursorStabilityTest.kt | 4 +- .../com/dct/app/tests/ChartInitialFillTest.kt | 90 ++ .../com/dct/app/tests/ChartNavigationTest.kt | 107 +- .../tests/ChartPerformanceLifecycleTest.kt | 74 + .../app/tests/ChartStoppedNavigationTest.kt | 5 +- .../app/tests/ChartTelemetryResolverTest.kt | 127 ++ .../com/dct/app/tests/ChartXAxisParityTest.kt | 28 +- .../app/tests/ColorRefCompatibilityTest.kt | 21 + .../dct/app/tests/DctProtocolRequestsTest.kt | 43 + .../app/tests/DctStreamPayloadParserTest.kt | 38 +- .../dct/app/tests/DpcProjectManagerTest.kt | 159 +- .../app/tests/DpcWorkspacePersistenceTest.kt | 7 +- .../app/tests/FormulaTelemetryEngineTest.kt | 173 +++ .../tests/FormulaTelemetryRegressionTest.kt | 197 +++ .../dct/app/tests/FormulaWindowStateTest.kt | 31 + .../dct/app/tests/GapClosureRegressionTest.kt | 13 +- .../app/tests/GraphSettingsRegressionTest.kt | 87 ++ .../app/tests/HardwareWireRegressionTest.kt | 70 + .../com/dct/app/tests/JackcessLoadUnitTest.kt | 2 +- .../com/dct/app/tests/MainViewModelTest.kt | 516 ++++++- .../dct/app/tests/MaintenanceServiceTest.kt | 58 + .../com/dct/app/tests/MdbRecordDecoderTest.kt | 127 ++ .../dct/app/tests/MdiWorkspaceManagerTest.kt | 16 + .../tests/MeasurementExecutionStateTest.kt | 70 + .../dct/app/tests/MeasurementLogPathsTest.kt | 4 + .../dct/app/tests/MeasurementLoggerTest.kt | 146 +- .../tests/MeasurementRestartRegressionTest.kt | 55 +- .../dct/app/tests/MinMaxDownsamplerTest.kt | 12 + .../tests/MultiAxisCoordinateTransformTest.kt | 14 + .../app/tests/PacketPipelineMetricsTest.kt | 23 + .../tests/PacketPipelineReliabilityTest.kt | 127 ++ .../app/tests/PacketRepositoryResponseTest.kt | 150 +- .../dct/app/tests/PcDpcCompatibilityTest.kt | 158 ++ .../dct/app/tests/PcOperationalLoggerTest.kt | 161 ++ .../dct/app/tests/SignalColorPresetsTest.kt | 19 + .../app/tests/SlidingRingBufferParserTest.kt | 21 +- .../dct/app/tests/SlimScrollbarMetricsTest.kt | 27 + .../dct/app/tests/TimeSeriesRingBufferTest.kt | 29 + .../com/dct/app/tests/TwoFingerFlingTest.kt | 10 +- .../com/dct/app/tests/TwoFingerGestureTest.kt | 17 + .../app/tests/WifiDirectManagerStateTest.kt | 229 +++ .../com/dct/app/tests/WifiDirectRouteTest.kt | 164 ++ .../com/dct/app/tests/WindowRevealTest.kt | 20 + .../java/com/dct/app/tests/YAxisHitTest.kt | 34 + Android/gradlew.bat | 13 + DCT_test/DCT_test/ClientSession.cs | 477 +++++- DCT_test/DCT_test/DCT_test.csproj | 12 +- DCT_test/DCT_test/DctPacket.cs | 10 +- DCT_test/DCT_test/DctTcpServer.cs | 472 ++++-- DCT_test/DCT_test/LogFileReader.cs | 153 +- DCT_test/DCT_test/LogPlaybackClock.cs | 26 + DCT_test/DCT_test/MainWindow.xaml | 172 +-- DCT_test/DCT_test/MainWindow.xaml.cs | 675 +++------ DCT_test/DCT_test/WifiDirectHost.cs | 376 +++++ DCT_test/Verify-Regression.ps1 | 53 + DCT_test/Verify-Transport.cs | 269 ++++ DCT_test/Verify-Transport.ps1 | 25 + 184 files changed, 18481 insertions(+), 3731 deletions(-) create mode 100644 Android/app/src/androidTest/java/com/dct/app/background/BackgroundMeasurementServiceTest.kt create mode 100644 Android/app/src/androidTest/java/com/dct/app/presentation/monitor/components/FormulaColorUiTest.kt create mode 100644 Android/app/src/androidTest/java/com/dct/app/presentation/monitor/components/FormulaColumnsUiTest.kt create mode 100644 Android/app/src/androidTest/java/com/dct/app/presentation/monitor/components/RibbonButtonUiTest.kt create mode 100644 Android/app/src/androidTest/java/com/dct/app/presentation/signaltree/SignalTableTextAlignmentUiTest.kt create mode 100644 Android/app/src/androidTest/java/com/dct/app/presentation/signaltree/TableAutoFitUiTest.kt create mode 100644 Android/app/src/androidTest/java/com/dct/app/presentation/signaltree/TableTextOverflowUiTest.kt create mode 100644 Android/app/src/main/java/com/dct/app/DctApplication.kt create mode 100644 Android/app/src/main/java/com/dct/app/background/DctSessionRuntime.kt create mode 100644 Android/app/src/main/java/com/dct/app/background/MeasurementExecutionState.kt create mode 100644 Android/app/src/main/java/com/dct/app/background/MeasurementForegroundService.kt create mode 100644 Android/app/src/main/java/com/dct/app/core/common/PacketPipelineMetrics.kt create mode 100644 Android/app/src/main/java/com/dct/app/core/formula/FormulaTelemetryEngine.kt create mode 100644 Android/app/src/main/java/com/dct/app/core/formula/FormulaVariableLookup.kt create mode 100644 Android/app/src/main/java/com/dct/app/core/theme/SignalColorPresets.kt create mode 100644 Android/app/src/main/java/com/dct/app/data/communication/OrderedPacketWriter.kt create mode 100644 Android/app/src/main/java/com/dct/app/data/communication/WifiDirectRoute.kt create mode 100644 Android/app/src/main/java/com/dct/app/data/communication/WifiDirectState.kt create mode 100644 Android/app/src/main/java/com/dct/app/data/logger/MeasurementSafExports.kt create mode 100644 Android/app/src/main/java/com/dct/app/data/logger/PcMeasurementNames.kt create mode 100644 Android/app/src/main/java/com/dct/app/data/logger/PcOperationalLogger.kt create mode 100644 Android/app/src/main/java/com/dct/app/data/repository/PcDpcCompatibility.kt create mode 100644 Android/app/src/main/java/com/dct/app/domain/model/mdb/CanSignalKey.kt create mode 100644 Android/app/src/main/java/com/dct/app/domain/usecase/chart/ChartTelemetryResolver.kt create mode 100644 Android/app/src/main/java/com/dct/app/presentation/dialog/WifiDirectDeviceListDialog.kt create mode 100644 Android/app/src/main/java/com/dct/app/presentation/main/BackgroundExecutionPermissionDialogs.kt create mode 100644 Android/app/src/main/java/com/dct/app/presentation/maintenance/LogTab.kt create mode 100644 Android/app/src/main/java/com/dct/app/presentation/maintenance/UdsDidTab.kt create mode 100644 Android/app/src/main/java/com/dct/app/presentation/maintenance/VehicleDbTab.kt create mode 100644 Android/app/src/main/java/com/dct/app/presentation/mdi/WindowReveal.kt create mode 100644 Android/app/src/main/java/com/dct/app/presentation/signaltree/SignalTableTextStyles.kt create mode 100644 Android/app/src/main/java/com/dct/app/presentation/signaltree/TableAutoFit.kt create mode 100644 Android/app/src/main/res/drawable/ic_measurement_notification.xml create mode 100644 Android/app/src/test/java/com/dct/app/data/maintenance/HeartbeatAndOtaParityTest.kt create mode 100644 Android/app/src/test/java/com/dct/app/data/protocol/DctOtaProtocolTest.kt create mode 100644 Android/app/src/test/java/com/dct/app/presentation/maintenance/MaintenanceUiParityTest.kt create mode 100644 Android/app/src/test/java/com/dct/app/presentation/signaltree/SignalTableTextStylesTest.kt create mode 100644 Android/app/src/test/java/com/dct/app/presentation/signaltree/TableAutoFitTest.kt create mode 100644 Android/app/src/test/java/com/dct/app/tests/A2lCharacteristicLayoutTest.kt create mode 100644 Android/app/src/test/java/com/dct/app/tests/AlphanumericalUpdatesTest.kt create mode 100644 Android/app/src/test/java/com/dct/app/tests/CanSignalKeyTest.kt create mode 100644 Android/app/src/test/java/com/dct/app/tests/ChartInitialFillTest.kt create mode 100644 Android/app/src/test/java/com/dct/app/tests/ChartPerformanceLifecycleTest.kt create mode 100644 Android/app/src/test/java/com/dct/app/tests/ChartTelemetryResolverTest.kt create mode 100644 Android/app/src/test/java/com/dct/app/tests/ColorRefCompatibilityTest.kt create mode 100644 Android/app/src/test/java/com/dct/app/tests/FormulaTelemetryEngineTest.kt create mode 100644 Android/app/src/test/java/com/dct/app/tests/FormulaTelemetryRegressionTest.kt create mode 100644 Android/app/src/test/java/com/dct/app/tests/FormulaWindowStateTest.kt create mode 100644 Android/app/src/test/java/com/dct/app/tests/HardwareWireRegressionTest.kt create mode 100644 Android/app/src/test/java/com/dct/app/tests/MdbRecordDecoderTest.kt create mode 100644 Android/app/src/test/java/com/dct/app/tests/MeasurementExecutionStateTest.kt create mode 100644 Android/app/src/test/java/com/dct/app/tests/PacketPipelineMetricsTest.kt create mode 100644 Android/app/src/test/java/com/dct/app/tests/PacketPipelineReliabilityTest.kt create mode 100644 Android/app/src/test/java/com/dct/app/tests/PcDpcCompatibilityTest.kt create mode 100644 Android/app/src/test/java/com/dct/app/tests/PcOperationalLoggerTest.kt create mode 100644 Android/app/src/test/java/com/dct/app/tests/SignalColorPresetsTest.kt create mode 100644 Android/app/src/test/java/com/dct/app/tests/SlimScrollbarMetricsTest.kt create mode 100644 Android/app/src/test/java/com/dct/app/tests/WifiDirectManagerStateTest.kt create mode 100644 Android/app/src/test/java/com/dct/app/tests/WifiDirectRouteTest.kt create mode 100644 Android/app/src/test/java/com/dct/app/tests/WindowRevealTest.kt create mode 100644 Android/app/src/test/java/com/dct/app/tests/YAxisHitTest.kt create mode 100644 DCT_test/DCT_test/LogPlaybackClock.cs create mode 100644 DCT_test/DCT_test/WifiDirectHost.cs create mode 100644 DCT_test/Verify-Regression.ps1 create mode 100644 DCT_test/Verify-Transport.cs create mode 100644 DCT_test/Verify-Transport.ps1 diff --git a/.gitignore b/.gitignore index 2581c0f..9cd3c6a 100644 --- a/.gitignore +++ b/.gitignore @@ -16,6 +16,7 @@ # ------------------------------------------------------------------------------ Android/.gradle/ Android/**/.gradle/ +Android/.gradle-tmp/ Android/build/ Android/**/build/ Android/.kotlin/ diff --git a/Android/app/src/androidTest/java/com/dct/app/background/BackgroundMeasurementServiceTest.kt b/Android/app/src/androidTest/java/com/dct/app/background/BackgroundMeasurementServiceTest.kt new file mode 100644 index 0000000..a1006d5 --- /dev/null +++ b/Android/app/src/androidTest/java/com/dct/app/background/BackgroundMeasurementServiceTest.kt @@ -0,0 +1,210 @@ +package com.dct.app.background + +import android.app.Notification +import android.app.NotificationManager +import android.os.SystemClock +import androidx.activity.ComponentActivity +import androidx.lifecycle.Lifecycle +import androidx.test.core.app.ActivityScenario +import androidx.test.core.app.ApplicationProvider +import androidx.test.platform.app.InstrumentationRegistry +import com.dct.app.DctApplication +import com.dct.app.data.logger.CsvMeasurementLogger +import com.dct.app.data.logger.Mf4MeasurementLogger +import com.dct.app.data.protocol.DctPacketCodec +import com.dct.app.data.protocol.SlidingRingBufferParser +import com.dct.app.domain.model.chart.AxisPosition +import com.dct.app.domain.model.chart.ChartSignalSeries +import com.dct.app.domain.model.protocol.DctPacket +import com.dct.app.domain.repository.DeviceConnectionState +import com.dct.app.presentation.main.MainUiState +import com.dct.app.presentation.main.MeasurementRecordingState +import kotlinx.coroutines.flow.MutableStateFlow +import org.junit.Assert.* +import org.junit.Test +import java.io.File +import java.net.InetAddress +import java.net.ServerSocket +import java.net.Socket +import java.nio.ByteBuffer +import java.nio.ByteOrder +import java.util.concurrent.CopyOnWriteArrayList +import java.util.concurrent.atomic.AtomicInteger +import java.util.concurrent.atomic.AtomicReference +import kotlin.concurrent.thread + +/** Local protocol fixture only: no simulation code or alternate network path in the app. */ +class BackgroundMeasurementServiceTest { + private val instrumentation = InstrumentationRegistry.getInstrumentation() + private val app = ApplicationProvider.getApplicationContext() + + @Test + fun backgroundKeepsReceivingAckGraphAndCsvThenStopKeepsTransport() = runSession(screenOff = false) + + @Test + fun screenOffKeepsReceivingAckGraphAndCsvThenStopKeepsTransport() = runSession(screenOff = true) + + private fun runSession(screenOff: Boolean) { + val scenario = ActivityScenario.launch(ComponentActivity::class.java) + val server = ServerSocket(0, 1, InetAddress.getByName("127.0.0.1")) + val peer = AtomicReference() + val peerError = AtomicReference() + val acknowledgements = AtomicInteger() + val starts = AtomicInteger() + val stops = AtomicInteger() + val codec = DctPacketCodec() + val directory = File(app.cacheDir, "background-regression-${System.nanoTime()}") + val logger = CsvMeasurementLogger(directory, listOf("sm_01_01" to "C"), "background.csv") + val mf4 = Mf4MeasurementLogger(directory, listOf("sm_01_01" to "C"), "background.mf4") + val session = app.session + lateinit var series: ChartSignalSeries + val peerThread = thread(name = "BackgroundProtocolFixture", isDaemon = true) { + try { + val socket = server.accept() + peer.set(socket) + val parser = SlidingRingBufferParser() + val input = socket.getInputStream() + val bytes = ByteArray(4096) + while (true) { + val count = input.read(bytes) + if (count < 0) break + for (packet in parser.parseStream(bytes, count)) { + when (packet.command.toInt() and 0xFF) { + 0x20 -> { starts.incrementAndGet(); send(socket, codec.encode(DctPacket(0xA0.toByte(), byteArrayOf(1, packet.payload.single())))) } + 0x21 -> { stops.incrementAndGet(); send(socket, codec.encode(DctPacket(0xA1.toByte(), byteArrayOf(1)))) } + 0x24 -> { assertArrayEquals(byteArrayOf(1), packet.payload); acknowledgements.incrementAndGet() } + 0x05 -> send(socket, codec.encode(DctPacket(0x85.toByte(), byteArrayOf(1)))) + } + } + } + } catch (error: Throwable) { + if (!server.isClosed) peerError.set(error) + } + } + try { + instrumentation.runOnMainSync { + session.onUiStarted() + assertTrue(session.ensureForegroundService()) + session.viewModel.chartRenderer.replaceSignalBindings(listOf( + ChartSignalSeries("sm_01_01", "sm_01_01", AxisPosition.LEFT_0, 0x0000FF) + )) + series = session.viewModel.chartRenderer.seriesListFlow.value.single() + session.viewModel.wifiSocketManager.connect("127.0.0.1", server.localPort) + } + await { session.serviceRunning.value && session.viewModel.wifiSocketManager.isConnected.value && peer.get() != null } + // Fixture bypasses equipment setup only, exercising the real START/STOP transactions. + session.viewModel.javaClass.getDeclaredField("configurationRequired").apply { + isAccessible = true; setBoolean(session.viewModel, false) + } + field>(session.viewModel.packetRepository, "_connectionState").value = DeviceConnectionState.READY + instrumentation.runOnMainSync { session.viewModel.startMeasure(30) } + await { session.viewModel.uiState.value.isMeasuring && starts.get() == 1 } + assertNotNull(logger.start()) + assertNotNull(mf4.start()) + field>(session.viewModel, "activeCsvLoggers").add(logger) + field>(session.viewModel, "activeMf4Loggers").add(mf4) + val ui = field>(session.viewModel, "_uiState") + instrumentation.runOnMainSync { + ui.value = ui.value.copy(recordingState = MeasurementRecordingState.RECORDING, + recordingStartedAtMillis = System.currentTimeMillis(), recordingSampleCount = 0, packetCount = 0) + } + await { session.executionState.value.recording } + fun transmit(first: Int, last: Int) { + for (value in first..last) { + val frame = codec.encode(DctPacket(0xA4.toByte(), byteArrayOf(1, 0, 1, 0, 0, value.toByte(), 0, 0))) + // Also check a fragmented frame while using the production parser/writer. + if (value % 7 == 0) { + send(peer.get(), frame.copyOfRange(0, 3)) + send(peer.get(), frame.copyOfRange(3, frame.size)) + } else send(peer.get(), frame) + SystemClock.sleep(33) + } + await { acknowledgements.get() == last && ui.value.recordingSampleCount == last.toLong() } + assertEquals(last.toDouble(), series.buffer.getLatestValue()!!, 0.0001) + } + transmit(1, 10) + val original = session.viewModel + scenario.recreate() + assertSame(original, app.session.viewModel) + scenario.moveToState(Lifecycle.State.CREATED) + instrumentation.runOnMainSync { session.onUiStopped() } + transmit(11, 40) + assertTrue(session.serviceRunning.value) + if (screenOff) shell("input keyevent KEYCODE_SLEEP") + transmit(41, 60) + assertEquals(60, series.buffer.toList().count { it.second.isFinite() }) + val notifications = app.getSystemService(NotificationManager::class.java).activeNotifications + notifications.firstOrNull { it.id == MeasurementForegroundService.NOTIFICATION_ID }?.let { + assertTrue(it.notification.flags and Notification.FLAG_ONGOING_EVENT != 0) + assertEquals("DCT · 계측 및 기록 중", it.notification.extras.getString(Notification.EXTRA_TITLE)) + } // Drawer visibility is user-controlled; do not grant notification permission silently. + assertTrue(heldWakeLocks().contains("DCT:MeasurementSession")) + val notification = MeasurementForegroundService.createNotification(app, session.executionState.value) + assertEquals("DCT · 계측 및 기록 중", notification.extras.getString(Notification.EXTRA_TITLE)) + assertTrue(notification.flags and Notification.FLAG_ONGOING_EVENT != 0) + assertEquals(listOf("계측 정지", "연결 해제"), notification.actions.map { it.title.toString() }) + notification.actions[0].actionIntent.send() + await { !ui.value.isMeasuring && stops.get() == 1 } + assertTrue(session.viewModel.wifiSocketManager.isConnected.value) + assertTrue(session.serviceRunning.value) + // Validate sample rows independently of the multi-row header. + assertEquals(60, logger.outputFile!!.readLines().count { + Regex("^[0-9]+\\.[0-9]+,").containsMatchIn(it) + }) + val mf4Bytes = mf4.outputFile!!.readBytes() + fun block(name: String) = (0..mf4Bytes.size - 4).first { + mf4Bytes.copyOfRange(it, it + 4).contentEquals(name.toByteArray(Charsets.US_ASCII)) + } + val binary = ByteBuffer.wrap(mf4Bytes).order(ByteOrder.LITTLE_ENDIAN) + assertEquals(60L, binary.getLong(block("##CG") + 32)) + val dataBlock = block("##DT") + assertEquals(24L + 60 * 16, binary.getLong(dataBlock + 8)) + assertEquals(60.0, binary.getDouble(dataBlock + 24 + 59 * 16 + 8), 0.0001) + instrumentation.runOnMainSync { session.viewModel.startMeasure(30) } + await { ui.value.isMeasuring && starts.get() == 2 } + notification.actions[1].actionIntent.send() + await { !session.viewModel.wifiSocketManager.isConnected.value && !session.serviceRunning.value } + if (!screenOff) assertFalse(heldWakeLocks().contains("DCT:MeasurementSession")) + // Screen-off may disconnect wireless ADB/UiAutomation. Check cleanup via + // serviceRunning above and via dumpsys after reconnect; the awake test checks OS-held locks. + assertNull(peerError.get()) + } finally { + if (screenOff) shell("input keyevent KEYCODE_WAKEUP") + server.close() + peer.get()?.close() + instrumentation.runOnMainSync { session.onUiStopped(); session.viewModel.disconnectCommunication() } + logger.stop() + mf4.stop() + scenario.close() + peerThread.join(2000) + // Delete only this test's uniquely named cache fixture, never project/measurement data. + directory.deleteRecursively() + } + } + + private fun send(socket: Socket, bytes: ByteArray) = synchronized(socket) { + socket.getOutputStream().apply { write(bytes); flush() } + } + + private fun await(condition: () -> Boolean) { + val deadline = SystemClock.elapsedRealtime() + 15_000 + while (!condition() && SystemClock.elapsedRealtime() < deadline) SystemClock.sleep(25) + assertTrue("Timed out waiting for session state", condition()) + } + + @Suppress("UNCHECKED_CAST") + private fun field(owner: Any, name: String): T = owner.javaClass.getDeclaredField(name).run { + isAccessible = true + get(owner) as T + } + + private fun shell(command: String): String = instrumentation.uiAutomation.executeShellCommand(command).use { + android.os.ParcelFileDescriptor.AutoCloseInputStream(it).bufferedReader().readText() + } + + private fun heldWakeLocks(): String { + val dump = shell("dumpsys power") + assertTrue("UiAutomation power dump unavailable", dump.contains("Wake Locks:")) + return dump.substringAfter("Wake Locks:").substringBefore("Suspend Blockers:") + } +} diff --git a/Android/app/src/androidTest/java/com/dct/app/presentation/dialog/ProjectDialogHoverTest.kt b/Android/app/src/androidTest/java/com/dct/app/presentation/dialog/ProjectDialogHoverTest.kt index d22ccf3..767bff4 100644 --- a/Android/app/src/androidTest/java/com/dct/app/presentation/dialog/ProjectDialogHoverTest.kt +++ b/Android/app/src/androidTest/java/com/dct/app/presentation/dialog/ProjectDialogHoverTest.kt @@ -18,6 +18,7 @@ import com.dct.app.presentation.project.NewProjectDialog import org.junit.Assert.assertEquals import org.junit.Rule import org.junit.Test +import java.io.File /** * Mouse and pen hover feed the same Compose HoverInteraction state layer. @@ -66,6 +67,27 @@ class ProjectDialogHoverTest { compose.onNode(isDialog()).assertDoesNotExist() } + @Test + fun openProjectFileCanBeSubmittedOnlyOnce() { + var selections = 0 + compose.setContent { + MaterialTheme { + OpenProjectDialog( + projectFiles = listOf(File("workspace.dpc")), + onSelectFile = { selections++ }, + onBrowseOther = {}, + onDismiss = {} + ) + } + } + + compose.onNodeWithText("workspace.dpc").performTouchInput { + click(center) + click(center) + } + compose.runOnIdle { assertEquals(1, selections) } + } + private fun assertStableHoverAndBodyTap(title: String) { val dialog = compose.onNode(isDialog()) val before = dialog.captureToImage().toPixelMap() diff --git a/Android/app/src/androidTest/java/com/dct/app/presentation/monitor/components/FormulaColorUiTest.kt b/Android/app/src/androidTest/java/com/dct/app/presentation/monitor/components/FormulaColorUiTest.kt new file mode 100644 index 0000000..bba73c7 --- /dev/null +++ b/Android/app/src/androidTest/java/com/dct/app/presentation/monitor/components/FormulaColorUiTest.kt @@ -0,0 +1,42 @@ +package com.dct.app.presentation.monitor.components + +import androidx.compose.ui.test.* +import androidx.compose.ui.test.junit4.createComposeRule +import com.dct.app.core.theme.SignalColorPresets +import com.dct.app.domain.model.formula.FormulaDefinition +import org.junit.Assert.assertEquals +import org.junit.Rule +import org.junit.Test + +class FormulaColorUiTest { + @get:Rule val compose = createComposeRule() + + @Test fun all24PresetsExistAndNewPresetIsSavedInPcFormat() { + var saved: FormulaDefinition? = null + compose.setContent { + FormulaEditDialog(initialFormula = FormulaDefinition(name = "F", expression = "2"), + onDismiss = {}, onSave = { saved = it }) + } + SignalColorPresets.colors.forEach { color -> + compose.onNodeWithTag("FormulaColorPreset_${color.toString(16).padStart(6, '0')}").assertExists() + } + val color = SignalColorPresets.colors.last() + compose.onNodeWithTag("FormulaColorPreset_${color.toString(16).padStart(6, '0')}").performScrollTo().performClick() + compose.onNodeWithText("저장").performScrollTo().performClick() + compose.runOnIdle { assertEquals(color, saved?.color) } + } + + @Test fun customColorPickerOpensAndCancelLeavesOriginalColor() { + var saved: FormulaDefinition? = null + val initial = SignalColorPresets.colors.first() + compose.setContent { + FormulaEditDialog(initialFormula = FormulaDefinition(name = "F", expression = "2", color = initial), + onDismiss = {}, onSave = { saved = it }) + } + compose.onNodeWithText("사용자 지정 색상").performScrollTo().performClick() + compose.onNodeWithText("Signal Color").assertExists() + compose.onNodeWithText("Cancel").performClick() + compose.onNodeWithText("저장").performScrollTo().performClick() + compose.runOnIdle { assertEquals(initial, saved?.color) } + } +} diff --git a/Android/app/src/androidTest/java/com/dct/app/presentation/monitor/components/FormulaColumnsUiTest.kt b/Android/app/src/androidTest/java/com/dct/app/presentation/monitor/components/FormulaColumnsUiTest.kt new file mode 100644 index 0000000..27982e5 --- /dev/null +++ b/Android/app/src/androidTest/java/com/dct/app/presentation/monitor/components/FormulaColumnsUiTest.kt @@ -0,0 +1,258 @@ +package com.dct.app.presentation.monitor.components + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.semantics.SemanticsProperties +import androidx.compose.ui.semantics.SemanticsActions +import androidx.compose.ui.test.* +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.unit.dp +import androidx.compose.ui.graphics.toPixelMap +import com.dct.app.core.theme.SignalColorPresets +import com.dct.app.domain.model.formula.FormulaDefinition +import com.dct.app.presentation.chart.DraggedSignal +import com.dct.app.presentation.chart.SignalDragDropManager +import com.dct.app.presentation.signaltree.WindowUiStateProvider +import org.junit.Assert.* +import org.junit.Rule +import org.junit.Test + +class FormulaColumnsUiTest { + @get:Rule val compose = createComposeRule() + private val formula = FormulaDefinition(id = "f", name = "Long Formula Name", expression = "Torque*2", unit = "kW") + + @Test fun nameAndColorAreSeparateAndEveryColumnSharesHeaderAndRowWidth() { + val widths = linkedMapOf("name" to "170.0", "value" to "90.0", "unit" to "65.0", "color" to "70.0", "expression" to "180.0") + compose.setContent { + WindowUiStateProvider("formula", widths.mapKeys { "width:${it.key}" }, {}) { + Box(Modifier.size(1000.dp, 300.dp)) { + FormulaWindowContent(listOf(formula), onAddFormula = {}, onUpdateFormula = {}, onDeleteFormula = {}) + } + } + } + compose.onNodeWithTag("FormulaCell_name_f", useUnmergedTree = true).onChildren().filterToOne(hasText(formula.name)).assertExists() + compose.onNodeWithTag("FormulaCell_unit_f", useUnmergedTree = true).onChildren().filterToOne(hasText(formula.unit)).assertExists() + compose.onNodeWithText("Expand").assertDoesNotExist() + compose.onNodeWithText("Collapse").assertDoesNotExist() + compose.onNodeWithText("Act").assertDoesNotExist() + compose.onNodeWithTag("FormulaHeader_act").assertDoesNotExist() + compose.onNodeWithTag("FormulaCell_act_f", useUnmergedTree = true).assertDoesNotExist() + compose.onNodeWithTag("FormulaActions_f").assertIsDisplayed() + widths.forEach { (key, width) -> + compose.onNodeWithTag("FormulaHeader_$key").assertWidthIsEqualTo(width.toFloat().dp) + compose.onNodeWithTag("FormulaCell_${key}_f", useUnmergedTree = true).assertWidthIsEqualTo(width.toFloat().dp) + } + val keys = widths.keys.toList() + keys.zipWithNext().forEach { (left, right) -> + val leftHeader = compose.onNodeWithTag("FormulaHeader_$left").fetchSemanticsNode().boundsInRoot + val rightHeader = compose.onNodeWithTag("FormulaHeader_$right").fetchSemanticsNode().boundsInRoot + val leftCell = compose.onNodeWithTag("FormulaCell_${left}_f", useUnmergedTree = true).fetchSemanticsNode().boundsInRoot + val rightCell = compose.onNodeWithTag("FormulaCell_${right}_f", useUnmergedTree = true).fetchSemanticsNode().boundsInRoot + assertTrue("Header order: $left before $right", leftHeader.left < rightHeader.left) + assertTrue("Row order: $left before $right", leftCell.left < rightCell.left) + } + } + + @Test fun actionsStayPinnedWhileHorizontalAndVerticalScrolling() { + val rows = (0..20).map { formula.copy(id = "f$it", name = "Formula $it") } + compose.setContent { + WindowUiStateProvider("formula", mapOf("width:expression" to "1200.0", "width:act" to "1200.0"), {}) { + Box(Modifier.size(300.dp, 250.dp)) { + FormulaWindowContent(rows, onAddFormula = {}, onUpdateFormula = {}, onDeleteFormula = {}) + } + } + } + val before = compose.onNodeWithTag("FormulaActions_f0").fetchSemanticsNode().boundsInRoot + compose.onNodeWithText("Act").assertDoesNotExist() + compose.onNodeWithTag("FormulaActions_f0").assertWidthIsEqualTo(48.dp).assertHeightIsEqualTo(48.dp).assertIsDisplayed() + compose.onNodeWithTag("FormulaViewport_f0").performTouchInput { swipeLeft(durationMillis = 200) } + val after = compose.onNodeWithTag("FormulaActions_f0").fetchSemanticsNode().boundsInRoot + assertEquals(before, after) + val headerScroll = compose.onNodeWithTag("FormulaHeaderViewport").fetchSemanticsNode() + .config[SemanticsProperties.HorizontalScrollAxisRange].value() + val rowScroll = compose.onNodeWithTag("FormulaViewport_f0").fetchSemanticsNode() + .config[SemanticsProperties.HorizontalScrollAxisRange].value() + assertTrue(headerScroll > 0f) + assertEquals(headerScroll, rowScroll, 1f) + compose.onNodeWithTag("FormulaHeaderViewport").performSemanticsAction(SemanticsActions.ScrollBy) { it(10000f, 0f) } + val expressionEnd = compose.onNodeWithTag("FormulaCell_expression_f0", useUnmergedTree = true).fetchSemanticsNode().boundsInRoot.right + val maskStart = compose.onNodeWithTag("FormulaMenuBackdrop_f0").fetchSemanticsNode().boundsInRoot.left + assertTrue("Last expression must scroll clear of the fade mask", expressionEnd <= maskStart + 1f) + val finalScroll = compose.onNodeWithTag("FormulaHeaderViewport").fetchSemanticsNode() + .config[SemanticsProperties.HorizontalScrollAxisRange].value() + compose.onNodeWithTag("FormulaRows").performScrollToIndex(10) + compose.onNodeWithTag("FormulaActions_f10").assertIsDisplayed() + val scrolled = compose.onNodeWithTag("FormulaActions_f10").fetchSemanticsNode().boundsInRoot + assertEquals(before.left, scrolled.left, 1f) + assertEquals(before.right, scrolled.right, 1f) + assertEquals(finalScroll, compose.onNodeWithTag("FormulaViewport_f10").fetchSemanticsNode() + .config[SemanticsProperties.HorizontalScrollAxisRange].value(), 1f) + compose.onNodeWithTag("FormulaActions_f10").performClick() + compose.onNodeWithText("수식 삭제").performClick() + compose.onNodeWithText("‘Formula 10’ 수식을 삭제하시겠습니까?").assertExists() + } + + @Test fun menuBackdropSoftensTextAreaAndMatchesEachRowBackground() { + val rows = listOf(formula, formula.copy(id = "second", name = "Second")) + compose.setContent { + Box(Modifier.size(300.dp, 250.dp)) { + FormulaWindowContent(rows, onAddFormula = {}, onUpdateFormula = {}, onDeleteFormula = {}) + } + } + compose.onNodeWithText("Act").assertDoesNotExist() + rows.forEachIndexed { index, row -> + val backdrop = compose.onNodeWithTag("FormulaMenuBackdrop_${row.id}") + backdrop.assertWidthIsEqualTo(82.dp) + val pixels = backdrop.captureToImage().toPixelMap() + val fade = pixels[(pixels.width * 0.25f).toInt(), (pixels.height * 0.15f).toInt()] + val opaque = pixels[(pixels.width * 0.65f).toInt(), (pixels.height * 0.15f).toInt()] + // The Color cell below the transition still tints the fade, but never the button area. + assertTrue(fade.blue > fade.red + 0.01f) + val expected = if (index == 0) androidx.compose.ui.graphics.Color.White else androidx.compose.ui.graphics.Color(0xFFF8F9FA) + assertEquals(expected.red, opaque.red, 0.01f) + assertEquals(expected.green, opaque.green, 0.01f) + assertEquals(expected.blue, opaque.blue, 0.01f) + compose.onNodeWithTag("FormulaActions_${row.id}").assertIsDisplayed() + } + } + + @Test fun actionMenuEditsWithoutDeletingAndDeleteRequiresConfirmation() { + var updated: FormulaDefinition? = null + val deleted = mutableListOf() + compose.setContent { + Box(Modifier.size(300.dp, 250.dp)) { + FormulaWindowContent(listOf(formula), onAddFormula = {}, onUpdateFormula = { updated = it }, + onDeleteFormula = { deleted.add(it) }) + } + } + compose.onNodeWithTag("FormulaActions_f").performClick() + compose.onNodeWithText("수식 수정").performClick() + compose.onNodeWithText("저장").performScrollTo().performClick() + compose.runOnIdle { assertEquals(formula, updated); assertTrue(deleted.isEmpty()) } + compose.onNodeWithTag("FormulaActions_f").performClick() + compose.onNodeWithText("수식 삭제").performClick() + compose.runOnIdle { assertTrue(deleted.isEmpty()) } + compose.onNodeWithText("‘${formula.name}’ 수식을 삭제하시겠습니까?").assertExists() + compose.onNodeWithText("취소").performClick() + compose.runOnIdle { assertTrue(deleted.isEmpty()) } + compose.onNodeWithTag("FormulaConfirmDelete").assertDoesNotExist() + compose.onNodeWithTag("FormulaActions_f").performClick() + compose.onNodeWithText("수식 삭제").performClick() + compose.onNodeWithTag("FormulaConfirmDelete").performClick() + compose.runOnIdle { assertEquals(listOf(formula), deleted) } + compose.onNodeWithTag("FormulaConfirmDelete").assertDoesNotExist() + } + + @Test fun pendingDeleteTracksLatestDefinitionAndDisappearsIfRemoved() { + val rows = mutableStateOf(listOf(formula)) + val deleted = mutableListOf() + compose.setContent { + Box(Modifier.size(300.dp, 250.dp)) { + FormulaWindowContent(rows.value, onAddFormula = {}, onUpdateFormula = {}, onDeleteFormula = { deleted.add(it) }) + } + } + compose.onNodeWithTag("FormulaActions_f").performClick() + compose.onNodeWithText("수식 삭제").performClick() + val changed = formula.copy(name = "Renamed", expression = "Torque*3") + compose.runOnIdle { rows.value = listOf(changed) } + compose.onNodeWithText("‘Renamed’ 수식을 삭제하시겠습니까?").assertExists() + compose.onNodeWithTag("FormulaConfirmDelete").performClick() + compose.runOnIdle { assertEquals(listOf(changed), deleted) } + compose.onNodeWithTag("FormulaActions_f").performClick() + compose.onNodeWithText("수식 삭제").performClick() + compose.runOnIdle { rows.value = emptyList() } + compose.onNodeWithTag("FormulaConfirmDelete").assertDoesNotExist() + compose.runOnIdle { assertEquals(listOf(changed), deleted) } + } + + @Test fun scrolledDataStillLongPressDragsAtTheVisibleTouchPosition() { + var dropped: DraggedSignal? = null + var droppedAt: Offset? = null + compose.setContent { + Box(Modifier.size(300.dp, 250.dp)) { + FormulaWindowContent(listOf(formula), onAddFormula = {}, onUpdateFormula = {}, onDeleteFormula = {}, + onSignalDrop = { dropped = it; droppedAt = SignalDragDropManager.dragPosition }) + } + } + compose.onNodeWithTag("FormulaViewport_f").performTouchInput { swipeLeft(durationMillis = 200) } + val bounds = compose.onNodeWithTag("FormulaViewport_f").fetchSemanticsNode().boundsInRoot + compose.runOnIdle { + SignalDragDropManager.registerDropTarget("formula-regression", Rect(bounds.left, bounds.top, bounds.right, bounds.bottom + 100f)) + } + try { + compose.onNodeWithTag("FormulaViewport_f").performTouchInput { + down(center) + advanceEventTime(700) + moveBy(Offset(0f, 30f)) + up() + } + compose.runOnIdle { + assertEquals("formula_${formula.name}", dropped?.signalId) + assertEquals(formula.unit, dropped?.unit) + assertEquals(bounds.center.x, requireNotNull(droppedAt).x, 1f) + assertFalse(SignalDragDropManager.isDragging) + } + } finally { + compose.runOnIdle { + SignalDragDropManager.unregisterDropTarget("formula-regression") + SignalDragDropManager.cancelDrag() + } + } + } + + @Test fun oldColumnSettingsKeepWidthsAndUseDefaultUnitWidth() { + compose.setContent { + WindowUiStateProvider("formula", mapOf("width:name" to "140.0", "width:color" to "75.0"), {}) { + Box(Modifier.size(800.dp, 300.dp)) { + FormulaWindowContent(listOf(formula.copy(unit = "")), onAddFormula = {}, onUpdateFormula = {}, onDeleteFormula = {}) + } + } + } + compose.onNodeWithTag("FormulaHeader_name").assertWidthIsEqualTo(140.dp) + compose.onNodeWithTag("FormulaHeader_color").assertWidthIsEqualTo(75.dp) + compose.onNodeWithTag("FormulaHeader_unit").assertWidthIsEqualTo(45.dp) + compose.onNodeWithTag("FormulaCell_unit_f", useUnmergedTree = true).assertWidthIsEqualTo(45.dp) + compose.onNodeWithTag("FormulaCell_value_f", useUnmergedTree = true).onChildren().filterToOne(hasText("No value")).assertExists() + compose.onNodeWithText("Expand").assertDoesNotExist() + } + + @Test fun dividerDragChangesOnlyNameWidthAndRestoresAfterRecreation() { + var saved = emptyMap() + val visible = mutableStateOf(true) + compose.setContent { + if (visible.value) WindowUiStateProvider("formula", saved, { saved = it }) { + Box(Modifier.size(800.dp, 300.dp)) { + FormulaWindowContent(listOf(formula), onAddFormula = {}, onUpdateFormula = {}, onDeleteFormula = {}) + } + } + } + compose.onNodeWithTag("FormulaHeader_name").performTouchInput { + swipe(Offset(right - 3f, centerY), Offset(right + 70f, centerY), 300) + } + compose.runOnIdle { assertTrue(saved.getValue("width:name").toFloat() > 110f); assertFalse("width:color" in saved) } + compose.onNodeWithTag("FormulaCell_name_f", useUnmergedTree = true).assertWidthIsEqualTo(saved.getValue("width:name").toFloat().dp) + compose.runOnIdle { visible.value = false } + compose.waitForIdle() + compose.runOnIdle { visible.value = true } + compose.onNodeWithTag("FormulaHeader_name").assertWidthIsEqualTo(saved.getValue("width:name").toFloat().dp) + compose.onNodeWithTag("FormulaCell_name_f", useUnmergedTree = true).assertWidthIsEqualTo(saved.getValue("width:name").toFloat().dp) + } + + @Test fun colorCellAppliesNewColorWithoutChangingNameOrExpression() { + var updated: FormulaDefinition? = null + compose.setContent { + Box(Modifier.size(800.dp, 300.dp)) { + FormulaWindowContent(listOf(formula), onAddFormula = {}, onUpdateFormula = { updated = it }, onDeleteFormula = {}) + } + } + compose.onNodeWithTag("FormulaCell_color_f", useUnmergedTree = true).onChildren().filterToOne(hasClickAction()).performClick() + val color = SignalColorPresets.colors.last() + compose.onNodeWithTag("SignalColorPreset_${color.toString(16).padStart(6, '0')}").performScrollTo().performClick() + compose.onNodeWithText("Apply").performClick() + compose.runOnIdle { assertEquals(formula.copy(color = color), updated) } + } +} diff --git a/Android/app/src/androidTest/java/com/dct/app/presentation/monitor/components/GraphSettingsUiTest.kt b/Android/app/src/androidTest/java/com/dct/app/presentation/monitor/components/GraphSettingsUiTest.kt index 7439db9..a7b110b 100644 --- a/Android/app/src/androidTest/java/com/dct/app/presentation/monitor/components/GraphSettingsUiTest.kt +++ b/Android/app/src/androidTest/java/com/dct/app/presentation/monitor/components/GraphSettingsUiTest.kt @@ -70,19 +70,20 @@ class GraphSettingsUiTest { compose.runOnIdle { assertEquals(3, state.value.legendPosition) } } - @Test fun axisDialogRetainsPrecisionAndRejectsNanWithoutDismissing() { + @Test fun axisDialogUsesPcIntegerBoundsAndRejectsInvalidInputWithoutDismissing() { var saved: Pair? = null compose.setContent { - GraphYAxisDialog("A", .012345, .098765, false, {}, { min,max,_ -> saved = min to max }) + GraphYAxisDialog("A", 10.9, 99.9, false, {}, { min,max,_ -> saved = min to max }) } - compose.onNodeWithText("0.012345").assertExists() + compose.onNodeWithText("10").assertExists() + compose.onNodeWithText("99").assertExists() compose.onAllNodes(hasSetTextAction())[1].performTextReplacement("NaN") compose.onNodeWithText("OK").performClick() - compose.onNodeWithText("Please enter valid numbers.").assertIsDisplayed() + compose.onNodeWithText("Please input the correct min/max value.").assertIsDisplayed() compose.runOnIdle { assertNull(saved) } - compose.onAllNodes(hasSetTextAction())[1].performTextReplacement("0.012345") + compose.onAllNodes(hasSetTextAction())[1].performTextReplacement("99") compose.onNodeWithText("OK").performClick() - compose.runOnIdle { assertEquals(.012345 to .098765, saved) } + compose.runOnIdle { assertEquals(10.0 to 99.0, saved) } } @Test fun outOfRangeCurvesAreClippedInsteadOfFlattenedOntoAxesForBothGraphTypes() { diff --git a/Android/app/src/androidTest/java/com/dct/app/presentation/monitor/components/RibbonButtonUiTest.kt b/Android/app/src/androidTest/java/com/dct/app/presentation/monitor/components/RibbonButtonUiTest.kt new file mode 100644 index 0000000..1d891ef --- /dev/null +++ b/Android/app/src/androidTest/java/com/dct/app/presentation/monitor/components/RibbonButtonUiTest.kt @@ -0,0 +1,62 @@ +package com.dct.app.presentation.monitor.components + +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.BarChart +import androidx.compose.runtime.mutableStateOf +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.dp +import androidx.compose.ui.test.* +import androidx.compose.ui.test.junit4.createComposeRule +import org.junit.Assert.assertEquals +import org.junit.Rule +import org.junit.Test + +class RibbonButtonUiTest { + @get:Rule val compose = createComposeRule() + + @Test fun allThreeLabelsRemainClickableAndScrollableWithLargerFont() { + val labels = listOf("Graph", "Alphanumerical", "MultiAxisGraph") + val clicks = mutableListOf() + compose.setContent { + val density = LocalDensity.current.density + CompositionLocalProvider(LocalDensity provides Density(density, 1.3f)) { + Row(Modifier.width(220.dp).height(44.dp).horizontalScroll(rememberScrollState())) { + labels.forEach { label -> + RibbonButton(Icons.Default.BarChart, label, label = label, + onClick = { clicks += label }) + } + } + } + } + labels.forEach { label -> + compose.onNodeWithText(label).performScrollTo().assertIsDisplayed().assertHasClickAction().performClick() + } + compose.runOnIdle { assertEquals(labels, clicks) } + } + + @Test fun disabledLabelCannotRunActionAndIconOnlyToolsStayUnchanged() { + val enabled = mutableStateOf(false) + var clicks = 0 + compose.setContent { + Row { + RibbonButton(Icons.Default.BarChart, "New Graph Window", label = "Graph", + enabled = enabled.value, onClick = { clicks++ }) + RibbonButton(Icons.Default.BarChart, "Save", onClick = { clicks++ }) + } + } + compose.onNodeWithText("Graph").assertIsNotEnabled().performClick() + compose.runOnIdle { assertEquals(0, clicks); enabled.value = true } + compose.onNodeWithText("Graph").assertIsEnabled().performClick() + compose.onNodeWithContentDescription("Save").assertHasClickAction().performClick() + compose.onNodeWithText("Save").assertDoesNotExist() + compose.runOnIdle { assertEquals(2, clicks) } + } +} diff --git a/Android/app/src/androidTest/java/com/dct/app/presentation/monitor/components/WorkspaceTouchNavigationUiTest.kt b/Android/app/src/androidTest/java/com/dct/app/presentation/monitor/components/WorkspaceTouchNavigationUiTest.kt index d53cfe4..43b1d0e 100644 --- a/Android/app/src/androidTest/java/com/dct/app/presentation/monitor/components/WorkspaceTouchNavigationUiTest.kt +++ b/Android/app/src/androidTest/java/com/dct/app/presentation/monitor/components/WorkspaceTouchNavigationUiTest.kt @@ -4,14 +4,15 @@ import androidx.compose.foundation.ScrollState import androidx.compose.foundation.clickable import androidx.compose.foundation.gestures.detectDragGestures import androidx.compose.foundation.gestures.ScrollableDefaults -import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.size import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Rect import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.input.nestedscroll.NestedScrollConnection import androidx.compose.ui.input.nestedscroll.NestedScrollSource @@ -36,21 +37,48 @@ class WorkspaceTouchNavigationUiTest { private var drags = 0 private var activations = 0 private lateinit var innerScroll: ScrollState + private var movingWindowBounds = Rect(-200f, -200f, -100f, -100f) - private fun content(graph: Boolean = false, multi: Boolean = false, header: Boolean = false, table: Boolean = false) { + private fun content( + graph: Boolean = false, + multi: Boolean = false, + header: Boolean = false, + table: Boolean = false, + reserveWindowSurface: Boolean = false, + movingWindowSurface: Boolean = false, + canvasEnabled: Boolean = true + ) { compose.setContent { - x = remember { ScrollState(150) } - y = remember { ScrollState(150) } + x = remember { ScrollState(if (canvasEnabled) 150 else 0) } + y = remember { ScrollState(if (canvasEnabled) 150 else 0) } innerScroll = remember { ScrollState(10) } val navigation = remember { WorkspaceTouchNavigation() } val horizontalFling = ScrollableDefaults.flingBehavior() val verticalFling = ScrollableDefaults.flingBehavior() CompositionLocalProvider(LocalWorkspaceTouchNavigation provides navigation) { + if (movingWindowSurface) { + DisposableEffect(navigation) { + navigation.registerWindowTouchRegion("moving-window") { movingWindowBounds } + onDispose { navigation.unregisterWindowTouchRegion("moving-window") } + } + } + if (reserveWindowSurface) { + // Mirrors MdiSubWindow's persistent root-bounds + // registration. The synthetic window starts at the + // viewport origin and is larger than the viewport. + DisposableEffect(navigation) { + navigation.registerWindowTouchRegion("test-window") { + Rect(0f, 0f, 900f, 700f) + } + onDispose { navigation.unregisterWindowTouchRegion("test-window") } + } + } Box(Modifier.size(400.dp, 300.dp).testTag("viewport") - .workspaceTouchNavigation(navigation, x, y, horizontalFling, verticalFling) - .horizontalScroll(x, flingBehavior = horizontalFling) - .verticalScroll(y, flingBehavior = verticalFling)) { - Box(Modifier.size(900.dp, 700.dp).bringWindowToFrontOnPress("window") { activations++ }) { + .workspaceCanvasScroll(canvasEnabled, navigation, x, y, horizontalFling, verticalFling)) { + val windowModifier = Modifier + .size(900.dp, 700.dp) + .then(if (reserveWindowSurface) Modifier.windowTouchScrollBarrier("test-window") else Modifier) + Box(windowModifier.testTag("window").bringWindowToFrontOnPress("window") { activations++ }) { if (graph) { MultiXGraphCanvas(ChartRenderSnapshot(0.0, 60.0, emptyList(), emptyMap(), legendPosition = 3), Modifier.size(900.dp, 700.dp), isMultiAxis = multi, @@ -65,7 +93,9 @@ class WorkspaceTouchNavigationUiTest { } } else if (header) { Box(Modifier.size(900.dp, 700.dp).pointerInput(Unit) { - detectDragGestures { change, _ -> change.consume(); drags++ } + detectDragGestures(onDragStart = { navigation.claimSinglePointerDrag() }) { change, _ -> + change.consume(); drags++ + } }) } else { Box(Modifier.size(900.dp, 700.dp).clickable { taps++ }) @@ -76,6 +106,82 @@ class WorkspaceTouchNavigationUiTest { } } + @Test fun emptyCanvasClampsContentAndPreservesTapWithSmallFingerMovement() { + content(canvasEnabled = false) + val viewport = compose.onNodeWithTag("viewport").fetchSemanticsNode().boundsInRoot + val content = compose.onNodeWithTag("window").fetchSemanticsNode().boundsInRoot + assertEquals(viewport.width, content.width, 0.1f) + assertEquals(viewport.height, content.height, 0.1f) + compose.onNodeWithTag("viewport").performTouchInput { + down(center) + moveTo(center + Offset(3f, 2f), delayMillis = 180) + up() + } + compose.runOnIdle { + assertEquals(1, taps) + assertEquals(0, x.value) + assertEquals(0, y.value) + } + } + + @Test fun emptyCanvasDoesNotPanWithOneOrTwoFingers() { + content(canvasEnabled = false) + compose.onNodeWithTag("viewport").performTouchInput { + down(center) + moveTo(center - Offset(100f, 80f), delayMillis = 80) + up() + } + compose.onNodeWithTag("viewport").performTouchInput { + val first = center - Offset(50f, 0f) + val second = center + Offset(50f, 0f) + down(0, first) + down(1, second) + for (i in 1..5) { + updatePointerTo(0, first - Offset(i * 15f, i * 10f)) + updatePointerTo(1, second - Offset(i * 15f, i * 10f)) + move() + } + up(1) + moveBy(0, Offset(-60f, -60f)) + up(0) + } + compose.runOnIdle { + assertEquals(0, x.value) + assertEquals(0, y.value) + assertEquals(0, pans) + assertEquals(0, zooms) + } + } + + private fun slowSingleFingerDrag() { + compose.onNodeWithTag("viewport").performTouchInput { + val start = center + down(start) + // Real fingers cross touch slop over many MOVE events, unlike + // the old tests' first jump of 60-140 px. + for (i in 1..60) moveTo(start - Offset(0f, i * 2f), delayMillis = 8) + up() + } + } + + @Test fun slowHeaderDragCrossesSlopWithoutBeingCancelled() { + content(header = true, reserveWindowSurface = true) + slowSingleFingerDrag() + compose.runOnIdle { + assertTrue("Slow title drag must reach the window handler", drags > 0) + assertEquals(150, x.value); assertEquals(150, y.value) + } + } + + @Test fun slowInnerScrollCrossesSlopWithoutBeingCancelled() { + content(table = true, reserveWindowSurface = true) + slowSingleFingerDrag() + compose.runOnIdle { + assertTrue("Slow drag must scroll the inner list", innerScroll.value > 10) + assertEquals(150, x.value); assertEquals(150, y.value) + } + } + private fun translateWithOneFingerTail() { compose.onNodeWithTag("viewport").performTouchInput { val a = center - Offset(50f, 0f) @@ -113,11 +219,187 @@ class WorkspaceTouchNavigationUiTest { content(graph = true, multi = true) translateWithOneFingerTail() } - @Test fun twoFingersOverHeaderCannotMoveWindow() { - content(header = true) + + private fun translateAfterFirstFingerStartsScrolling() { + val start = compose.onNodeWithTag("viewport").fetchSemanticsNode().boundsInRoot.size + val a = Offset(start.width * .5f, start.height * .6f) + val drift = Offset(-45f, -45f) + compose.onNodeWithTag("viewport").performTouchInput { + down(0, a) + // Fingers rarely land in the same event. A scroll can already have + // consumed movement when the second finger reaches the screen. + moveTo(0, a + drift, delayMillis = 32) + } + val beforeX = x.value + val beforeY = y.value + val beforePans = pans + val beforeInner = innerScroll.value + val beforeActivations = activations + compose.onNodeWithTag("viewport").performTouchInput { + val b = a + drift + Offset(80f, 0f) + down(1, b) + for (i in 1..6) { + val delta = Offset(-i * 15f, -i * 10f) + updatePointerTo(0, a + drift + delta) + updatePointerTo(1, b + delta) + move() + } + advanceEventTime(300) + up(1); up(0) + } + compose.runOnIdle { + assertTrue("Second finger must take over horizontal canvas scroll", x.value > beforeX) + assertTrue("Second finger must take over vertical canvas scroll", y.value > beforeY) + assertEquals("Time axis must stop moving after the second finger lands", beforePans, pans) + assertEquals("Table must stop scrolling after the second finger lands", beforeInner, innerScroll.value) + assertEquals(0, zooms) + assertEquals(0, taps) + assertEquals("Two-finger takeover must not activate another window", beforeActivations, activations) + } + } + + @Test fun delayedSecondFingerOverGraphTakesOverCanvasScroll() { + content(graph = true) + translateAfterFirstFingerStartsScrolling() + } + + @Test fun delayedSecondFingerOverTableTakesOverCanvasScroll() { + content(table = true) + translateAfterFirstFingerStartsScrolling() + } + + @Test fun delayedSecondFingerOverReservedTableTakesOverCanvasScroll() { + content(table = true, reserveWindowSurface = true) + translateAfterFirstFingerStartsScrolling() + } + + @Test fun delayedSecondFingerOverCanvasTakesOverBothScrollAxes() { + content() + translateAfterFirstFingerStartsScrolling() + } + + @Test fun delayedSecondFingerOverMultiAxisGraphTakesOverCanvasScroll() { + content(graph = true, multi = true) + translateAfterFirstFingerStartsScrolling() + } + + @Test fun canvasPanContinuesWhenWindowMovesAcrossOriginalDownPoint() { + content(movingWindowSurface = true) + compose.onNodeWithTag("viewport").performTouchInput { + down(center) + for (i in 1..20) moveBy(Offset(-3f, -2f), delayMillis = 16) + } + compose.runOnIdle { + assertEquals(210, x.value) + assertEquals(190, y.value) + // Model a neighbouring window moving under the original DOWN point. + movingWindowBounds = Rect(0f, 0f, 10000f, 10000f) + } + compose.onNodeWithTag("viewport").performTouchInput { + for (i in 1..20) moveBy(Offset(-3f, -2f), delayMillis = 16) + } + compose.runOnIdle { + assertEquals(270, x.value) + assertEquals(230, y.value) + } + compose.onNodeWithTag("viewport").performTouchInput { up() } + } + + @Test fun oneFingerCanvasTracksExactDistanceBeforeRelease() { + content() + compose.onNodeWithTag("viewport").performTouchInput { + down(center) + for (i in 1..40) moveBy(Offset(-3f, -2f), delayMillis = 16) + } + compose.runOnIdle { + assertEquals("Horizontal movement must be applied once", 270, x.value) + assertEquals("Vertical movement must be applied once", 230, y.value) + } + compose.onNodeWithTag("viewport").performTouchInput { + for (i in 1..20) moveBy(Offset(3f, 2f), delayMillis = 16) + } + compose.runOnIdle { + assertEquals(210, x.value) + assertEquals(190, y.value) + } + compose.onNodeWithTag("viewport").performTouchInput { up() } + } + + @Test fun oneFingerDiagonalDragMovesWorkspaceOnBothAxes() { + content() + compose.onNodeWithTag("viewport").performTouchInput { + val start = center + down(0, start) + for (i in 1..8) { + updatePointerTo(0, start - Offset(i * 18f, i * 14f)) + move() + } + up(0) + } + compose.runOnIdle { + assertTrue("Diagonal drag must move the horizontal canvas", x.value > 150) + assertTrue("Diagonal drag must move the vertical canvas", y.value > 150) + assertEquals(0, taps) + } + } + + @Test fun nestedWindowScrollDoesNotMoveWorkspaceCanvas() { + content(table = true, reserveWindowSurface = true) + compose.onNodeWithTag("viewport").performTouchInput { + down(0, center) + moveTo(0, center - Offset(0f, 140f), delayMillis = 16) + moveTo(0, center - Offset(0f, 220f), delayMillis = 16) + up(0) + } + compose.runOnIdle { + assertEquals("Window scroll must not move horizontal canvas", 150, x.value) + assertEquals("Window scroll must not move vertical canvas", 150, y.value) + assertTrue("The nested window list should consume the vertical drag", innerScroll.value > 10) + } + } + + @Test fun anyGestureStartingInsideWindowDoesNotFallBackToCanvas() { + content(reserveWindowSurface = true) + compose.onNodeWithTag("viewport").performTouchInput { + down(0, center) + moveTo(0, center - Offset(140f, 100f), delayMillis = 16) + up(0) + } + compose.runOnIdle { + assertEquals(150, x.value) + assertEquals(150, y.value) + assertEquals(0, taps) + } + } + + @Test fun oneFingerDiagonalDragKeepsGraphPlotGestureOnTimeAxis() { + content(graph = true) + compose.onNodeWithTag("viewport").performTouchInput { + val start = center + down(0, start) + for (i in 1..8) { + updatePointerTo(0, start - Offset(i * 18f, i * 14f)) + move() + } + up(0) + } + compose.runOnIdle { + assertTrue("Graph plot drag must pan the time axis", pans > 0) + assertEquals("Graph plot drag must not move the workspace vertically", 150, y.value) + } + } + @Test fun twoFingersOverHeaderPanCanvasWithoutActivatingWindow() { + content(header = true, reserveWindowSurface = true) translateWithOneFingerTail() compose.runOnIdle { assertEquals(0, drags) } } + @Test fun twoFingersOverReservedTablePanCanvasWithoutMovingInnerTable() { + content(table = true, reserveWindowSurface = true) + translateWithOneFingerTail() + compose.runOnIdle { + assertEquals("A two-finger page pan must not scroll the inner table", 10, innerScroll.value) + } + } @Test fun tableNestedScrollBarrierDoesNotBlockTwoFingerCanvasNavigation() { content(table = true) translateWithOneFingerTail() @@ -169,7 +451,7 @@ class WorkspaceTouchNavigationUiTest { } } @Test fun establishedWindowDragIsNotCancelledBySecondFinger() { - content(header = true) + content(header = true, reserveWindowSurface = true) compose.onNodeWithTag("viewport").performTouchInput { down(0, center) moveTo(0, center + Offset(60f, 0f)) @@ -184,7 +466,7 @@ class WorkspaceTouchNavigationUiTest { } } - private fun fastTwoFingerSwipe() { + private fun fastTwoFingerSwipe(releaseJitter: Boolean = false) { compose.onNodeWithTag("viewport").performTouchInput { val a = center - Offset(50f, 0f) val b = center + Offset(50f, 0f) @@ -194,7 +476,12 @@ class WorkspaceTouchNavigationUiTest { updatePointerTo(1, b - Offset(i * 12f, i * 6f)) move(delayMillis = 10) } - up(0); up(1) + up(0) + if (releaseJitter) { + // Real touch hardware can emit small MOVE events between the two UPs. + moveBy(1, Offset(-1f, -1f), delayMillis = 16) + } + up(1) } } @@ -213,6 +500,22 @@ class WorkspaceTouchNavigationUiTest { compose.runOnIdle { assertFalse(x.isScrollInProgress); assertFalse(y.isScrollInProgress) } } + @Test fun smallMovementBetweenFingerReleasesPreservesInertia() { + content() + compose.mainClock.autoAdvance = false + fastTwoFingerSwipe(releaseJitter = true) + val releasedX = x.value + val releasedY = y.value + compose.mainClock.advanceTimeBy(200) + compose.runOnIdle { + assertTrue("Horizontal inertia was cancelled by release jitter", x.value > releasedX) + assertTrue("Vertical inertia was cancelled by release jitter", y.value > releasedY) + assertEquals(0, activations) + assertEquals(0, taps) + } + compose.mainClock.advanceTimeBy(5000) + } + @Test fun newTouchOverWindowStopsTwoFingerInertiaImmediately() { content() compose.mainClock.autoAdvance = false diff --git a/Android/app/src/androidTest/java/com/dct/app/presentation/signaltree/SignalTableTextAlignmentUiTest.kt b/Android/app/src/androidTest/java/com/dct/app/presentation/signaltree/SignalTableTextAlignmentUiTest.kt new file mode 100644 index 0000000..1a15c4b --- /dev/null +++ b/Android/app/src/androidTest/java/com/dct/app/presentation/signaltree/SignalTableTextAlignmentUiTest.kt @@ -0,0 +1,85 @@ +package com.dct.app.presentation.signaltree + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.size +import androidx.compose.material3.ProvideTextStyle +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.semantics.SemanticsActions +import androidx.compose.ui.test.* +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.text.TextLayoutResult +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.dct.app.core.theme.DctTheme +import com.dct.app.domain.model.mdb.MdbAttrType +import com.dct.app.domain.model.mdb.MdbDatabase +import com.dct.app.domain.model.mdb.SignalDefinition +import com.dct.app.domain.model.mdb.SignalGroup +import org.junit.Assert.* +import org.junit.Rule +import org.junit.Test +import kotlin.math.abs + +class SignalTableTextAlignmentUiTest { + @get:Rule val compose = createComposeRule() + + @Test fun mdbAtTabletScale() = assertAlignment("mdb", 1.3f) + @Test fun canAtTabletScale() = assertAlignment("can", 1.3f) + @Test fun a2lAtTabletScale() = assertAlignment("a2l", 1.3f) + @Test fun mdbAtLargeScale() = assertAlignment("mdb", 2f) + @Test fun canAtLargeScale() = assertAlignment("can", 2f) + @Test fun a2lAtLargeScale() = assertAlignment("a2l", 2f) + + private fun assertAlignment(kind: String, scale: Float) { + val signal = SignalDefinition( + reclid = 1, idx = 2, name = "RPM_CENTER", attr = MdbAttrType.ATTR_2_UINT16, + cvsA = 1.0, cvsB = 0.0, unit = "rpm", color = 0xFF0000, + min = 321.0, max = 999.0, description = "센터 gy 신호", messageName = "Engine" + ) + var density = 1f + compose.setContent { + density = LocalDensity.current.density + CompositionLocalProvider(LocalDensity provides Density(density, scale)) { + DctTheme { + // Reproduce the large inherited line height that caused the original regression. + ProvideTextStyle(TextStyle(fontSize = 16.sp, lineHeight = 24.sp)) { + // Keep horizontal width clipping separate from this vertical alignment test. + WindowUiStateProvider(kind, mapOf("expanded" to "true", "width:name" to "200.0", + "width:unit" to "100.0", "width:desc" to "300.0", "width:min" to "120.0"), {}) { + Box(Modifier.size(1200.dp, 200.dp)) { + when (kind) { + "mdb" -> WindowsClassicMdbSignalTableWindow( + MdbDatabase(groups = listOf(SignalGroup(1, "", "", true, signals = listOf(signal)))), emptyMap() + ) + "can" -> WindowsClassicCanSignalTableWindow(listOf(signal), emptyMap()) + else -> WindowsClassicA2lSignalTableWindow(listOf(signal), emptyMap()) + } + } + } + } + } + } + } + val row = compose.onNode(hasClickAction() and hasText(signal.name)).fetchSemanticsNode().boundsInRoot + val expectedRow = with(Density(density, scale)) { signalTableRowHeight(this).toPx() } + assertEquals(expectedRow, row.height, 1f) + for ((text, style) in listOf( + signal.name to SignalTableTextStyle, signal.unit to SignalTableTextStyle, + signal.description to SignalTableDetailTextStyle, "321.000" to SignalTableDetailTextStyle + )) { + val node = compose.onNodeWithText(text, useUnmergedTree = true) + val layouts = mutableListOf() + node.performSemanticsAction(SemanticsActions.GetTextLayoutResult) { assertTrue(it(layouts)) } + val layout = layouts.single() + assertEquals(style.lineHeight, layout.layoutInput.style.lineHeight) + assertFalse("Clipped text: $kind/$scale/$text size=${layout.size} paragraph=${layout.multiParagraph.width}x${layout.multiParagraph.height} ellipsized=${layout.isLineEllipsized(0)}", layout.didOverflowHeight) + val bounds = node.fetchSemanticsNode().boundsInRoot + assertTrue("Not vertically centered: $kind/$scale/$text", abs(bounds.center.y - row.center.y) <= 1f) + assertTrue("No vertical breathing room: $kind/$scale/$text", bounds.height < row.height) + } + } +} diff --git a/Android/app/src/androidTest/java/com/dct/app/presentation/signaltree/TableAutoFitUiTest.kt b/Android/app/src/androidTest/java/com/dct/app/presentation/signaltree/TableAutoFitUiTest.kt new file mode 100644 index 0000000..69fcbd6 --- /dev/null +++ b/Android/app/src/androidTest/java/com/dct/app/presentation/signaltree/TableAutoFitUiTest.kt @@ -0,0 +1,163 @@ +package com.dct.app.presentation.signaltree + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.size +import androidx.compose.material3.Text +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.semantics.SemanticsActions +import androidx.compose.ui.test.* +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.text.TextLayoutResult +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.dp +import com.dct.app.core.theme.DctTheme +import com.dct.app.data.parser.A2lParser.A2lCharacteristic +import com.dct.app.data.parser.A2lParser.A2lCharacteristicArray +import com.dct.app.domain.model.formula.FormulaDefinition +import com.dct.app.domain.model.mdb.* +import com.dct.app.presentation.monitor.components.FormulaWindowContent +import org.junit.Assert.* +import org.junit.Rule +import org.junit.Test + +class TableAutoFitUiTest { + @get:Rule val compose = createComposeRule() + private val name = "WMWM_온도_Shift_iiiiMMMM_WMWM" + private val description = "Description_온도_WMWM_아주긴설명" + + @Test fun mdbManualWidthAndTinyClipping() = assertWindow("mdb") + @Test fun canManualWidthAndTinyClipping() = assertWindow("can") + @Test fun a2lManualWidthAndTinyClipping() = assertWindow("a2l") + @Test fun formulaManualWidthAndTinyClipping() = assertWindow("formula") + @Test fun constantManualWidthAndTinyClipping() = assertWindow("constant") + @Test fun compensationManualWidthAndTinyClipping() = assertWindow("compensation") + @Test fun characteristicCheckboxIsIncludedInFit() = assertWindow("characteristic") + @Test fun characteristicArrayManualWidthAndTinyClipping() = assertWindow("array") + + private fun assertWindow(kind: String) { + val signal = SignalDefinition(1, 2, name, MdbAttrType.ATTR_2_UINT16, + 1.0, 0.0, "rpm", 0xFF0000, 0.0, 999.0, description = description) + val key = if (kind == "compensation") "test:YNAME" else "name" + lateinit var widths: WindowColumnWidths + val visible = mutableStateOf(true) + var saved = mapOf("width:$key" to "130.0") + var pixelsPerDp = 1f + compose.setContent { + val current = LocalDensity.current + pixelsPerDp = current.density + CompositionLocalProvider(LocalDensity provides Density(current.density, 1.3f)) { + DctTheme { + if (visible.value) WindowUiStateProvider(kind, saved, { saved = it }) { + widths = rememberWindowColumnWidths() + Box(Modifier.size(1200.dp, 450.dp)) { + when (kind) { + "mdb" -> WindowsClassicMdbSignalTableWindow( + MdbDatabase(groups = listOf(SignalGroup(1, "", "", true, signals = listOf(signal)))), emptyMap()) + "can" -> WindowsClassicCanSignalTableWindow(listOf(signal), emptyMap()) + "a2l" -> WindowsClassicA2lSignalTableWindow(listOf(signal), emptyMap()) + "formula" -> FormulaWindowContent(listOf(FormulaDefinition(id = "f", name = name, + expression = "1", unit = "rpm")), onAddFormula = {}, onUpdateFormula = {}, onDeleteFormula = {}) + "constant" -> WindowsClassicConstantTableWindow(listOf(MdbConstant(1, name, 1.0, + 0x1000, 0.0, 100.0, MdbAttrType.ATTR_2_UINT16, description))) + "characteristic" -> WindowsClassicA2lCharacteristicTableWindow(listOf(A2lCharacteristic(name, description))) + "array" -> WindowsClassicA2lCharacteristicArrayTableWindow(listOf(A2lCharacteristicArray(name, description))) + else -> CompensationTable(compensationMapColumns(true), listOf(CompensationMap( + CompensationMapType.TYPE_A, 1, name, 0, MdbAttrType.ATTR_2_UINT16, 0.0, 100.0, + "X", 0, MdbAttrType.ATTR_2_UINT16, 1.0, 0.0, description + )), "test", selectedIndex = 0, onRowClick = { _, _ -> }, onDescriptionChange = { _, _ -> }) + } + } + } + } + } + } + // Legacy saved widths must not imply that the column is already auto-fitted. + compose.onAllNodesWithContentDescription("열 너비 닫기").assertCountEquals(0) + compose.onAllNodesWithContentDescription("열 너비 열기")[0].performClick() + val fitted = widths[key]!! + assertTrue(fitted > 130.dp) + assertTextFits(name) + val title = when (kind) { "mdb" -> "Signal name"; "can" -> "DBC name"; "compensation" -> "YNAME"; else -> "Name" } + assertTextFits(title) + compose.onAllNodesWithContentDescription("열 너비 닫기").assertCountEquals(1) + + val icon = compose.onNodeWithContentDescription("열 너비 닫기").fetchSemanticsNode().boundsInRoot + val root = compose.onRoot().fetchSemanticsNode().boundsInRoot + val grip = Offset(icon.right + 6f * pixelsPerDp, icon.center.y) - root.topLeft + compose.onRoot().performTouchInput { swipe(grip, grip - Offset(30f * pixelsPerDp, 0f), 300) } + assertTrue("Grip drag did not resize $kind", widths[key]!! < fitted) + compose.onAllNodesWithContentDescription("열 너비 닫기").assertCountEquals(0) + compose.onAllNodesWithContentDescription("열 너비 열기")[0].performClick() + assertEquals(fitted, widths[key]) + assertTextFits(name) + + // A tiny manual reduction clears fit mode. Opening must recover, not reset to default. + compose.runOnIdle { widths[key] = fitted - 2.dp } + compose.onAllNodesWithContentDescription("열 너비 닫기").assertCountEquals(0) + compose.onAllNodesWithContentDescription("열 너비 열기")[0].performClick() + assertEquals(fitted, widths[key]) + assertTextFits(name) + + compose.runOnIdle { visible.value = false } + compose.waitForIdle() + compose.runOnIdle { visible.value = true } + compose.onAllNodesWithContentDescription("열 너비 닫기").assertCountEquals(1) + compose.onNodeWithContentDescription("열 너비 닫기").performClick() + assertFalse(saved.containsKey("width:$key")) + assertFalse(saved.containsKey("autoFit:$key")) + + // Exercise the shared description path, including nested padding in calibration tables. + if (kind != "formula") { + val descriptionIndex = if (kind == "compensation") 2 else 1 + compose.onAllNodesWithContentDescription("열 너비 열기")[descriptionIndex].performScrollTo().performClick() + assertTextFits(description) + compose.onNodeWithContentDescription("열 너비 닫기").performClick() + } + } + + private fun assertTextFits(text: String) { + val layouts = mutableListOf() + compose.onNodeWithText(text, useUnmergedTree = true) + .performSemanticsAction(SemanticsActions.GetTextLayoutResult) { assertTrue(it(layouts)) } + val layout = layouts.single() + val details = "$text size=${layout.size} paragraph=${layout.multiParagraph.width}x${layout.multiParagraph.height} " + + "constraints=${layout.layoutInput.constraints} style=${layout.layoutInput.style}" + // A naturally sized Text can have a paragraph as wide as its parent's constraint. + // didOverflowWidth compares those different boxes, not whether glyphs are clipped. + assertTrue("Clipped line: $details", layout.getLineRight(0) <= layout.layoutInput.constraints.maxWidth + 1f) + assertFalse("Hidden lines: $details", layout.multiParagraph.didExceedMaxLines) + assertFalse("Ellipsized: $details", layout.isLineEllipsized(0)) + } + + @Test fun packetOnlyRecompositionDoesNotRemeasureMetadataButScaleAndNamesDo() { + val packet = mutableStateOf(0) + val scale = mutableStateOf(1.3f) + val items = mutableStateOf(listOf(name)) + var extractions = 0 + var measured = 0.dp + compose.setContent { + val density = LocalDensity.current + CompositionLocalProvider(LocalDensity provides Density(density.density, scale.value)) { + measured = rememberAutoFitWidth("Name", items.value, 110.dp) { extractions++; it } + Text(packet.value.toString()) + } + } + val initial = extractions + val initialWidth = measured + repeat(5) { compose.runOnIdle { packet.value++ }; compose.waitForIdle() } + assertEquals(initial, extractions) + compose.runOnIdle { items.value = listOf(name + name) } + compose.waitForIdle() + assertTrue(extractions > initial) + assertTrue(measured > initialWidth) + val beforeScale = extractions + val beforeWidth = measured + compose.runOnIdle { scale.value = 2f } + compose.waitForIdle() + assertTrue(extractions > beforeScale) + assertTrue(measured > beforeWidth) + } +} diff --git a/Android/app/src/androidTest/java/com/dct/app/presentation/signaltree/TableTextOverflowUiTest.kt b/Android/app/src/androidTest/java/com/dct/app/presentation/signaltree/TableTextOverflowUiTest.kt new file mode 100644 index 0000000..e23db17 --- /dev/null +++ b/Android/app/src/androidTest/java/com/dct/app/presentation/signaltree/TableTextOverflowUiTest.kt @@ -0,0 +1,90 @@ +package com.dct.app.presentation.signaltree + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.semantics.SemanticsActions +import androidx.compose.ui.test.* +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.text.TextLayoutResult +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.dp +import com.dct.app.core.theme.DctTheme +import com.dct.app.data.parser.A2lParser.A2lCharacteristic +import com.dct.app.data.parser.A2lParser.A2lCharacteristicArray +import com.dct.app.domain.model.mdb.* +import org.junit.Assert.* +import org.junit.Rule +import org.junit.Test + +/** 원문을 자르지 않고 실제 TextLayout의 말줄임과 열 확장 후 복원을 확인한다. */ +class TableTextOverflowUiTest { + @get:Rule val compose = createComposeRule() + private val longUnit = "UNIT_NAME_TOO_LONG" + private val longMinimum = "-123456.789" + + @Test fun mdbNarrowCellsEllipsizeAndWideningRestoresFullText() = checkWindow("mdb") + @Test fun canNarrowCellsEllipsizeAndWideningRestoresFullText() = checkWindow("can") + @Test fun a2lNarrowCellsEllipsizeAndWideningRestoresFullText() = checkWindow("a2l") + @Test fun characteristicLimitsAndAddressEllipsize() = checkWindow("characteristic") + @Test fun arrayLimitsAddressAndMatrixValuesEllipsize() = checkWindow("array") + + private fun checkWindow(kind: String) { + lateinit var widths: WindowColumnWidths + val signal = SignalDefinition(1, 2, "Signal", MdbAttrType.ATTR_2_UINT16, + 1.0, 0.0, longUnit, 0xFF0000, -123456.789, 654321.987) + compose.setContent { + val density = LocalDensity.current + CompositionLocalProvider(LocalDensity provides Density(density.density, 1.3f)) { + DctTheme { + WindowUiStateProvider(kind, mapOf("expanded" to "true", "width:unit" to "40.0", + "width:min" to "40.0", "width:max" to "40.0", "width:addr" to "40.0"), {}) { + widths = rememberWindowColumnWidths() + Box(Modifier.size(1100.dp, 450.dp)) { + when (kind) { + "mdb" -> WindowsClassicMdbSignalTableWindow( + MdbDatabase(groups = listOf(SignalGroup(1, "", "", true, signals = listOf(signal)))), emptyMap()) + "can" -> WindowsClassicCanSignalTableWindow(listOf(signal), emptyMap()) + "a2l" -> WindowsClassicA2lSignalTableWindow(listOf(signal), emptyMap()) + "characteristic" -> WindowsClassicA2lCharacteristicTableWindow(listOf( + A2lCharacteristic("Scalar", lowerLimit = -123456.789, upperLimit = 654321.987, + ecuAddress = 0x12345678))) + else -> WindowsClassicA2lCharacteristicArrayTableWindow(listOf( + A2lCharacteristicArray("Array", lowerLimit = -123456.789, upperLimit = 654321.987, + ecuAddress = 0x12345678, matrixDim = listOf(1), values = listOf(123456789.12)))) + } + } + } + } + } + } + assertEllipsized(longMinimum, true) + if (kind in setOf("mdb", "can", "a2l")) assertEllipsized(longUnit, true) + else assertEllipsized("0x12345678", true) + if (kind == "array") assertEllipsized("123456789.12", true) + + compose.runOnIdle { + widths["min"] = 220.dp + widths["unit"] = 220.dp + widths["addr"] = 220.dp + } + compose.waitForIdle() + assertEllipsized(longMinimum, false) + if (kind in setOf("mdb", "can", "a2l")) assertEllipsized(longUnit, false) + else assertEllipsized("0x12345678", false) + } + + private fun assertEllipsized(original: String, expected: Boolean) { + val results = mutableListOf() + compose.onNodeWithText(original, useUnmergedTree = true) + .performSemanticsAction(SemanticsActions.GetTextLayoutResult) { assertTrue(it(results)) } + val layout = results.single() + assertEquals(original, layout.layoutInput.text.text) + assertEquals(TextOverflow.Ellipsis, layout.layoutInput.overflow) + assertFalse(layout.layoutInput.softWrap) + assertEquals("Unexpected truncation for $original", expected, layout.isLineEllipsized(0)) + } +} diff --git a/Android/app/src/main/AndroidManifest.xml b/Android/app/src/main/AndroidManifest.xml index 18fbfa7..63c26f0 100644 --- a/Android/app/src/main/AndroidManifest.xml +++ b/Android/app/src/main/AndroidManifest.xml @@ -3,6 +3,12 @@ xmlns:tools="http://schemas.android.com/tools"> + + + + + + @@ -12,9 +18,11 @@ - - - + + + + @@ -23,6 +31,7 @@ + = _serviceRunning.asStateFlow() + + val viewModel: MainViewModel by lazy { + val factory = object : ViewModelProvider.Factory { + @Suppress("UNCHECKED_CAST") + override fun create(modelClass: Class): T { + val dispatchers = DefaultDispatcherProvider() + val metrics = PacketPipelineMetrics() + val mdb = MdbRepositoryImpl(dispatchers) + val chart = ChartBatchRenderer(dispatchers, pipelineMetrics = metrics) + val tree = SignalTreeViewModel(dispatchers) + val mdi = MdiViewModel(dispatchers) + val calibration = CalibrationViewModel(mdb, dispatchers) + val wifi = WifiSocketManager(metrics, operationalLogger) + val packets = PacketRepositoryImpl(wifiSocketManager = wifi, + ioDispatcher = dispatchers.io, metrics = metrics) + return MainViewModel(mdb, chart, tree, mdi, calibration, EventBus(), dispatchers, + wifiSocketManager = wifi, packetRepository = packets, pipelineMetrics = metrics, + ensureBackgroundExecution = ::ensureForegroundService, + operationalLogger = operationalLogger) as T + } + } + ViewModelProvider(this, factory)[MainViewModel::class.java] + } + + 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) + }.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)) + } + + /** Start while handling a visible user connection request, before P2P/TCP negotiation begins. */ + @Synchronized + fun ensureForegroundService(): Boolean { + // A running service may already have queued stopSelf(). Deliver a fresh startId + // for a new connection instead of relying on a stale "running" flag. + if (serviceRequested && !serviceRunning.value) return true + return try { + serviceRequested = true + ContextCompat.startForegroundService(appContext, Intent(appContext, MeasurementForegroundService::class.java)) + true + } catch (error: RuntimeException) { + serviceRequested = false + Log.e("DctSessionRuntime", "Foreground session start rejected", error) + viewModel.setStatusMessage("백그라운드 계측 실행을 시작할 수 없습니다. 앱을 연 상태에서 다시 연결해 주세요.") + false + } + } + + fun onUiStarted() { + uiVisible = true + viewModel.setApplicationVisible(true) + if (!observing) { + observing = true + scope.launch { + executionState.map { it.active }.distinctUntilChanged().collect { active -> + // Also adopt an OS-created P2P group when the user returns to the app. + if (uiVisible && active) ensureForegroundService() + } + } + } + if (executionState.value.active) ensureForegroundService() + } + + fun onUiStopped() { uiVisible = false; viewModel.setApplicationVisible(false) } + fun onServiceStarted() { serviceRequested = true; _serviceRunning.value = true } + fun onServiceStopped() { serviceRequested = false; _serviceRunning.value = false } +} 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 new file mode 100644 index 0000000..6c7573c --- /dev/null +++ b/Android/app/src/main/java/com/dct/app/background/MeasurementExecutionState.kt @@ -0,0 +1,48 @@ +package com.dct.app.background + +import com.dct.app.data.communication.WifiDirectPhase +import com.dct.app.domain.repository.DeviceConnectionState +import com.dct.app.presentation.main.MainUiState +import com.dct.app.presentation.main.MeasurementRecordingState + +/** Notification state uses real transport/measurement state, not Activity visibility. */ +data class MeasurementExecutionState( + val active: Boolean, + val title: String, + val detail: String, + val measuring: Boolean = false, + val recording: Boolean = false, + val startedAtMillis: Long? = null +) { + companion object { + val PREPARING = MeasurementExecutionState(true, "DCT · 연결 준비 중", "장비 연결을 준비하고 있습니다.") + + fun from(ui: MainUiState, connection: DeviceConnectionState, socketConnected: Boolean, + p2pPhase: WifiDirectPhase, groupPresent: Boolean): 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) + val title = when { + ui.recordingState == MeasurementRecordingState.STOPPING -> "DCT · 기록 마무리 중" + measuring && recording -> "DCT · 계측 및 기록 중" + measuring -> "DCT · 계측 중 (기록 안 함)" + recording -> "DCT · 기록 처리 중" + connected -> "DCT · 장비 연결됨 / 계측 대기" + negotiating -> "DCT · 장비 연결 중" + groupPresent -> "DCT · Wi-Fi Direct 연결됨 / TCP 대기" + else -> "DCT · 연결 종료" + } + val info = buildList { + ui.projectFileName?.takeIf { it.isNotBlank() }?.let(::add) + ui.connectedDeviceName?.takeIf { it.isNotBlank() }?.let(::add) + add("수신 ${ui.packetCount}건") + if (recording) add("기록 ${ui.recordingSampleCount}건") + } + return MeasurementExecutionState(measuring || recording || connected || negotiating || groupPresent, + title, info.joinToString(" · "), measuring, recording, ui.recordingStartedAtMillis) + } + } +} diff --git a/Android/app/src/main/java/com/dct/app/background/MeasurementForegroundService.kt b/Android/app/src/main/java/com/dct/app/background/MeasurementForegroundService.kt new file mode 100644 index 0000000..2dd842a --- /dev/null +++ b/Android/app/src/main/java/com/dct/app/background/MeasurementForegroundService.kt @@ -0,0 +1,161 @@ +package com.dct.app.background + +import android.app.Notification +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.app.Service +import android.content.Context +import android.content.Intent +import android.content.pm.ServiceInfo +import android.os.Build +import android.os.IBinder +import android.os.PowerManager +import android.os.SystemClock +import android.util.Log +import androidx.core.app.NotificationCompat +import androidx.core.app.ServiceCompat +import com.dct.app.DctApplication +import com.dct.app.R +import com.dct.app.presentation.main.MainActivity +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch + +/** Keeps the shared measurement session alive; it never replays START after process death. */ +class MeasurementForegroundService : Service() { + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate) + private lateinit var session: DctSessionRuntime + private var wakeLock: PowerManager.WakeLock? = null + private var intentionalStop = false + private var startedAt = 0L + private var lastWakeRenewal = 0L + private var latestStartId = 0 + + override fun onCreate() { + super.onCreate() + session = (application as DctApplication).session + val notifications = getSystemService(NotificationManager::class.java) + notifications.createNotificationChannel(NotificationChannel(CHANNEL_ID, "장비 연결 및 계측", + NotificationManager.IMPORTANCE_LOW).apply { + description = "백그라운드 장비 연결, 실시간 계측 및 파일 기록 상태" + setShowBadge(false) + }) + try { + ServiceCompat.startForeground(this, NOTIFICATION_ID, notification(MeasurementExecutionState.PREPARING), + if (Build.VERSION.SDK_INT >= 29) ServiceInfo.FOREGROUND_SERVICE_TYPE_CONNECTED_DEVICE else 0) + startedAt = SystemClock.elapsedRealtime() + session.onServiceStarted() + wakeLock = getSystemService(PowerManager::class.java) + .newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "DCT:MeasurementSession").apply { + setReferenceCounted(false) + } + renewWakeLock() + } catch (error: RuntimeException) { + Log.e(TAG, "Foreground promotion failed", error) + session.viewModel.setStatusMessage("백그라운드 계측 서비스를 시작하지 못했습니다: ${error.message}") + session.viewModel.disconnectCommunication() + intentionalStop = true + stopSelf() + return + } + scope.launch { + var inactiveSince: Long? = null + var previous: MeasurementExecutionState? = null + while (isActive) { + val now = SystemClock.elapsedRealtime() + val state = session.executionState.value + if (state.active) { + inactiveSince = null + if (now - lastWakeRenewal >= WAKE_RENEW_MS) renewWakeLock() + } else { + if (inactiveSince == null) inactiveSince = now + // Allow a START/STOP/reconnect callback to cross the main/IO boundary. + if (now - startedAt > START_GRACE_MS && now - inactiveSince >= IDLE_GRACE_MS) { + // A newer queued connect request must win over an idle stop. + if (stopSelfResult(latestStartId)) { + intentionalStop = true + stopForeground(STOP_FOREGROUND_REMOVE) + break + } + } + } + val display = if (!state.active && now - startedAt < START_GRACE_MS) + MeasurementExecutionState.PREPARING else state + if (display != previous) { + notifications.notify(NOTIFICATION_ID, notification(display)) + previous = display + } + delay(1_000) // Never post a notification per packet/sample. + } + } + } + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + latestStartId = startId + startedAt = SystemClock.elapsedRealtime() + when (intent?.action) { + ACTION_STOP_MEASUREMENT -> session.viewModel.stopMeasure() // STOP is not disconnect. + ACTION_DISCONNECT -> session.viewModel.disconnectCommunication() + } + return START_NOT_STICKY // Unknown session state must not silently restart equipment measurement. + } + + override fun onBind(intent: Intent?): IBinder? = null + + override fun onDestroy() { + scope.cancel() + stopForeground(STOP_FOREGROUND_REMOVE) + wakeLock?.let { if (it.isHeld) it.release() } + wakeLock = null + if (::session.isInitialized) { + if (!intentionalStop && session.executionState.value.active) session.viewModel.disconnectCommunication() + session.onServiceStopped() + } + super.onDestroy() + } + + private fun renewWakeLock() { + wakeLock?.acquire(WAKE_TIMEOUT_MS) // A bounded lock is also released on disconnect/service destruction. + lastWakeRenewal = SystemClock.elapsedRealtime() + } + + private fun notification(state: MeasurementExecutionState) = createNotification(this, state) + + companion object { + const val CHANNEL_ID = "dct_measurement_session" + const val NOTIFICATION_ID = 6001 + const val ACTION_STOP_MEASUREMENT = "com.dct.app.STOP_MEASUREMENT" + const val ACTION_DISCONNECT = "com.dct.app.DISCONNECT_SESSION" + private const val TAG = "DctMeasurementService" + private const val START_GRACE_MS = 5_000L + private const val IDLE_GRACE_MS = 3_000L + private const val WAKE_TIMEOUT_MS = 10 * 60 * 1_000L + private const val WAKE_RENEW_MS = 5 * 60 * 1_000L + + /** Shared builder lets device tests verify controls without changing user notification permission. */ + internal fun createNotification(context: Context, state: MeasurementExecutionState): Notification { + val open = PendingIntent.getActivity(context, 0, Intent(context, MainActivity::class.java).apply { + flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP + }, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE) + fun action(command: String, request: Int) = PendingIntent.getService(context, request, + Intent(context, MeasurementForegroundService::class.java).setAction(command), + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE) + return NotificationCompat.Builder(context, CHANNEL_ID) + .setSmallIcon(R.drawable.ic_measurement_notification) + .setContentTitle(state.title).setContentText(state.detail) + .setStyle(NotificationCompat.BigTextStyle().bigText(state.detail)) + .setContentIntent(open).setOngoing(true).setOnlyAlertOnce(true) + .setCategory(NotificationCompat.CATEGORY_SERVICE).setVisibility(NotificationCompat.VISIBILITY_PRIVATE) + .setSilent(true).apply { + state.startedAtMillis?.let { setWhen(it); setUsesChronometer(true) } + if (state.measuring) addAction(0, "계측 정지", action(ACTION_STOP_MEASUREMENT, 1)) + addAction(0, "연결 해제", action(ACTION_DISCONNECT, 2)) + }.build() + } + } +} diff --git a/Android/app/src/main/java/com/dct/app/core/common/PacketDistributor.kt b/Android/app/src/main/java/com/dct/app/core/common/PacketDistributor.kt index 8cdc5b2..b2fe2b8 100644 --- a/Android/app/src/main/java/com/dct/app/core/common/PacketDistributor.kt +++ b/Android/app/src/main/java/com/dct/app/core/common/PacketDistributor.kt @@ -7,15 +7,16 @@ import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.asSharedFlow class PacketDistributor { - // 고속 패킷 수신 스트림 (256 버퍼 유지, 오버플로우 시 가장 오래된 패킷 drop) + // Lossless fan-out for active consumers. Transport response matching is + // performed before this potentially slow UI/logging path. private val _packetStream = MutableSharedFlow( replay = 0, extraBufferCapacity = 256, - onBufferOverflow = BufferOverflow.DROP_OLDEST + onBufferOverflow = BufferOverflow.SUSPEND ) val packetStream: SharedFlow = _packetStream.asSharedFlow() - fun dispatchPacket(packet: DctPacket) { - _packetStream.tryEmit(packet) + suspend fun dispatchPacket(packet: DctPacket) { + _packetStream.emit(packet) } } diff --git a/Android/app/src/main/java/com/dct/app/core/common/PacketPipelineMetrics.kt b/Android/app/src/main/java/com/dct/app/core/common/PacketPipelineMetrics.kt new file mode 100644 index 0000000..9eb50dc --- /dev/null +++ b/Android/app/src/main/java/com/dct/app/core/common/PacketPipelineMetrics.kt @@ -0,0 +1,137 @@ +package com.dct.app.core.common + +import android.util.Log +import java.util.concurrent.atomic.AtomicInteger +import java.util.concurrent.atomic.AtomicLong +import java.util.Locale + +/** + * 수신부터 렌더링까지의 파이프라인 상태를 저비용으로 관찰하기 위한 계측기. + * + * 데이터 경로의 동작을 바꾸지 않고 카운터와 누적 시간을 기록한다. 5초마다 + * 한 번만 Logcat에 요약을 출력하므로 고속 스트림에서 로그 자체가 병목이 + * 되지 않도록 했다. + */ +class PacketPipelineMetrics { + val tcpReadCalls = AtomicLong() + val tcpBytesRead = AtomicLong() + val parsedPackets = AtomicLong() + val ackEnqueued = AtomicLong() + val ackRejected = AtomicLong() + val dispatchQueueDepth = AtomicInteger() + val maxDispatchQueueDepth = AtomicInteger() + val processedPackets = AtomicLong() + val processingNanos = AtomicLong() + private val receiveToProcessCount = AtomicLong() + private val receiveToProcessNanos = AtomicLong() + private val lastReceiveToProcessNanos = AtomicLong() + private val maxReceiveToProcessNanos = AtomicLong() + val graphSamples = AtomicLong() + val renderFrames = AtomicLong() + val renderNanos = AtomicLong() + + private val lastReportNanos = AtomicLong() + + fun onDispatchEnqueued() { + val depth = dispatchQueueDepth.incrementAndGet() + updateMax(maxDispatchQueueDepth, depth) + } + + fun onDispatchDequeued() { + dispatchQueueDepth.updateAndGet { current -> (current - 1).coerceAtLeast(0) } + } + + fun recordProcessing(durationNanos: Long) { + processedPackets.incrementAndGet() + processingNanos.addAndGet(durationNanos.coerceAtLeast(0)) + } + + /** Uses the same monotonic clock as socket ingress, independent of wall-clock correction. */ + fun recordReceiveToProcess(durationNanos: Long) { + if (durationNanos < 0L) return + receiveToProcessCount.incrementAndGet() + receiveToProcessNanos.addAndGet(durationNanos) + lastReceiveToProcessNanos.set(durationNanos) + maxReceiveToProcessNanos.updateAndGet { current -> maxOf(current, durationNanos) } + } + + fun recordRender(durationNanos: Long) { + renderFrames.incrementAndGet() + renderNanos.addAndGet(durationNanos.coerceAtLeast(0)) + } + + data class Snapshot( + val tcpReadCalls: Long, + val tcpBytesRead: Long, + val parsedPackets: Long, + val ackEnqueued: Long, + val ackRejected: Long, + val dispatchQueueDepth: Int, + val maxDispatchQueueDepth: Int, + val processedPackets: Long, + val processingNanos: Long, + val graphSamples: Long, + val renderFrames: Long, + val renderNanos: Long, + val receiveToProcessCount: Long, + val receiveToProcessNanos: Long, + val lastReceiveToProcessNanos: Long, + val maxReceiveToProcessNanos: Long + ) { + val averageProcessingMicros: Double + get() = if (processedPackets == 0L) 0.0 else processingNanos / processedPackets / 1_000.0 + val averageRenderMicros: Double + get() = if (renderFrames == 0L) 0.0 else renderNanos / renderFrames / 1_000.0 + val averageReceiveToProcessMillis: Double + get() = if (receiveToProcessCount == 0L) 0.0 else receiveToProcessNanos / receiveToProcessCount / 1_000_000.0 + } + + fun snapshot(): Snapshot = Snapshot( + tcpReadCalls = tcpReadCalls.get(), + tcpBytesRead = tcpBytesRead.get(), + parsedPackets = parsedPackets.get(), + ackEnqueued = ackEnqueued.get(), + ackRejected = ackRejected.get(), + dispatchQueueDepth = dispatchQueueDepth.get(), + maxDispatchQueueDepth = maxDispatchQueueDepth.get(), + processedPackets = processedPackets.get(), + processingNanos = processingNanos.get(), + graphSamples = graphSamples.get(), + renderFrames = renderFrames.get(), + renderNanos = renderNanos.get(), + receiveToProcessCount = receiveToProcessCount.get(), + receiveToProcessNanos = receiveToProcessNanos.get(), + lastReceiveToProcessNanos = lastReceiveToProcessNanos.get(), + maxReceiveToProcessNanos = maxReceiveToProcessNanos.get() + ) + + /** 5초에 한 번만 호출자 태그로 요약을 남긴다. */ + fun maybeLog(tag: String) { + val now = System.nanoTime() + while (true) { + val previous = lastReportNanos.get() + if (previous != 0L && now - previous < 5_000_000_000L) return + if (lastReportNanos.compareAndSet(previous, now)) break + } + val s = snapshot() + Log.i( + tag, + "pipeline read=${s.tcpReadCalls}/${s.tcpBytesRead}B " + + "parsed=${s.parsedPackets} ack=${s.ackEnqueued}/${s.ackRejected} " + + "queue=${s.dispatchQueueDepth} maxQueue=${s.maxDispatchQueueDepth} " + + "processed=${s.processedPackets} avgProcess=${String.format(Locale.US, "%.1f", s.averageProcessingMicros)}us " + + "rxToProcess=${String.format(Locale.US, "%.1f", s.lastReceiveToProcessNanos / 1_000_000.0)}ms " + + "avgRxToProcess=${String.format(Locale.US, "%.1f", s.averageReceiveToProcessMillis)}ms " + + "maxRxToProcess=${String.format(Locale.US, "%.1f", s.maxReceiveToProcessNanos / 1_000_000.0)}ms " + + "samples=${s.graphSamples} frames=${s.renderFrames} " + + "avgRender=${String.format(Locale.US, "%.1f", s.averageRenderMicros)}us" + ) + } + + private fun updateMax(target: AtomicInteger, value: Int) { + while (true) { + val previous = target.get() + if (value <= previous || target.compareAndSet(previous, value)) return + } + } +} diff --git a/Android/app/src/main/java/com/dct/app/core/engine/XcpDaqStreamDecoder.kt b/Android/app/src/main/java/com/dct/app/core/engine/XcpDaqStreamDecoder.kt index 974de15..4ebbc2a 100644 --- a/Android/app/src/main/java/com/dct/app/core/engine/XcpDaqStreamDecoder.kt +++ b/Android/app/src/main/java/com/dct/app/core/engine/XcpDaqStreamDecoder.kt @@ -67,12 +67,27 @@ class XcpDaqStreamDecoder { is DecodeResult.Failure -> emptyMap() } - /** 잘린 패킷, 선언 개수 불일치, 타입 순서 불일치를 부분 적용 없이 거절합니다. */ + /** + * 잘린 패킷, 선언 개수 불일치, 타입 순서 불일치를 부분 적용 없이 거절합니다. + * 바이트 입력 경로는 구조 검증/파싱 후 공통 디코더로 위임합니다. + */ @Synchronized fun decodeDaqPayloadResult(payload: ByteArray): DecodeResult { - if (configuredSignals.isEmpty()) return DecodeResult.Failure("XCP 측정 신호가 설정되지 않았습니다.") val stream = DctStreamPayloadParser.parseXcpStream(payload) ?: return DecodeResult.Failure("XCP D0 payload 구조가 올바르지 않습니다.") + return decodeDaqStream(stream) + } + + /** + * 수신 계층에서 이미 검증한 XCP 스트림을 재사용하는 경로. + * ACK 생성과 그래프 디코드가 같은 프레임을 두 번 파싱하지 않도록 한다. + */ + @Synchronized + fun decodeDaqPayloadResult(stream: DctStreamPayloadParser.XcpStream): DecodeResult = + decodeDaqStream(stream) + + private fun decodeDaqStream(stream: DctStreamPayloadParser.XcpStream): DecodeResult { + if (configuredSignals.isEmpty()) return DecodeResult.Failure("XCP 측정 신호가 설정되지 않았습니다.") if (stream.values.size != configuredSignals.size) { return DecodeResult.Failure( "XCP 측정 개수가 다릅니다. 설정=${configuredSignals.size}, 수신=${stream.values.size}" diff --git a/Android/app/src/main/java/com/dct/app/core/error/DctError.kt b/Android/app/src/main/java/com/dct/app/core/error/DctError.kt index faa8ca3..993d815 100644 --- a/Android/app/src/main/java/com/dct/app/core/error/DctError.kt +++ b/Android/app/src/main/java/com/dct/app/core/error/DctError.kt @@ -13,6 +13,15 @@ sealed class DctError(val message: String, val code: Int) { ProtocolError("장비 연결 실패: $reason", 1004) object Disconnected : ProtocolError("연결이 종료되었습니다.", 1005) + /** + * The command may already have reached the peer even though its ACK + * was not observed before the response budget expired. Callers can + * therefore decide whether to keep the byte stream alive (STOP is + * deliberately recoverable) instead of treating every timeout as a + * transport failure. + */ + data class ResponseTimeout(val command: String, val expectedResponse: String) : + ProtocolError("응답 시간 초과: $command -> $expectedResponse", 1006) } // 2000: MDB 데이터베이스 오류 diff --git a/Android/app/src/main/java/com/dct/app/core/formula/FormulaEvaluator.kt b/Android/app/src/main/java/com/dct/app/core/formula/FormulaEvaluator.kt index fb33845..5bdf5b5 100644 --- a/Android/app/src/main/java/com/dct/app/core/formula/FormulaEvaluator.kt +++ b/Android/app/src/main/java/com/dct/app/core/formula/FormulaEvaluator.kt @@ -36,12 +36,22 @@ object FormulaEvaluator { * @return 계산 결과 Double (오류 발생 시 Double.NaN) */ fun evaluate(expression: String, variables: Map): Double { + return evaluate(expression, FormulaVariableLookup(variables)::get) + } + + internal fun evaluate(expression: String, resolve: (String) -> Double?): Double { if (expression.isBlank()) return Double.NaN val trimmed = expression.trim() if (trimmed.startsWith("LIN(", ignoreCase = true) && trimmed.endsWith(")")) { - return SMLinEvaluator.evaluate(trimmed, variables) + return SMLinEvaluator.evaluate(trimmed, resolve) } - val rpn = compiledCache.computeIfAbsent(expression) { expr -> + val rpn = compile(expression) + if (rpn.isEmpty()) return Double.NaN + return evaluateRpn(rpn, resolve).takeIf { it.isFinite() } ?: Double.NaN + } + + private fun compile(expression: String): List = + compiledCache.computeIfAbsent(expression) { expr -> try { val tokens = tokenize(expr) val r = toRpn(tokens) @@ -50,8 +60,12 @@ object FormulaEvaluator { emptyList() } } - if (rpn.isEmpty()) return Double.NaN - return evaluateRpn(rpn, variables) + + internal fun dependencies(expression: String): Set { + if (expression.trim().startsWith("LIN(", ignoreCase = true)) { + return SMLinEvaluator.dependencies(expression) + } + return compile(expression).filterIsInstance().mapTo(linkedSetOf()) { it.name } } /** @@ -61,16 +75,9 @@ object FormulaEvaluator { if (expression.isBlank()) return false val trimmed = expression.trim() if (trimmed.startsWith("LIN(", ignoreCase = true) && trimmed.endsWith(")")) { - val testVars = mapOf("01-01 ch" to 1.0, "01-01" to 1.0, "02-01 ch" to 1.0) - return !SMLinEvaluator.evaluate(trimmed, testVars).isNaN() - } - return try { - val tokens = tokenize(expression) - val rpn = toRpn(tokens) - isRpnValid(rpn) - } catch (e: Exception) { - false + return SMLinEvaluator.validate(trimmed) } + return compile(expression).isNotEmpty() } /** @@ -78,6 +85,7 @@ object FormulaEvaluator { */ fun clearCache() { compiledCache.clear() + SMLinEvaluator.clearCache() } private fun isRpnValid(rpn: List): Boolean { @@ -280,18 +288,15 @@ object FormulaEvaluator { return output } - private fun evaluateRpn(rpn: List, variables: Map): Double { + private fun evaluateRpn(rpn: List, resolve: (String) -> Double?): Double { val stack = ArrayDeque() for (token in rpn) { when (token) { is Token.Number -> stack.push(token.value) is Token.Variable -> { - // 변수 조회: 대소문자 무시 검색 지원 - val value = variables[token.name] - ?: variables[token.name.lowercase(Locale.ROOT)] - ?: variables.entries.find { it.key.equals(token.name, ignoreCase = true) }?.value - ?: 0.0 + val value = resolve(token.name) ?: return Double.NaN + if (!value.isFinite()) return Double.NaN stack.push(value) } is Token.Operator -> { @@ -313,6 +318,7 @@ object FormulaEvaluator { '^' -> a.pow(b) else -> Double.NaN } + if (!res.isFinite()) return Double.NaN stack.push(res) } is Token.Function -> { @@ -343,6 +349,7 @@ object FormulaEvaluator { "deg" -> Math.toDegrees(a) else -> Double.NaN } + if (!res.isFinite()) return Double.NaN stack.push(res) } else if (token.argCount == 2) { if (stack.size < 2) return Double.NaN @@ -355,6 +362,7 @@ object FormulaEvaluator { "atan2" -> atan2(a, b) else -> Double.NaN } + if (!res.isFinite()) return Double.NaN stack.push(res) } } diff --git a/Android/app/src/main/java/com/dct/app/core/formula/FormulaTelemetryEngine.kt b/Android/app/src/main/java/com/dct/app/core/formula/FormulaTelemetryEngine.kt new file mode 100644 index 0000000..88ac91b --- /dev/null +++ b/Android/app/src/main/java/com/dct/app/core/formula/FormulaTelemetryEngine.kt @@ -0,0 +1,111 @@ +package com.dct.app.core.formula + +import com.dct.app.domain.model.formula.FormulaDefinition +import java.util.ArrayDeque + +/** Owned by the telemetry consumer. Rebuild the dependency graph only when definitions change. */ +class FormulaTelemetryEngine { + private var definitions: List? = null + private var lookup = FormulaVariableLookup(emptyMap()) + private val consumers = HashMap>() + private var rank = emptyMap() + private var blocked = emptySet() + private var constants = emptyList() + + fun reset() { + definitions = null + lookup = FormulaVariableLookup(emptyMap()) + consumers.clear() + rank = emptyMap() + blocked = emptySet() + constants = emptyList() + } + + /** Adds only affected formula aliases to updated; NaN must replace stale results. */ + fun update(formulas: List, values: MutableMap, updated: MutableSet) { + val hasPacketInputs = updated.isNotEmpty() + val changed = definitions != formulas + if (changed) rebuild(formulas, values) + lookup.bind(values, updated) + val affected = linkedSetOf() + val pending = ArrayDeque() + fun enqueue(index: Int) { + if (affected.add(index)) pending.add(index) + } + if (changed) formulas.indices.forEach(::enqueue) + else updated.forEach { name -> consumers[FormulaVariableLookup.key(name)]?.forEach(::enqueue) } + while (pending.isNotEmpty()) { + val formula = formulas[pending.removeFirst()] + aliases(formula).forEach { name -> consumers[FormulaVariableLookup.key(name)]?.forEach(::enqueue) } + } + // Sort only the affected subgraph, never walk every formula on an unrelated packet. + for (index in affected.sortedBy { rank[it] ?: Int.MAX_VALUE }) { + val formula = formulas[index] + val result = if (index in blocked) Double.NaN else FormulaEvaluator.evaluate(formula.expression, lookup::get) + aliases(formula).forEach { name -> + values[name] = result + updated.add(name) + } + } + // A constant has no triggering input but must still draw/record a time series. + // Reuse its compiled result instead of evaluating it on every packet. + if (hasPacketInputs) constants.forEach { index -> updated.addAll(aliases(formulas[index])) } + lookup.bind(values, updated) + } + + private fun rebuild(formulas: List, values: MutableMap) { + definitions?.flatMap(::aliases)?.forEach(values::remove) + definitions = formulas + lookup = FormulaVariableLookup(values) + consumers.clear() + val owners = HashMap() + val foldedOwners = HashMap() + val ambiguous = hashSetOf() + formulas.forEachIndexed { index, formula -> + aliases(formula).forEach { name -> + owners.putIfAbsent(name, index)?.let { previous -> + ambiguous.add(previous) + ambiguous.add(index) + } + foldedOwners.putIfAbsent(FormulaVariableLookup.key(name), index) + values[name] = Double.NaN + } + } + val dependencies = formulas.map { FormulaEvaluator.dependencies(it.expression) } + fun owner(name: String): Int? { + val key = FormulaVariableLookup.key(name) + return owners[name] ?: owners[key] ?: foldedOwners[key] + } + val predecessors = formulas.mapIndexed { index, _ -> + dependencies[index].mapNotNullTo(linkedSetOf()) { dependency -> + val key = FormulaVariableLookup.key(dependency) + consumers.getOrPut(key) { linkedSetOf() }.add(index) + owner(dependency) + } + } + val children = Array(formulas.size) { mutableListOf() } + val counts = IntArray(formulas.size) { predecessors[it].size } + ambiguous.forEach { counts[it]++ } // Duplicate output keys cannot have an unambiguous value. + predecessors.forEachIndexed { index, parents -> parents.forEach { children[it].add(index) } } + val queue = ArrayDeque() + formulas.indices.filter { counts[it] == 0 }.forEach(queue::add) + val ordered = mutableMapOf() + while (queue.isNotEmpty()) { + val index = queue.removeFirst() + ordered[index] = ordered.size + children[index].forEach { if (--counts[it] == 0) queue.add(it) } + } + rank = ordered + val constantGraph = linkedSetOf() + ordered.keys.forEach { index -> + if (FormulaEvaluator.validate(formulas[index].expression) && dependencies[index].all { owner(it) in constantGraph }) { + constantGraph.add(index) + } + } + constants = constantGraph.toList() + // Cycles and their descendants are invalid, not previous-frame values. + blocked = formulas.indices.filterTo(hashSetOf()) { counts[it] != 0 } + } + + private fun aliases(formula: FormulaDefinition) = listOf(formula.name, "formula_${formula.name}") +} diff --git a/Android/app/src/main/java/com/dct/app/core/formula/FormulaVariableLookup.kt b/Android/app/src/main/java/com/dct/app/core/formula/FormulaVariableLookup.kt new file mode 100644 index 0000000..bb009e3 --- /dev/null +++ b/Android/app/src/main/java/com/dct/app/core/formula/FormulaVariableLookup.kt @@ -0,0 +1,28 @@ +package com.dct.app.core.formula + +import java.util.Locale + +/** Exact names win; case-insensitive aliases are indexed once, not searched per variable. */ +internal class FormulaVariableLookup(private var values: Map) { + private val aliases = HashMap() + private var indexed = false + + fun bind(values: Map, updated: Set) { + this.values = values + if (indexed) updated.forEach { aliases.putIfAbsent(key(it), it) } + } + + fun get(name: String): Double? { + values[name]?.let { return it } + values[key(name)]?.let { return it } + if (!indexed) { + values.keys.forEach { aliases.putIfAbsent(key(it), it) } + indexed = true + } + return aliases[key(name)]?.let { values[it] } + } + + companion object { + fun key(name: String): String = name.lowercase(Locale.ROOT) + } +} diff --git a/Android/app/src/main/java/com/dct/app/core/formula/SMLinEvaluator.kt b/Android/app/src/main/java/com/dct/app/core/formula/SMLinEvaluator.kt index 203f6ac..88c9eeb 100644 --- a/Android/app/src/main/java/com/dct/app/core/formula/SMLinEvaluator.kt +++ b/Android/app/src/main/java/com/dct/app/core/formula/SMLinEvaluator.kt @@ -1,109 +1,74 @@ -package com.dct.app.core.formula +package com.dct.app.core.formula import java.util.Locale +import java.util.concurrent.ConcurrentHashMap -/** - * Sensor Module LIN 구간 선형 보간 엔진 - * PC 레거시 CSMLINParser 호환: LIN("01-01"; x1; y1; x2; y2; ...; xn; yn) - */ +/** PC CSMLINParser-compatible interpolation; syntax and points are compiled once. */ object SMLinEvaluator { - data class Point(val x: Double, val y: Double) - - /** - * LIN 수식 표현식을 파싱하고 주어진 변수 맵(텔레메트리)을 기준으로 보간값을 계산합니다. - * @param expr LIN 수식 문자열 (예: "LIN(\"01-01\"; 0.5; 0.0; 2.5; 50.0; 4.5; 100.0)") - * @param variables 실시간 신호/센서 텔레메트리 맵 - * @return 보간 계산 결과 Double (오류 시 Double.NaN) - */ - fun evaluate(expr: String, variables: Map): Double { - val trimmed = expr.trim() - if (!trimmed.startsWith("LIN(", ignoreCase = true) || !trimmed.endsWith(")")) { - return Double.NaN - } - - val inner = trimmed.substring(4, trimmed.length - 1).trim() - if (!inner.contains(';')) return Double.NaN - val parts = inner.split(';').map { it.trim() } - - if (parts.size < 5 || (parts.size - 1) % 2 != 0) { - return Double.NaN + private data class Compiled(val channels: List, val points: List) + private val cache = ConcurrentHashMap() + private val invalid = Compiled(emptyList(), emptyList()) + + private fun compile(expr: String): Compiled = cache.computeIfAbsent(expr) { + val trimmed = it.trim() + if (!trimmed.startsWith("LIN(", ignoreCase = true) || !trimmed.endsWith(")")) return@computeIfAbsent invalid + val parts = trimmed.substring(4, trimmed.length - 1).split(';').map(String::trim) + if (parts.size < 5 || (parts.size - 1) % 2 != 0) return@computeIfAbsent invalid + val raw = parts[0].removeSurrounding("\"").removeSurrounding("'").trim() + if (raw.isEmpty() || raw.contains('"') || raw.contains('\'')) return@computeIfAbsent invalid + val digits = raw.filter(Char::isDigit) + val channels = linkedSetOf(raw, normalizeChannelName(raw)) + if (digits.length == 4) channels.add("sm_${digits.take(2)}_${digits.takeLast(2)}") + channels.add(raw.replace(" ", "")) + channels.add(raw.replace("ch", "", ignoreCase = true).trim()) + if (digits.length == 4) { + channels.add("${digits.take(2)}-${digits.takeLast(2)}") + channels.add("${digits.take(2)}-${digits.takeLast(2)} ch") } - - // 1. 센서 채널 식별자 추출 (예: "01-01", "01-01 ch", "sm_01_01") - val rawCh = parts[0].replace("\"", "").replace("'", "").trim() - val chName = normalizeChannelName(rawCh) - - val cleanDigits = rawCh.replace(Regex("""[^0-9]"""), "") - val smUnderscore = if (cleanDigits.length >= 4) { - "sm_${cleanDigits.substring(0, 2)}_${cleanDigits.substring(2, 4)}" - } else "" - - val candidates = listOfNotNull( - rawCh, - chName, - if (smUnderscore.isNotEmpty()) smUnderscore else null, - rawCh.replace(" ", ""), - rawCh.replace("ch", "").trim() - ) - - // 2. 현재 센서 채널 값 조회 - val sensorValue = candidates.firstNotNullOfOrNull { variables[it] } - ?: variables.entries.find { entry -> candidates.any { c -> entry.key.equals(c, ignoreCase = true) } }?.value - ?: return Double.NaN - - if (sensorValue.isNaN()) return Double.NaN - - // 3. (x, y) 좌표점 쌍 추출 - val points = mutableListOf() + val points = ArrayList() for (i in 1 until parts.size step 2) { - val x = parts[i].toDoubleOrNull() ?: return Double.NaN - val y = parts[i + 1].toDoubleOrNull() ?: return Double.NaN + val x = parts[i].toDoubleOrNull() ?: return@computeIfAbsent invalid + val y = parts[i + 1].toDoubleOrNull() ?: return@computeIfAbsent invalid + if (!x.isFinite() || !y.isFinite() || (points.isNotEmpty() && x <= points.last().x)) return@computeIfAbsent invalid points.add(Point(x, y)) } + Compiled(channels.toList(), points) + } - if (points.size < 2) return Double.NaN - if (points.zipWithNext().any { (left, right) -> right.x <= left.x }) return Double.NaN - - // 4. PC CSMLINParser와 동일하게 입력된 순서의 구간을 사용한다. - val len = points.size - - if (sensorValue <= points.first().x) { - return points.first().y - } - if (sensorValue >= points.last().x) { - return points.last().y - } - - for (i in 0 until len - 1) { - val p1 = points[i] - val p2 = points[i + 1] - - if (sensorValue >= p1.x && sensorValue <= p2.x) { - val slope = (p2.y - p1.y) / (p2.x - p1.x) - return p1.y + slope * (sensorValue - p1.x) - } + fun validate(expr: String): Boolean = compile(expr).points.isNotEmpty() + internal fun dependencies(expr: String): Set = compile(expr).channels.toSet() + internal fun clearCache() = cache.clear() + + fun evaluate(expr: String, variables: Map): Double = + evaluate(expr, FormulaVariableLookup(variables)::get) + + internal fun evaluate(expr: String, resolve: (String) -> Double?): Double { + val compiled = compile(expr) + if (compiled.points.isEmpty()) return Double.NaN + val sensor = compiled.channels.firstNotNullOfOrNull(resolve) ?: return Double.NaN + if (!sensor.isFinite()) return Double.NaN + val points = compiled.points + if (sensor <= points.first().x) return points.first().y + if (sensor >= points.last().x) return points.last().y + // Binary search preserves input order and avoids scanning all calibration points. + var low = 0 + var high = points.lastIndex + while (high - low > 1) { + val mid = (low + high) / 2 + if (sensor < points[mid].x) high = mid else low = mid } - - return Double.NaN + val left = points[low] + val right = points[high] + return (left.y + (right.y - left.y) * ((sensor - left.x) / (right.x - left.x))) + .takeIf { it.isFinite() } ?: Double.NaN } - /** - * 채널명을 표준 형식(예: "01-01 ch")으로 정규화합니다. - */ fun normalizeChannelName(raw: String): String { val clean = raw.trim() - if (clean.matches(Regex("""^\d{2}-\d{2}\s*ch$""", RegexOption.IGNORE_CASE))) { - return clean.lowercase(Locale.ROOT) - } - if (clean.matches(Regex("""^\d{2}-\d{2}$"""))) { - return "$clean ch" - } - if (clean.matches(Regex("""^sm_\d{2}_\d{2}$""", RegexOption.IGNORE_CASE))) { - val m = clean.substring(3, 5) - val c = clean.substring(6, 8) - return "$m-$c ch" - } + if (clean.matches(Regex("""^\d{2}-\d{2}\s*ch$""", RegexOption.IGNORE_CASE))) return clean.lowercase(Locale.ROOT) + if (clean.matches(Regex("""^\d{2}-\d{2}$"""))) return "$clean ch" + if (clean.matches(Regex("""^sm_\d{2}_\d{2}$""", RegexOption.IGNORE_CASE))) return "${clean.substring(3, 5)}-${clean.substring(6, 8)} ch" return clean } } diff --git a/Android/app/src/main/java/com/dct/app/core/theme/DctColors.kt b/Android/app/src/main/java/com/dct/app/core/theme/DctColors.kt index 2d3c11c..363ce24 100644 --- a/Android/app/src/main/java/com/dct/app/core/theme/DctColors.kt +++ b/Android/app/src/main/java/com/dct/app/core/theme/DctColors.kt @@ -61,6 +61,50 @@ object DctColors { val StatusInfo = StatusCyan } +/** + * PC DCT가 프로젝트 파일에 저장하는 Win32 COLORREF 값 모음입니다. + * + * COLORREF는 `0x00BBGGRR` 순서이며 Android/Compose의 ARGB(`0xAARRGGBB`)와 + * 표현 순서가 다릅니다. 신호/축/타일 색상 Long은 이 형식을 유일한 저장 + * 형식으로 사용해야 PC 프로젝트와 색상이 동일하게 유지됩니다. + */ +object PcColorRef { + const val DEFAULT_SIGNAL: Long = 0x00F39621L // RGB #2196F3 + const val DEFAULT_SENSOR: Long = 0x004C9A00L // RGB #009A4C + const val DEFAULT_AXIS: Long = 0x004D2B17L // RGB #172B4D + const val DEFAULT_NEUTRAL: Long = 0x00424242L // RGB #424242 + const val DEFAULT_DASHBOARD: Long = 0x00D27619L // RGB #1976D2 + const val DEFAULT_ACCENT: Long = 0x00CC5200L // RGB #0052CC + + /** Compose/Android RGB 채널을 PC COLORREF로 변환합니다. */ + fun fromRgb(red: Int, green: Int, blue: Int): Long = + (red.toLong() and 0xFFL) or + ((green.toLong() and 0xFFL) shl 8) or + ((blue.toLong() and 0xFFL) shl 16) + + /** + * 기존 Android 프로젝트에 남아 있는 ARGB 값도 한 번만 정규화합니다. + * 상위 바이트가 0이면 이미 PC COLORREF이므로 하위 24비트만 유지합니다. + */ + fun normalize(value: Long): Long { + val raw = value and 0xFFFFFFFFL + return if ((raw and 0xFF000000L) != 0L) { + fromRgb( + red = ((raw ushr 16) and 0xFFL).toInt(), + green = ((raw ushr 8) and 0xFFL).toInt(), + blue = (raw and 0xFFL).toInt() + ) + } else { + raw and 0xFFFFFFL + } + } +} + +/** PC 호환 색상 저장 경계에서 호출하는 정규화 함수입니다. */ +fun Long.toColorRef(): Long = PcColorRef.normalize(this) + +fun Int.toColorRef(): Long = toLong().toColorRef() + /** * Windows Win32 COLORREF (0x00BBGGRR) 색상 값을 Compose Color(sRGB: 0xFFAARRGGBB)로 변환합니다. * - Red: (colorRef & 0xFF) @@ -68,7 +112,7 @@ object DctColors { * - Blue: ((colorRef >> 16) & 0xFF) */ fun Long.toComposeColor(): Color { - val colorRef = this and 0xFFFFFFL + val colorRef = toColorRef() val r = (colorRef and 0xFFL).toInt() val g = ((colorRef shr 8) and 0xFFL).toInt() val b = ((colorRef shr 16) and 0xFFL).toInt() @@ -80,7 +124,7 @@ fun Int.toComposeColor(): Color { } fun Long.toAndroidColorInt(): Int { - val colorRef = this and 0xFFFFFFL + val colorRef = toColorRef() val r = (colorRef and 0xFFL).toInt() val g = ((colorRef shr 8) and 0xFFL).toInt() val b = ((colorRef shr 16) and 0xFFL).toInt() diff --git a/Android/app/src/main/java/com/dct/app/core/theme/SignalColorPresets.kt b/Android/app/src/main/java/com/dct/app/core/theme/SignalColorPresets.kt new file mode 100644 index 0000000..2953f55 --- /dev/null +++ b/Android/app/src/main/java/com/dct/app/core/theme/SignalColorPresets.kt @@ -0,0 +1,11 @@ +package com.dct.app.core.theme + +/** Shared graph signal presets, stored as PC COLORREF rather than Android ARGB. */ +object SignalColorPresets { + val colors: List = listOf( + 0x2196F3, 0x4CAF50, 0xFF9800, 0xE91E63, 0x9C27B0, 0x00BCD4, + 0xFFEB3B, 0xF44336, 0x0D47A1, 0x1B5E20, 0xFF6F00, 0x880E4F, + 0x3F51B5, 0x009688, 0x827717, 0x795548, 0x64B5F6, 0x8BC34A, + 0xFFAB91, 0xB39DDB, 0x607D8B, 0x424242, 0x000000, 0x9C006B + ).map { rgb -> PcColorRef.fromRgb(rgb ushr 16, rgb ushr 8, rgb) } +} diff --git a/Android/app/src/main/java/com/dct/app/data/communication/OrderedPacketWriter.kt b/Android/app/src/main/java/com/dct/app/data/communication/OrderedPacketWriter.kt new file mode 100644 index 0000000..0dffcd3 --- /dev/null +++ b/Android/app/src/main/java/com/dct/app/data/communication/OrderedPacketWriter.kt @@ -0,0 +1,105 @@ +package com.dct.app.data.communication + +import kotlinx.coroutines.* +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.selects.select +import java.util.concurrent.atomic.AtomicBoolean + +/** One bounded writer per socket. Closing that socket interrupts a blocked JVM write. */ +internal class OrderedPacketWriter( + scope: CoroutineScope, + private val write: (ByteArray) -> Unit, + private val closeTransport: () -> Unit, + private val timeoutMs: Long = 3_000, + capacity: Int = 64, + private val onFailure: (Throwable) -> Unit = {} +) { + private class Request(val bytes: ByteArray, val result: CompletableDeferred = CompletableDeferred()) + private val closed = AtomicBoolean() + // Control requests (handshake/START/STOP/configuration) must not wait + // behind a burst of fire-and-forget stream ACKs. They still share one + // worker so frames can never overlap on the TCP byte stream. + private val controlQueue = Channel( + maxOf(8, capacity / 4), + onUndeliveredElement = { it.result.complete(false) } + ) + private val streamQueue = Channel( + capacity, + onUndeliveredElement = { it.result.complete(false) } + ) + + private suspend fun receiveNext(): Request = select { + // Prefer control traffic whenever both channels are ready. The + // worker also performs a non-suspending control drain between writes, + // so a STOP arriving while telemetry is queued is serviced promptly. + controlQueue.onReceive { it } + streamQueue.onReceive { it } + } + + private val worker = scope.launch(Dispatchers.IO) { + try { + while (isActive) { + val request = controlQueue.tryReceive().getOrNull() ?: receiveNext() + // An expired queued START/STOP must not change the peer later. + if (request.result.isCancelled) continue + try { + write(request.bytes) + request.result.complete(true) + } catch (e: Exception) { + request.result.complete(false) + throw e + } + } + } catch (e: Exception) { + if (e !is CancellationException) onFailure(e) + } finally { + close() + } + } + + suspend fun send(bytes: ByteArray): Boolean { + if (closed.get()) return false + val request = Request(bytes) + return try { + // A full queue or a slow socket may make this caller wait longer + // than the command budget. Expire only this request's wait; do + // not close the transport while the single writer can still drain + // an in-flight packet. The worker remains the owner of real I/O + // failures and closes the socket only when write() throws. + withTimeout(timeoutMs) { controlQueue.send(request) } + withTimeout(timeoutMs) { request.result.await() } + } catch (_: TimeoutCancellationException) { + request.result.cancel() + false + } catch (e: CancellationException) { + // Cancelling one caller must not cancel the session's shared writer. + request.result.cancel() + throw e + } catch (_: Exception) { + request.result.cancel() + false + } + } + + /** + * Enqueue a fire-and-forget packet without waiting for the socket write. + * Stream ACKs use this path so a slow UI subscriber cannot delay the + * protocol acknowledgement and trigger peer-side backpressure. + */ + fun offer(bytes: ByteArray): Boolean { + if (closed.get()) return false + val request = Request(bytes) + val result = streamQueue.trySend(request) + if (result.isFailure) request.result.complete(false) + return result.isSuccess + } + + fun close() { + if (closed.compareAndSet(false, true)) { + controlQueue.cancel() + streamQueue.cancel() + // Do not wait for the worker: socket.close() must release its blocking write first. + closeTransport() + } + } +} diff --git a/Android/app/src/main/java/com/dct/app/data/communication/WifiDirectManager.kt b/Android/app/src/main/java/com/dct/app/data/communication/WifiDirectManager.kt index f06e50c..bf69f20 100644 --- a/Android/app/src/main/java/com/dct/app/data/communication/WifiDirectManager.kt +++ b/Android/app/src/main/java/com/dct/app/data/communication/WifiDirectManager.kt @@ -1,434 +1,504 @@ 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.net.wifi.p2p.WifiP2pConfig -import android.net.wifi.p2p.WifiP2pDevice -import android.net.wifi.p2p.WifiP2pDeviceList -import android.net.wifi.p2p.WifiP2pGroup -import android.net.wifi.p2p.WifiP2pInfo -import android.net.wifi.p2p.WifiP2pManager +import android.content.* +import android.content.pm.PackageManager +import android.location.LocationManager +import android.net.ConnectivityManager +import android.net.Network +import android.net.NetworkCapabilities +import android.net.NetworkRequest +import android.net.LinkProperties +import android.net.wifi.WifiManager +import android.net.wifi.WpsInfo +import android.net.wifi.p2p.* import android.os.Build import android.os.Looper -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.isActive -import kotlinx.coroutines.launch -import java.net.DatagramPacket -import java.net.DatagramSocket -import java.net.InetSocketAddress - -data class DiscoveredLanServer( - val deviceName: String, - val hostIp: String, - val port: Int = 6000, - val lastSeen: Long = System.currentTimeMillis() -) - -/** - * Android 표준 Wi-Fi Direct 실제 통신 관리자 및 LAN UDP 자동 디스커버리 - */ -class WifiDirectManager( - private val context: Context -) { - private val wifiP2pManager: WifiP2pManager? = context.getSystemService(Context.WIFI_P2P_SERVICE) as? WifiP2pManager +import android.util.Log +import androidx.core.content.ContextCompat +import androidx.core.location.LocationManagerCompat +import kotlinx.coroutines.* +import kotlinx.coroutines.flow.* +import java.net.NetworkInterface + +/** ViewModel-owned OS P2P controller. Commands/callbacks serialize on Main; no Activity context. */ +class WifiDirectManager(context: Context) { + private val context = context.applicationContext + private val connectivityManager = this.context.getSystemService(ConnectivityManager::class.java) + private val manager = this.context.getSystemService(WifiP2pManager::class.java) + private val supported = this.context.packageManager.hasSystemFeature(PackageManager.FEATURE_WIFI_DIRECT) && manager != null + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate) private var channel: WifiP2pManager.Channel? = null - private var receiver: BroadcastReceiver? = null - - private val _isP2pEnabled = MutableStateFlow(false) - val isP2pEnabled: StateFlow = _isP2pEnabled.asStateFlow() - - private val _isSearching = MutableStateFlow(false) - val isSearching: StateFlow = _isSearching.asStateFlow() - - private val _peers = MutableStateFlow>(emptyList()) - val peers: StateFlow> = _peers.asStateFlow() - - private val _lanServers = MutableStateFlow>(emptyList()) - val lanServers: StateFlow> = _lanServers.asStateFlow() - - private val _connectionInfo = MutableStateFlow(null) - val connectionInfo: StateFlow = _connectionInfo.asStateFlow() - - private val _groupInfo = MutableStateFlow(null) - val groupInfo: StateFlow = _groupInfo.asStateFlow() - - private val _thisDevice = MutableStateFlow(null) - val thisDevice: StateFlow = _thisDevice.asStateFlow() - - private val _connectedDeviceName = MutableStateFlow(null) - val connectedDeviceName: StateFlow = _connectedDeviceName.asStateFlow() - - private val _connectedHostIp = MutableStateFlow(null) - val connectedHostIp: StateFlow = _connectedHostIp.asStateFlow() - - private val _lastErrorReason = MutableStateFlow(null) - val lastErrorReason: StateFlow = _lastErrorReason.asStateFlow() - - private var pendingIpCallback: ((String) -> Unit)? = null - private var udpDiscoveryJob: Job? = null - private val scope = CoroutineScope(Dispatchers.IO) - - init { - wifiP2pManager?.let { manager -> - channel = manager.initialize(context, Looper.getMainLooper()) { - // Channel lost - } + private var registered = false + private var closed = false + private var epoch = 0L + private var query = 0L + private var operation = 0L + private var discovery = 0L + private var deadline: Job? = null + private var discoveryDeadline: Job? = null + private var recovery: Job? = null + private var recoveryAttempts = 0 + private var syncWatch: Job? = null + private var confirmedQuery = 0L + private var networkRegistered = false + private val networks = java.util.concurrent.ConcurrentHashMap() + private val networkCallback = object : ConnectivityManager.NetworkCallback() { + override fun onLinkPropertiesChanged(network: Network, properties: LinkProperties) { + networks[network] = properties } + override fun onLost(network: Network) { networks.remove(network) } + } + private var cancelAccepted = false + private var removalAccepted = false + private val syncWaiters = mutableListOf<() -> Unit>() + private val _uiState = MutableStateFlow(WifiDirectUiState(supported = supported)) + val uiState: StateFlow = _uiState.asStateFlow() + + private fun publish(change: (WifiDirectUiState) -> WifiDirectUiState) { + val old = _uiState.value + _uiState.value = change(old) + if (old.phase != _uiState.value.phase || old.link?.key != _uiState.value.link?.key) + Log.i(TAG, "P2P state=${_uiState.value.phase}, group=${_uiState.value.link?.key}, error=${_uiState.value.error}") } - /** - * BroadcastReceiver 등록 및 UDP LAN 디스커버리 시작 - */ - fun registerReceiver() { - if (receiver != null) return - - val intentFilter = IntentFilter().apply { + fun start(): Job = scope.launch { + if (closed || registered) return@launch + if (!supported) { + publish { it.copy(phase = WifiDirectPhase.UNSUPPORTED, error = "이 기기는 Wi-Fi Direct를 지원하지 않습니다.") } + return@launch + } + val filter = IntentFilter().apply { addAction(WifiP2pManager.WIFI_P2P_STATE_CHANGED_ACTION) addAction(WifiP2pManager.WIFI_P2P_PEERS_CHANGED_ACTION) addAction(WifiP2pManager.WIFI_P2P_CONNECTION_CHANGED_ACTION) addAction(WifiP2pManager.WIFI_P2P_THIS_DEVICE_CHANGED_ACTION) + addAction(WifiP2pManager.WIFI_P2P_DISCOVERY_CHANGED_ACTION) + addAction(LocationManager.MODE_CHANGED_ACTION) } - - receiver = object : BroadcastReceiver() { - @SuppressLint("MissingPermission") - override fun onReceive(context: Context?, intent: Intent?) { - when (intent?.action) { - WifiP2pManager.WIFI_P2P_STATE_CHANGED_ACTION -> { - val state = intent.getIntExtra(WifiP2pManager.EXTRA_WIFI_STATE, -1) - _isP2pEnabled.value = (state == WifiP2pManager.WIFI_P2P_STATE_ENABLED) - } - WifiP2pManager.WIFI_P2P_PEERS_CHANGED_ACTION -> { - channel?.let { ch -> - wifiP2pManager?.requestPeers(ch) { peerList: WifiP2pDeviceList? -> - val deviceList = peerList?.deviceList?.toList() ?: emptyList() - _peers.value = deviceList - } - } - } - WifiP2pManager.WIFI_P2P_CONNECTION_CHANGED_ACTION -> { - refreshConnectionAndPeers() - } - WifiP2pManager.WIFI_P2P_THIS_DEVICE_CHANGED_ACTION -> { - val thisDev = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - intent.getParcelableExtra(WifiP2pManager.EXTRA_WIFI_P2P_DEVICE, WifiP2pDevice::class.java) - } else { - @Suppress("DEPRECATION") - intent.getParcelableExtra(WifiP2pManager.EXTRA_WIFI_P2P_DEVICE) - } - _thisDevice.value = thisDev - } - } - } + guarded("Wi-Fi Direct 초기화") { + // Framework broadcasts can originate from privileged non-system UIDs. + ContextCompat.registerReceiver(context, receiver, filter, ContextCompat.RECEIVER_EXPORTED) + registered = true + connectivityManager?.registerNetworkCallback( + NetworkRequest.Builder().clearCapabilities() + .addTransportType(NetworkCapabilities.TRANSPORT_WIFI).build(), networkCallback) + networkRegistered = connectivityManager != null + initializeChannel() } - - try { - context.registerReceiver(receiver, intentFilter) - refreshConnectionAndPeers() - } catch (_: Exception) {} - - startUdpDiscovery() } - private var multicastLock: android.net.wifi.WifiManager.MulticastLock? = null + private fun initializeChannel() { + if (closed) return + val generation = ++epoch + channel?.close() + channel = manager?.initialize(context, Looper.getMainLooper()) { + if (closed || generation != epoch) return@initialize + epoch++; query++ + deadline?.cancel(); discoveryDeadline?.cancel() + channel = null + publish { it.copy(phase = WifiDirectPhase.ERROR, synchronizedWithOs = false, + discovering = false, error = "Wi-Fi Direct 시스템 채널이 끊겼습니다. 상태를 다시 확인합니다.") } + if (recoveryAttempts++ < 1) + recovery = scope.launch { delay(500); initializeChannel() } + } + refresh() + } - private fun startUdpDiscovery() { - if (udpDiscoveryJob != null) return - try { - val wm = context.applicationContext.getSystemService(android.content.Context.WIFI_SERVICE) as? android.net.wifi.WifiManager - multicastLock = wm?.createMulticastLock("DctUdpDiscoveryLock")?.apply { - setReferenceCounted(true) - acquire() + /** Reconcile after foreground/permission changes, including groups created in Settings. */ + fun refresh(): Job = scope.launch { + if (closed || !supported) return@launch + if (!registered) { start(); return@launch } + if (channel == null) { + if (recovery?.isActive != true) guarded("채널 복구") { initializeChannel() } + return@launch + } + updateRequirements() + val ch = channel ?: return@launch + val generation = epoch + guarded("Wi-Fi Direct 상태 확인") { + if (Build.VERSION.SDK_INT >= 29) { + manager!!.requestP2pState(ch) { value -> + if (generation == epoch && !closed) setEnabled(value == WifiP2pManager.WIFI_P2P_STATE_ENABLED) + } + } else { + // API 26-28: sticky P2P broadcasts are authoritative; initial fallback only. + if (_uiState.value.phase == WifiDirectPhase.CHECKING) + setEnabled(context.getSystemService(WifiManager::class.java)?.isWifiEnabled == true) } - } catch (_: Throwable) {} - - udpDiscoveryJob = scope.launch { - var socket: DatagramSocket? = null - try { - socket = DatagramSocket(null).apply { - reuseAddress = true - bind(InetSocketAddress(6005)) - soTimeout = 3000 + if (_uiState.value.permissionGranted) { + if (Build.VERSION.SDK_INT >= 29) manager!!.requestDeviceInfo(ch) { device -> + if (generation == epoch && !closed) publish { it.copy(thisDevice = device) } } - val buffer = ByteArray(1024) - val packet = DatagramPacket(buffer, buffer.size) - - while (isActive) { - try { - socket.receive(packet) - val text = String(packet.data, 0, packet.length, Charsets.UTF_8).trim() - if (text.startsWith("DCT_BEACON|")) { - val parts = text.split("|") - if (parts.size >= 4) { - val srvName = parts[1] - val srvIp = parts[2] - val srvPort = parts[3].toIntOrNull() ?: 6000 + synchronize() + requestPeers() + } + } + } - val server = DiscoveredLanServer( - deviceName = srvName, - hostIp = srvIp, - port = srvPort, - lastSeen = System.currentTimeMillis() - ) + private fun updateRequirements() { + val granted = WifiDirectRequirements.runtimePermissions(Build.VERSION.SDK_INT).all { + ContextCompat.checkSelfPermission(context, it) == PackageManager.PERMISSION_GRANTED + } + val location = context.getSystemService(LocationManager::class.java)?.let { + LocationManagerCompat.isLocationEnabled(it) + } == true + publish { it.copy(permissionGranted = granted, locationEnabled = location, + phase = if (!granted) WifiDirectPhase.PERMISSION_REQUIRED else it.phase, + synchronizedWithOs = if (!granted) false else it.synchronizedWithOs, + error = if (!granted) "주변 Wi-Fi 기기 검색 및 연결 권한이 필요합니다." else it.error) } + if ((!granted || !location) && _uiState.value.discovering) stopPeerDiscovery() + if (!granted) { + operation++ + deadline?.cancel() + syncWaiters.clear() + } + } - val current = _lanServers.value.filter { - it.hostIp != srvIp && (System.currentTimeMillis() - it.lastSeen < 10000) - } - _lanServers.value = listOf(server) + current - } - } - } catch (_: java.net.SocketTimeoutException) { - // Clean up stale servers (> 10s) - _lanServers.value = _lanServers.value.filter { - System.currentTimeMillis() - it.lastSeen < 10000 - } - } catch (_: Exception) {} - } - } catch (_: Exception) { - } finally { - socket?.close() - } + private fun setEnabled(enabled: Boolean) { + if (!enabled) { + operation++; query++; discovery++ + deadline?.cancel(); discoveryDeadline?.cancel() + syncWaiters.clear() + publish { it.copy(enabled = false, phase = WifiDirectPhase.DISABLED, link = null, + target = null, discovering = false, peers = emptyList(), synchronizedWithOs = true, + error = "Wi-Fi가 꺼져 있습니다. Wi-Fi를 켜 주세요.") } + } else { + val changed = !_uiState.value.enabled + publish { it.copy(enabled = true, error = if (changed) null else it.error) } + if (changed && _uiState.value.permissionGranted) synchronize() } } - private fun stopUdpDiscovery() { - udpDiscoveryJob?.cancel() - udpDiscoveryJob = null - try { - if (multicastLock?.isHeld == true) { - multicastLock?.release() + private val receiver = object : BroadcastReceiver() { + override fun onReceive(context: Context?, intent: Intent?) { + if (closed) return + when (intent?.action) { + WifiP2pManager.WIFI_P2P_STATE_CHANGED_ACTION -> + setEnabled(intent.getIntExtra(WifiP2pManager.EXTRA_WIFI_STATE, -1) == WifiP2pManager.WIFI_P2P_STATE_ENABLED) + WifiP2pManager.WIFI_P2P_PEERS_CHANGED_ACTION -> requestPeers() + WifiP2pManager.WIFI_P2P_CONNECTION_CHANGED_ACTION -> synchronize() + WifiP2pManager.WIFI_P2P_THIS_DEVICE_CHANGED_ACTION -> { + val device = if (Build.VERSION.SDK_INT >= 33) + intent.getParcelableExtra(WifiP2pManager.EXTRA_WIFI_P2P_DEVICE, WifiP2pDevice::class.java) + else { + @Suppress("DEPRECATION") + intent.getParcelableExtra(WifiP2pManager.EXTRA_WIFI_P2P_DEVICE) + } + publish { it.copy(thisDevice = device) } + synchronize() + } + WifiP2pManager.WIFI_P2P_DISCOVERY_CHANGED_ACTION -> { + val active = intent.getIntExtra(WifiP2pManager.EXTRA_DISCOVERY_STATE, -1) == WifiP2pManager.WIFI_P2P_DISCOVERY_STARTED + publish { it.copy(discovering = active, phase = + if (it.phase in setOf(WifiDirectPhase.IDLE, WifiDirectPhase.DISCOVERING)) + if (active) WifiDirectPhase.DISCOVERING else WifiDirectPhase.IDLE else it.phase) } + if (!active) discoveryDeadline?.cancel() + } + LocationManager.MODE_CHANGED_ACTION -> refresh() } - multicastLock = null - } catch (_: Throwable) {} + } } - /** - * 연결 상태, 그룹 정보, 피어 목록을 일괄 요청하여 동기화 - */ + /** Publish info/group atomically, and ignore callbacks from superseded queries/channels. */ @SuppressLint("MissingPermission") - fun refreshConnectionAndPeers() { + private fun synchronize(after: (() -> Unit)? = null) { + if (after != null) syncWaiters.add(after) val ch = channel ?: return - wifiP2pManager?.requestConnectionInfo(ch) { info: WifiP2pInfo? -> - _connectionInfo.value = info - if (info?.groupFormed == true) { - val ownerIp = info.groupOwnerAddress?.hostAddress - _connectedHostIp.value = ownerIp - if (ownerIp != null) { - pendingIpCallback?.invoke(ownerIp) - pendingIpCallback = null - } - } else { - _connectedDeviceName.value = null - _connectedHostIp.value = null - _groupInfo.value = null - pendingIpCallback = null + if (!_uiState.value.permissionGranted) return + val revision = ++query + val generation = epoch + // Lost callbacks or temporarily inconsistent snapshots must not leave a permanent spinner. + if (syncWatch?.isActive != true) syncWatch = scope.launch { + repeat(5) { + delay(1000) + if (closed || generation != epoch || confirmedQuery >= revision) return@launch + synchronize() + } + if (confirmedQuery < revision && generation == epoch) { + publish { it.copy(synchronizedWithOs = false, phase = WifiDirectPhase.ERROR, + error = "Wi-Fi Direct 상태 응답을 확인하지 못했습니다. 새로고침해 주세요.") } } } - - wifiP2pManager?.requestGroupInfo(ch) { group: WifiP2pGroup? -> - _groupInfo.value = group - if (group != null) { - if (_connectedDeviceName.value == null) { - val isOwner = _connectionInfo.value?.isGroupOwner == true - if (isOwner) { - val firstClient = group.clientList.firstOrNull() - if (firstClient != null) { - _connectedDeviceName.value = firstClient.deviceName.ifBlank { firstClient.deviceAddress } + guarded("연결 상태 확인") { + manager!!.requestConnectionInfo(ch) { info -> + if (closed || generation != epoch || revision != query) return@requestConnectionInfo + guarded("그룹 정보 확인") { + manager.requestGroupInfo(ch) { group -> + if (closed || generation != epoch || revision != query) return@requestGroupInfo + if ((info?.groupFormed == true) != (group != null)) { + // Unknown during a transition is not evidence of disconnect. + publish { it.copy(synchronizedWithOs = false) } + return@requestGroupInfo } - } else { - val owner = group.owner - if (owner != null) { - _connectedDeviceName.value = owner.deviceName.ifBlank { owner.deviceAddress } + val link = if (info?.groupFormed == true && group != null) WifiDirectLink(info, group) else null + confirmedQuery = revision + val phase = _uiState.value.phase + val invited = _uiState.value.thisDevice?.status == WifiP2pDevice.INVITED || + _uiState.value.peers.any { it.status == WifiP2pDevice.INVITED } + val next = when { + !_uiState.value.enabled -> WifiDirectPhase.DISABLED + phase == WifiDirectPhase.DISCONNECTING && link != null -> phase + phase == WifiDirectPhase.CANCELLING && !cancelAccepted -> phase + link != null -> WifiDirectPhase.CONNECTED + phase == WifiDirectPhase.CONNECTING -> phase + phase == WifiDirectPhase.CANCELLING && invited -> phase + phase == WifiDirectPhase.DISCONNECTING && !removalAccepted -> phase + _uiState.value.discovering -> WifiDirectPhase.DISCOVERING + else -> WifiDirectPhase.IDLE } + publish { it.copy(link = link, phase = next, synchronizedWithOs = true, + target = if (next in pendingPhases) it.target else null, + error = if ((next == WifiDirectPhase.CONNECTED && phase in pendingPhases) || + phase == WifiDirectPhase.PERMISSION_REQUIRED) null else it.error) } + if (next !in pendingPhases) deadline?.cancel() + val callbacks = syncWaiters.toList() + syncWaiters.clear() + callbacks.forEach { it() } } } } } - - wifiP2pManager?.requestPeers(ch) { peerList: WifiP2pDeviceList? -> - val deviceList = peerList?.deviceList?.toList() ?: emptyList() - _peers.value = deviceList - } } - /** - * 앱 실행 시 이미 Wi-Fi Direct 그룹이 연결되어 있는지 확인하고 콜백 반환 (isGroupOwner 포함) - */ @SuppressLint("MissingPermission") - fun checkExistingConnection( - onConnected: (deviceName: String, hostIp: String, isGroupOwner: Boolean) -> Unit - ) { + private fun requestPeers() { + updateRequirements() + if (!_uiState.value.permissionGranted || !_uiState.value.locationEnabled) return val ch = channel ?: return - wifiP2pManager?.requestConnectionInfo(ch) { info: WifiP2pInfo? -> - if (info?.groupFormed == true) { - _connectionInfo.value = info - val hostIp = info.groupOwnerAddress?.hostAddress ?: "192.168.49.1" - _connectedHostIp.value = hostIp - val isOwner = info.isGroupOwner - - wifiP2pManager.requestGroupInfo(ch) { group: WifiP2pGroup? -> - _groupInfo.value = group - val devName = if (isOwner) { - group?.clientList?.firstOrNull()?.let { it.deviceName.ifBlank { it.deviceAddress } } ?: "Wi-Fi Direct Client" - } else { - group?.owner?.let { it.deviceName.ifBlank { it.deviceAddress } } ?: "Wi-Fi Direct Owner" - } - _connectedDeviceName.value = devName - onConnected(devName, hostIp, isOwner) + val generation = epoch + val token = operation + guarded("기기 목록 확인") { + manager!!.requestPeers(ch) { list -> + if (generation != epoch || token != operation || closed) return@requestPeers + val peers = list.deviceList.map { WifiP2pDevice(it) }.distinctBy { it.deviceAddress } + .sortedWith(compareBy({ it.deviceName.lowercase() }, { it.deviceAddress })) + publish { it.copy(peers = peers) } + val invited = peers.firstOrNull { it.status == WifiP2pDevice.INVITED } + if (invited != null && _uiState.value.link == null && + _uiState.value.phase in setOf(WifiDirectPhase.IDLE, WifiDirectPhase.DISCOVERING)) { + publish { it.copy(phase = WifiDirectPhase.CONNECTING, target = invited) } + watchOperation(++operation, 45_000) } + if (_uiState.value.phase == WifiDirectPhase.CANCELLING) synchronize() } } } - @SuppressLint("MissingPermission") - fun checkExistingConnection( - onConnected: (deviceName: String, hostIp: String) -> Unit - ) { - checkExistingConnection { dev, ip, _ -> onConnected(dev, ip) } + fun discoverPeers() = scope.launch { + updateRequirements() + if (!_uiState.value.canDiscover) { + if (!_uiState.value.locationEnabled && _uiState.value.permissionGranted) + publish { it.copy(error = "기기 검색을 위해 위치 서비스를 켜 주세요.") } + return@launch + } + val ch = channel ?: return@launch + val token = ++discovery + val generation = epoch + publish { it.copy(discovering = true, error = null, + phase = if (it.link == null) WifiDirectPhase.DISCOVERING else it.phase) } + // Bound the request as well as discovery; a lost ActionListener cannot spin forever. + discoveryDeadline?.cancel() + discoveryDeadline = scope.launch { delay(20_000); stopPeerDiscovery() } + guarded("기기 검색", discoveryOnly = true) { + manager!!.discoverPeers(ch, listener("기기 검색", { token == discovery && generation == epoch }, + discoveryOnly = true, + success = { /* Accepted, not completed: peers arrive through requestPeers. */ }, + failure = { discoveryDeadline?.cancel(); publish { it.copy(discovering = false) } })) + } } - /** - * BroadcastReceiver 해제 - */ - fun unregisterReceiver() { - stopUdpDiscovery() - receiver?.let { - try { - context.unregisterReceiver(it) - } catch (_: Exception) {} - receiver = null + fun stopPeerDiscovery() = scope.launch { + val token = ++discovery + discoveryDeadline?.cancel() + // Closing the dialog must not interrupt negotiation or overwrite its watchdog state. + // Discovery broadcasts remain authoritative while a link operation is pending. + if (_uiState.value.phase in pendingPhases) return@launch + val ch = channel ?: return@launch + val generation = epoch + discoveryDeadline = scope.launch { + delay(2000) + if (token == discovery && generation == epoch && !closed && _uiState.value.discovering) { + discovery++ // Ignore an acknowledgement arriving after this request expired. + reportDiscoveryFailure("검색 종료 응답이 지연되었습니다. 새로고침해 주세요.") + } + } + guarded("검색 중지", discoveryOnly = true) { + manager!!.stopPeerDiscovery(ch, listener("검색 중지", { generation == epoch && token == discovery }, discoveryOnly = true, success = { + discoveryDeadline?.cancel() + publish { it.copy(discovering = false, + phase = if (it.phase == WifiDirectPhase.DISCOVERING) WifiDirectPhase.IDLE else it.phase) } + }, failure = { discoveryDeadline?.cancel() })) } } - /** - * 주변 Wi-Fi Direct 피어 실제 탐색 시작 (일시적 BUSY 시 1회 자동 재시도) - */ - @SuppressLint("MissingPermission") - fun discoverPeers( - isRetry: Boolean = false, - onSuccess: () -> Unit = {}, - onFailure: (Int) -> Unit = {} - ) { - val ch = channel ?: return - _isSearching.value = true - _lastErrorReason.value = null - wifiP2pManager?.discoverPeers(ch, object : WifiP2pManager.ActionListener { - override fun onSuccess() { - _isSearching.value = true - _lastErrorReason.value = null - onSuccess() + /** Recheck OS group before connect; never silently remove an existing group. */ + fun connect(device: WifiP2pDevice) = scope.launch { + updateRequirements() + if (!_uiState.value.canConnect) return@launch + val token = ++operation + discovery++; discoveryDeadline?.cancel() + publish { it.copy(phase = WifiDirectPhase.CONNECTING, target = WifiP2pDevice(device), error = null) } + watchOperation(token, 45_000) + synchronize { + if (token != operation || _uiState.value.phase != WifiDirectPhase.CONNECTING || _uiState.value.link != null) return@synchronize + val ch = channel ?: return@synchronize + val config = WifiP2pConfig().apply { + deviceAddress = device.deviceAddress + // Prefer PC as GO for Windows WPS interoperability; OS decides the actual role. + groupOwnerIntent = 0 + // The framework normally initializes this field, but the public + // config object may expose it as null on some API stubs/OEMs. + wps = (wps ?: WpsInfo()).apply { setup = WpsInfo.PBC } } - - override fun onFailure(reasonCode: Int) { - // 채널 일시적 혼잡(BUSY: 2)인 경우 800ms 후 1회 자동 재시도 - if (reasonCode == WifiP2pManager.BUSY && !isRetry) { - scope.launch { - kotlinx.coroutines.delay(800) - discoverPeers(isRetry = true, onSuccess = onSuccess, onFailure = onFailure) - } - return - } - _isSearching.value = false - _lastErrorReason.value = reasonCode - onFailure(reasonCode) + guarded("연결 요청") { + manager!!.connect(ch, config, listener("연결 요청", { token == operation }, + success = { requestPeers(); synchronize() }, + failure = { deadline?.cancel(); synchronize() })) } - }) + } } - /** - * 피어 탐색 정지 - */ - fun stopPeerDiscovery() { - val ch = channel ?: return - wifiP2pManager?.stopPeerDiscovery(ch, object : WifiP2pManager.ActionListener { - override fun onSuccess() { - _isSearching.value = false + fun cancelConnect() = scope.launch { + if (_uiState.value.phase !in setOf(WifiDirectPhase.CONNECTING, WifiDirectPhase.CANCELLING)) return@launch + val ch = channel ?: return@launch + val token = ++operation + cancelAccepted = false + publish { it.copy(phase = WifiDirectPhase.CANCELLING, error = null) } + watchOperation(token, 10_000) + guarded("연결 취소") { + manager!!.cancelConnect(ch, listener("연결 취소", { token == operation }, + success = { cancelAccepted = true; requestPeers(); refresh() }, + failure = { synchronize() })) + } + } + + fun disconnect() = scope.launch { + if (_uiState.value.link == null || _uiState.value.phase == WifiDirectPhase.DISCONNECTING) return@launch + val ch = channel ?: return@launch + val token = ++operation + removalAccepted = false + publish { it.copy(phase = WifiDirectPhase.DISCONNECTING, error = null) } + watchOperation(token, 10_000) + guarded("연결 해제") { + manager!!.removeGroup(ch, listener("연결 해제", { token == operation }, + success = { removalAccepted = true; synchronize() }, failure = { synchronize() })) + } + } + + /** Bounded rechecks cover missing broadcasts; BUSY never starts an automatic retry loop. */ + private fun watchOperation(token: Long, timeout: Long) { + deadline?.cancel() + deadline = scope.launch { + repeat((timeout / 1000).toInt()) { + delay(1000) + if (operation != token || _uiState.value.phase !in pendingPhases) return@launch + refresh() } - override fun onFailure(reason: Int) { - _isSearching.value = false + if (operation != token) return@launch + if (_uiState.value.phase == WifiDirectPhase.CONNECTING) { + cancelConnect() + publish { it.copy(error = "연결 협상 시간이 초과되어 취소를 요청했습니다.") } + } else { + operation++ + publish { it.copy(phase = WifiDirectPhase.ERROR, + error = "시스템에서 작업 완료를 확인하지 못했습니다. 새로고침 후 다시 시도하세요.") } + synchronize() } - }) + } } - /** - * 선택한 Wi-Fi Direct 기기에 실제 연결 요청 (그룹 형성 후 IP 콜백 지원) - */ - @SuppressLint("MissingPermission") - fun connect( - device: WifiP2pDevice, - onSuccessWithIp: (String) -> Unit = {}, - onFailure: (Int) -> Unit = {} - ) { - val ch = channel ?: return - val config = WifiP2pConfig().apply { - deviceAddress = device.deviceAddress - // 태블릿이 Client가 되고 단말이 Group Owner(GO)가 되도록 우선순위를 0으로 양보 - groupOwnerIntent = 0 + /** Discovery errors cannot replace an OS link or an in-flight connection operation. */ + private fun reportDiscoveryFailure(message: String, reason: Int? = null) { + Log.w(TAG, message) + publish { + val discoveryPhase = it.phase in setOf(WifiDirectPhase.IDLE, WifiDirectPhase.DISCOVERING) + it.copy(discovering = false, + phase = if (discoveryPhase) WifiDirectPhase.IDLE else it.phase, + error = if (discoveryPhase) message else it.error, + errorReason = if (discoveryPhase) reason else it.errorReason) } + } - pendingIpCallback = onSuccessWithIp - - wifiP2pManager?.connect(ch, config, object : WifiP2pManager.ActionListener { - override fun onSuccess() { - _connectedDeviceName.value = device.deviceName.ifBlank { device.deviceAddress } + private fun listener(label: String, valid: () -> Boolean, discoveryOnly: Boolean = false, success: () -> Unit = {}, failure: () -> Unit = {}) = + object : WifiP2pManager.ActionListener { + override fun onSuccess() { if (!closed && valid()) success() } + override fun onFailure(reason: Int) { + if (closed || !valid()) return + Log.w(TAG, "$label failed: $reason") + if (discoveryOnly) reportDiscoveryFailure(WifiDirectRequirements.error(label, reason), reason) + else publish { it.copy(phase = WifiDirectPhase.ERROR, errorReason = reason, + error = WifiDirectRequirements.error(label, reason)) } + failure() } + } - override fun onFailure(reasonCode: Int) { - _connectedDeviceName.value = null - _connectedHostIp.value = null - pendingIpCallback = null - onFailure(reasonCode) - } - }) + private inline fun guarded(label: String, discoveryOnly: Boolean = false, action: () -> Unit) { + try { action() } catch (e: SecurityException) { + publish { it.copy(phase = WifiDirectPhase.PERMISSION_REQUIRED, permissionGranted = false, + synchronizedWithOs = false, discovering = false, error = "주변 Wi-Fi 기기 권한을 다시 허용해 주세요.") } + Log.w(TAG, "$label permission revoked", e) + } catch (e: RuntimeException) { + Log.e(TAG, label, e) + if (discoveryOnly) reportDiscoveryFailure("$label 실패: ${e.message}") + else publish { it.copy(phase = WifiDirectPhase.ERROR, discovering = false, error = "$label 실패: ${e.message}") } + } } - /** - * Wi-Fi Direct 연결 해제 (P2P 그룹 제거 및 진행 중인 연결 취소) - */ - fun disconnect(onComplete: () -> Unit = {}) { - val ch = channel - if (ch == null) { - _connectedDeviceName.value = null - _connectionInfo.value = null - _groupInfo.value = null - _connectedHostIp.value = null - pendingIpCallback = null - onComplete() - return - } + fun currentP2pRoute(): WifiDirectRoute { + val snapshot = uiState.value + if (!snapshot.permissionGranted || !snapshot.synchronizedWithOs) + throw WifiDirectRouteNotReady("Wi-Fi Direct 권한 및 실제 그룹 상태를 확인하는 중입니다.") + val info = snapshot.link?.info + if (info?.groupFormed != true) throw WifiDirectRouteNotReady("Wi-Fi Direct 그룹이 연결되지 않았습니다.") + val owner = info.groupOwnerAddress + ?: throw WifiDirectRouteNotReady("Wi-Fi Direct 상대 장비 IP를 기다리는 중입니다.") + val group = snapshot.link?.group + ?: throw WifiDirectRouteNotReady("Wi-Fi Direct 그룹 정보를 기다리는 중입니다.") + val name = group.`interface`?.takeIf { it.isNotBlank() } + ?: throw WifiDirectRouteNotReady("Wi-Fi Direct 인터페이스 정보를 기다리는 중입니다.") + val iface = NetworkInterface.getByName(name) + if (iface == null || !iface.isUp) throw WifiDirectRouteNotReady("Wi-Fi Direct 인터페이스 활성화를 기다리는 중입니다.") + val local = iface.interfaceAddresses.firstOrNull { + !it.address.isAnyLocalAddress && !it.address.isLoopbackAddress && + WifiDirectRoute.sameSubnet(it.address, owner, it.networkPrefixLength.toInt()) && + (if (info.isGroupOwner) it.address == owner else it.address != owner) + }?.address ?: throw WifiDirectRouteNotReady("Wi-Fi Direct IP 할당을 기다리는 중입니다.") + val network = networks.entries.firstOrNull { (_, links) -> + links.interfaceName == name && links.linkAddresses.any { address -> address.address == local } + }?.key + return WifiDirectRoute(name, local, owner, info.isGroupOwner, network) + } - // 1. 진행 중인 연결 요청 취소 - wifiP2pManager?.cancelConnect(ch, object : WifiP2pManager.ActionListener { - override fun onSuccess() {} - override fun onFailure(reasonCode: Int) {} - }) - // 2. 형성된 P2P 그룹 제거 (실제 OS 레벨 Wi-Fi Direct 링크 해제) - wifiP2pManager?.removeGroup(ch, object : WifiP2pManager.ActionListener { - override fun onSuccess() { - _connectedDeviceName.value = null - _connectionInfo.value = null - _groupInfo.value = null - _connectedHostIp.value = null - pendingIpCallback = null - onComplete() + /** Release app resources, not the OS-owned group. */ + fun close() { + if (closed) return + if (_uiState.value.discovering && _uiState.value.phase !in pendingPhases) { + channel?.let { ch -> + try { + manager?.stopPeerDiscovery(ch, object : WifiP2pManager.ActionListener { + override fun onSuccess() { Log.d(TAG, "Discovery stopped on owner cleanup") } + override fun onFailure(reason: Int) { Log.w(TAG, "Discovery cleanup: $reason") } + }) + } catch (e: RuntimeException) { Log.w(TAG, "Discovery cleanup failed", e) } } + } + closed = true + epoch++; query++; operation++; discovery++ + syncWaiters.clear() + scope.cancel() + if (networkRegistered) { + connectivityManager?.unregisterNetworkCallback(networkCallback) + networkRegistered = false + } + if (registered) { context.unregisterReceiver(receiver); registered = false } + channel?.close() + channel = null + } - override fun onFailure(reasonCode: Int) { - _connectedDeviceName.value = null - _connectionInfo.value = null - _groupInfo.value = null - _connectedHostIp.value = null - pendingIpCallback = null - onComplete() - } - }) + companion object { + private const val TAG = "WifiDirectManager" + private val pendingPhases = setOf(WifiDirectPhase.CONNECTING, WifiDirectPhase.CANCELLING, WifiDirectPhase.DISCONNECTING) } } diff --git a/Android/app/src/main/java/com/dct/app/data/communication/WifiDirectRoute.kt b/Android/app/src/main/java/com/dct/app/data/communication/WifiDirectRoute.kt new file mode 100644 index 0000000..fb0434e --- /dev/null +++ b/Android/app/src/main/java/com/dct/app/data/communication/WifiDirectRoute.kt @@ -0,0 +1,40 @@ +package com.dct.app.data.communication + +import android.net.Network +import java.io.IOException +import java.net.InetAddress + +/** A route verified against the current WifiP2pGroup, independent of Network exposure. */ +data class WifiDirectRoute( + val interfaceName: String, + val localAddress: InetAddress, + val ownerAddress: InetAddress, + val isGroupOwner: Boolean, + val network: Network? = null +) { + fun requireClientTarget(host: String) { + if (isGroupOwner || host != ownerAddress.hostAddress) { + throw IOException("Wi-Fi Direct 그룹 주소 또는 역할이 변경되었습니다. 다시 연결해 주세요.") + } + } + + fun samePath(other: WifiDirectRoute): Boolean = + interfaceName == other.interfaceName && localAddress == other.localAddress && + ownerAddress == other.ownerAddress && isGroupOwner == other.isGroupOwner + + companion object { + /** Match the actual interface subnet, never a guessed 192.168.49.x subnet. */ + fun sameSubnet(local: InetAddress, peer: InetAddress, prefix: Int): Boolean { + val a = local.address + val b = peer.address + if (a.size != b.size || prefix !in 1..a.size * 8) return false + return a.indices.all { i -> + val bits = (prefix - i * 8).coerceIn(0, 8) + val mask = (0xff shl (8 - bits)) and 0xff + (a[i].toInt() and mask) == (b[i].toInt() and mask) + } + } + } +} + +class WifiDirectRouteNotReady(message: String) : IOException(message) diff --git a/Android/app/src/main/java/com/dct/app/data/communication/WifiDirectState.kt b/Android/app/src/main/java/com/dct/app/data/communication/WifiDirectState.kt new file mode 100644 index 0000000..791d40b --- /dev/null +++ b/Android/app/src/main/java/com/dct/app/data/communication/WifiDirectState.kt @@ -0,0 +1,55 @@ +package com.dct.app.data.communication + +import android.Manifest +import android.net.wifi.p2p.WifiP2pDevice +import android.net.wifi.p2p.WifiP2pGroup +import android.net.wifi.p2p.WifiP2pInfo +import android.net.wifi.p2p.WifiP2pManager + +/** Link and operation are separate: an API error must not erase an OS-confirmed group. */ +enum class WifiDirectPhase { CHECKING, UNSUPPORTED, DISABLED, PERMISSION_REQUIRED, IDLE, DISCOVERING, CONNECTING, CANCELLING, DISCONNECTING, CONNECTED, ERROR } + +data class WifiDirectLink(val info: WifiP2pInfo, val group: WifiP2pGroup) { + val peers: List + get() = if (info.isGroupOwner) group.clientList.toList() else listOfNotNull(group.owner) + val displayName: String get() = peers.joinToString { it.deviceName.ifBlank { it.deviceAddress } }.ifBlank { "Wi-Fi Direct 그룹" } + val key: String get() = "${info.isGroupOwner}:${info.groupOwnerAddress?.hostAddress}:${group.`interface`}" +} + +data class WifiDirectUiState( + val phase: WifiDirectPhase = WifiDirectPhase.CHECKING, + val supported: Boolean = true, + val enabled: Boolean = false, + val permissionGranted: Boolean = false, + val locationEnabled: Boolean = false, + val discovering: Boolean = false, + val peers: List = emptyList(), + val thisDevice: WifiP2pDevice? = null, + val target: WifiP2pDevice? = null, + val link: WifiDirectLink? = null, + val error: String? = null, + val errorReason: Int? = null, + val synchronizedWithOs: Boolean = false +) { + val busy: Boolean get() = phase in setOf(WifiDirectPhase.CHECKING, WifiDirectPhase.CONNECTING, WifiDirectPhase.CANCELLING, WifiDirectPhase.DISCONNECTING) + val canDiscover: Boolean get() = supported && enabled && permissionGranted && locationEnabled && !busy && !discovering + val canConnect: Boolean get() = supported && enabled && permissionGranted && !busy && link == null && synchronizedWithOs +} + +object WifiDirectRequirements { + fun runtimePermissions(sdk: Int): Array = if (sdk >= 33) + arrayOf(Manifest.permission.NEARBY_WIFI_DEVICES) + else arrayOf(Manifest.permission.ACCESS_FINE_LOCATION) + + fun error(operation: String, reason: Int): String = when (reason) { + WifiP2pManager.P2P_UNSUPPORTED -> "이 기기는 Wi-Fi Direct를 지원하지 않습니다." + WifiP2pManager.BUSY -> "$operation: Wi-Fi Direct가 다른 작업을 처리 중입니다. 잠시 후 다시 시도하세요." + WifiP2pManager.ERROR -> "$operation: 시스템에서 요청을 처리하지 못했습니다. Wi-Fi 상태를 확인하고 다시 시도하세요." + // NO_SERVICE_REQUESTS belongs to discoverServices, not discoverPeers/connect. + else -> "$operation 실패 (시스템 오류 코드 $reason)" + } +} + +/** Retained only for the existing, unused manual connection form. */ +data class DiscoveredLanServer(val deviceName: String, val hostIp: String, + val port: Int = WifiSocketManager.DEFAULT_PORT, val lastSeen: Long = System.currentTimeMillis()) 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 2d1af51..3e525cc 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 @@ -2,7 +2,9 @@ package com.dct.app.data.communication import com.dct.app.data.protocol.DctPacketCodec import com.dct.app.data.protocol.SlidingRingBufferParser +import com.dct.app.core.common.PacketPipelineMetrics import com.dct.app.domain.model.protocol.DctPacket +import com.dct.app.domain.model.protocol.DctCommand import kotlinx.coroutines.* import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.flow.MutableSharedFlow @@ -14,23 +16,48 @@ import java.io.IOException import java.net.InetSocketAddress import java.net.ServerSocket import java.net.Socket +import android.util.Log /** - * Wi-Fi TCP/IP 하이브리드(서버/클라이언트) 실제 하드웨어 통신 소켓 관리자 (기본 포트: 6000) + * Wi-Fi Direct L4 TCP 소켓 관리자 (기본 포트: 6000). * * Wi-Fi Direct 환경에서 태블릿이 Group Owner(서버)가 되든 Client(클라이언트)가 되든 * 상관없이 안정적인 양방향 송수신 세션을 지원합니다. */ -class WifiSocketManager { +class WifiSocketManager( + val metrics: PacketPipelineMetrics = PacketPipelineMetrics(), + private val operationalLogger: com.dct.app.data.logger.PcOperationalLogger? = null +) { private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) - private var activeSocket: Socket? = null + @Volatile private var activeSocket: Socket? = null + private var packetWriter: OrderedPacketWriter? = null + private val sessionCounter = java.util.concurrent.atomic.AtomicLong() + @Volatile var currentSessionId: Long = 0 + private set private var serverSocket: ServerSocket? = null private var serverJob: Job? = null private var readJob: Job? = null private val connectionGeneration = java.util.concurrent.atomic.AtomicLong() + private val ingressSequenceCounter = java.util.concurrent.atomic.AtomicLong() + // System.nanoTime() is monotonic but has no epoch. Pair it once with the + // wall clock so graph timestamps stay compatible with the existing epoch + // based x-axis while retaining sub-millisecond ordering. + @Volatile private var timestampBaseNanos: Long = System.nanoTime() + @Volatile private var timestampBaseMillis: Long = System.currentTimeMillis() + @Volatile private var wifiDirectRouteProvider: (() -> WifiDirectRoute)? = null + private var connectingSocket: Socket? = null + + /** Production sockets must use a verified current P2P group interface/address. */ + fun setWifiDirectRouteProvider(provider: (() -> WifiDirectRoute)?) { + wifiDirectRouteProvider = provider + } @Synchronized - private fun nextConnectionGeneration(): Long = connectionGeneration.incrementAndGet() + private fun nextConnectionGeneration(): Long { + connectingSocket?.close() + connectingSocket = null + return connectionGeneration.incrementAndGet() + } private val parser = SlidingRingBufferParser() @@ -46,21 +73,22 @@ class WifiSocketManager { private val _packetStream = MutableSharedFlow( replay = 0, extraBufferCapacity = 256, - onBufferOverflow = BufferOverflow.DROP_OLDEST + onBufferOverflow = BufferOverflow.SUSPEND ) val packetStream: SharedFlow = _packetStream companion object { const val DEFAULT_PORT = 6000 const val CONNECT_TIMEOUT_MS = 5000 + const val CONNECTION_DEADLINE_MS = 15000L } /** - * 하이브리드 듀얼 모드 시작 (태블릿의 Group Owner 여부에 따라 서버/클라이언트 자동 기동) + * Wi-Fi Direct 그룹의 Group Owner 여부에 따라 TCP 서버/클라이언트를 기동한다. */ fun startDualMode( isGroupOwner: Boolean, - hostIp: String = "192.168.49.1", + hostIp: String, port: Int = DEFAULT_PORT, onConnected: (isServer: Boolean, remoteAddress: String) -> Unit = { _, _ -> }, onWaiting: (String) -> Unit = {}, @@ -68,34 +96,63 @@ class WifiSocketManager { ) { val generation = nextConnectionGeneration() scope.launch { - if (isGroupOwner) { - // 1. 태블릿이 Group Owner(GO)인 경우: ServerSocket(Port 6000)을 열고 상대방 접속 대기 - val listening = startServerInternal(port, generation, onClientConnected = { clientSocket -> - val remoteIp = clientSocket.inetAddress?.hostAddress ?: "Client" - onConnected(true, remoteIp) - }, onFailure = onFailure) - if (listening) onWaiting("TCP 서버가 포트 $port 에서 장비 접속을 기다리는 중입니다.") - } else { - // 2. 태블릿이 Client인 경우: 상대 Group Owner IP로 ClientSocket 접속 시도 - // (만약 접속 실패 시 상대방이 태블릿으로 접속할 수 있도록 서버 리스너도 안전망으로 가동) + 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 previousStatus: String? = null + fun status(message: String) { + if (message != previousStatus) { + previousStatus = message + onWaiting(message) + } + } + while (remainingMs() > 0 && generation == connectionGeneration.get()) { try { - connectClientInternal(hostIp, port, generation) - onConnected(false, hostIp) + val route = wifiDirectRouteProvider?.invoke() + if (route != null && route.isGroupOwner != isGroupOwner) { + throw IOException("Wi-Fi Direct 그룹 역할이 변경되었습니다. 다시 연결해 주세요.") + } + if (isGroupOwner) { + val listening = startServerInternal(port, generation, onClientConnected = { clientSocket -> + val remoteIp = clientSocket.inetAddress?.hostAddress ?: "Client" + onConnected(true, remoteIp) + }, onFailure = { failConnection(generation, it, onFailure) }, route = route) + if (listening) onWaiting("TCP 서버가 포트 $port 에서 장비 접속을 기다리는 중입니다.") + return@launch + } else { + // Wi-Fi Direct has already established the peer route; + // do not show a misleading "PC connecting" alarm while + // the application opens its TCP stream. + connectClientInternal(hostIp, port, generation, + minOf(CONNECT_TIMEOUT_MS, remainingMs().coerceAtLeast(1))) + if (generation == connectionGeneration.get()) onConnected(false, hostIp) + return@launch + } } catch (e: Exception) { if (e is CancellationException || generation != connectionGeneration.get()) return@launch - // 상대방이 서버를 아직 안 띄웠거나 태블릿으로 접속하려는 경우를 대비해 ServerSocket도 가동 - val listening = startServerInternal(port, generation, onClientConnected = { clientSocket -> - val remoteIp = clientSocket.inetAddress?.hostAddress ?: "Client" - onConnected(true, remoteIp) - }, onFailure = onFailure) - if (listening) { - onWaiting("${e.message ?: "클라이언트 접속 실패"}; TCP 서버 접속 대기 중") + lastError = when (e) { + 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 연결 실패" } + if (e is WifiDirectRouteNotReady) status(lastError) + Log.w("WifiSocketManager", "P2P connect: generation=$generation, elapsedMs=${(System.nanoTime() - startedAt) / 1_000_000}, reason=$lastError", e) + delay(minOf(500, remainingMs()).toLong()) } } + failConnection(generation, lastError, onFailure) } } + @Synchronized + private fun failConnection(generation: Long, error: String, onFailure: (String) -> Unit) { + if (generation != connectionGeneration.get()) return + disconnectInternal() + onFailure(error) + } + /** * TCP 서버 소켓 시작 (Port 6000 수신 대기 및 다중/단일 클라이언트 접속 처리) */ @@ -114,7 +171,8 @@ class WifiSocketManager { port: Int, generation: Long, onClientConnected: (Socket) -> Unit = {}, - onFailure: (String) -> Unit = {} + onFailure: (String) -> Unit = {}, + route: WifiDirectRoute? = null ): Boolean { if (generation != connectionGeneration.get()) return false try { @@ -125,7 +183,13 @@ class WifiSocketManager { serverSocket?.close() serverJob?.cancel() - val sSocket = ServerSocket(port) + val sSocket = ServerSocket() + try { + sSocket.bind(InetSocketAddress(route?.localAddress, port)) + } catch (e: Exception) { + sSocket.close() + throw e + } serverSocket = sSocket _isServerRunning.value = true @@ -133,6 +197,13 @@ class WifiSocketManager { try { while (isActive && generation == connectionGeneration.get() && !sSocket.isClosed) { val clientSocket = sSocket.accept() + if (route != null) { + val current = runCatching { wifiDirectRouteProvider?.invoke() }.getOrNull() + if (current == null || !route.samePath(current) || clientSocket.localAddress != route.localAddress) { + clientSocket.close() + continue + } + } clientSocket.tcpNoDelay = true clientSocket.soTimeout = 0 @@ -171,26 +242,47 @@ class WifiSocketManager { onSuccess() } catch (e: Exception) { if (e is CancellationException || generation != connectionGeneration.get()) return@launch - disconnectInternal() - onFailure(e.message ?: "Wi-Fi 소켓 연결 실패") + failConnection(generation, e.message ?: "Wi-Fi 소켓 연결 실패", onFailure) } } } - private fun connectClientInternal(host: String, port: Int, generation: Long) { + private fun connectClientInternal(host: String, port: Int, generation: Long, timeoutMs: Int = CONNECT_TIMEOUT_MS) { synchronized(this) { if (generation != connectionGeneration.get()) throw CancellationException("Superseded connection") disconnectActiveSocketOnly() } - val socket = Socket() + val route = wifiDirectRouteProvider?.invoke() + route?.requireClientTarget(host) + val socket = route?.network?.socketFactory?.createSocket() ?: Socket() try { + synchronized(this) { + if (generation != connectionGeneration.get()) throw CancellationException("Superseded connection") + connectingSocket = socket + } socket.tcpNoDelay = true socket.soTimeout = 0 - socket.connect(InetSocketAddress(host, port), CONNECT_TIMEOUT_MS) + // 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}") + socket.connect(InetSocketAddress(host, port), timeoutMs) + if (route != null) { + val current = wifiDirectRouteProvider?.invoke() + if (current == null || !route.samePath(current) || socket.localAddress != route.localAddress) { + throw IOException("Wi-Fi Direct 연결 경로가 변경되었습니다.") + } + } + 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}" + ) if (!attachSocket(socket, host, generation)) throw CancellationException("Superseded connection") } catch (e: Exception) { socket.close() throw e + } finally { + synchronized(this) { if (connectingSocket === socket) connectingSocket = null } } } @@ -206,6 +298,22 @@ class WifiSocketManager { disconnectActiveSocketOnly() activeSocket = socket + val sessionId = sessionCounter.incrementAndGet() + currentSessionId = sessionId + operationalLogger?.connected(sessionId, hostName) + timestampBaseNanos = System.nanoTime() + timestampBaseMillis = System.currentTimeMillis() + packetWriter = OrderedPacketWriter(scope, onFailure = { error -> + Log.e("WifiSocketManager", "TCP writer failed: session=$sessionId, host=$hostName", error) + }, write = { bytes -> + socket.getOutputStream().write(bytes) + operationalLogger?.frame(sessionId, "Send", bytes) + }, closeTransport = { + try { socket.close() } catch (_: IOException) {} + synchronized(this@WifiSocketManager) { + if (activeSocket === socket) disconnectActiveSocketOnly() + } + }) _isConnected.value = true _connectedHost.value = hostName parser.reset() @@ -218,26 +326,45 @@ class WifiSocketManager { while (isActive && activeSocket === socket && !socket.isClosed) { val bytesRead = inputStream.read(buffer) if (bytesRead > 0) { + metrics.tcpReadCalls.incrementAndGet() + metrics.tcpBytesRead.addAndGet(bytesRead.toLong()) val packets = synchronized(this@WifiSocketManager) { if (activeSocket !== socket || generation != connectionGeneration.get()) emptyList() else parser.parseStream(buffer, bytesRead) } + metrics.parsedPackets.addAndGet(packets.size.toLong()) for (packet in packets) { - _packetStream.emit(packet) + val receivedAtNanos = System.nanoTime() + val receivedAtMillis = timestampBaseMillis + + ((receivedAtNanos - timestampBaseNanos) / 1_000_000L) + val receivedAtSec = timestampBaseMillis / 1000.0 + + (receivedAtNanos - timestampBaseNanos) / 1_000_000_000.0 + val ingressSequence = ingressSequenceCounter.incrementAndGet() + operationalLogger?.frame(sessionId, "Recv", codec.encode(packet), receivedAtMillis) + if (packet.command == DctCommand.RES_START_TRANSMIT_DATA.code || + packet.command == DctCommand.RES_STOP_TRANSMIT_DATA.code) { + Log.i("WifiSocketManager", "P2P RX control: session=$sessionId, command=${DctCommand.toHexString(packet.command)}, payload=${packet.payload.size}B") + } + _packetStream.emit(packet.copy( + transportSessionId = sessionId, + receivedAtMillis = receivedAtMillis, + receivedAtNanos = receivedAtNanos, + receivedAtSec = receivedAtSec, + ingressSequence = ingressSequence + )) } + metrics.maybeLog("WifiSocketManager") } else if (bytesRead < 0) { + Log.i("WifiSocketManager", "TCP peer EOF: session=$sessionId, host=$hostName") break } } - } catch (_: IOException) { - // 소켓 연결 종료 + } catch (e: IOException) { + Log.w("WifiSocketManager", "TCP read closed: session=$sessionId, localClosed=${socket.isClosed}, reason=${e.message}", e) } finally { synchronized(this@WifiSocketManager) { if (activeSocket === socket) { - try { socket.close() } catch (_: Exception) {} - _isConnected.value = false - _connectedHost.value = null - activeSocket = null + disconnectActiveSocketOnly() } } } @@ -247,27 +374,38 @@ class WifiSocketManager { private val codec = DctPacketCodec() + /** Final ViewModel cleanup; unblocks I/O before cancelling worker coroutines. */ + fun close() { + disconnectInternal() + scope.cancel() + } + /** * 실제 하드웨어/원격 기기로 바이너리 패킷 송신 */ suspend fun sendCommand(packet: DctPacket): Boolean { - val socket = activeSocket ?: return false - if (!socket.isConnected || socket.isClosed) return false + val writer = synchronized(this) { + val socket = activeSocket ?: return false + if (!socket.isConnected || socket.isClosed) return false + if (packet.transportSessionId != 0L && packet.transportSessionId != currentSessionId) return false + packetWriter ?: return false + } + return writer.send(codec.encode(packet)) + } - return withContext(Dispatchers.IO) { - try { - val encodedBytes = codec.encode(packet) - val outputStream = socket.getOutputStream() - outputStream.write(encodedBytes) - outputStream.flush() - true - } catch (_: Exception) { - synchronized(this@WifiSocketManager) { - if (activeSocket === socket) disconnectActiveSocketOnly() - } - false - } + /** + * Bounded fire-and-forget path for stream ACKs. It only reports whether + * the packet entered the per-session ordered writer queue; it never waits + * on a potentially blocked socket write. + */ + fun offerCommand(packet: DctPacket): Boolean { + val writer = synchronized(this) { + val socket = activeSocket ?: return false + if (!socket.isConnected || socket.isClosed) return false + if (packet.transportSessionId != 0L && packet.transportSessionId != currentSessionId) return false + packetWriter ?: return false } + return writer.offer(codec.encode(packet)) } /** @@ -284,14 +422,26 @@ class WifiSocketManager { disconnectInternal() } + /** Socket close unblocks I/O; perform generation invalidation before any asynchronous cleanup. */ + fun invalidateConnection() { + disconnectInternal() + } + @Synchronized private fun disconnectActiveSocketOnly() { + if (activeSocket != null) Log.i("WifiSocketManager", "TCP local cleanup: session=$currentSessionId", + Throwable("Socket cleanup caller")) val job = readJob readJob = null job?.cancel() val socket = activeSocket + if (socket != null) operationalLogger?.disconnected(currentSessionId) activeSocket = null + currentSessionId = 0 + val writer = packetWriter + packetWriter = null + writer?.close() if (socket != null && !socket.isClosed) { try { socket.shutdownInput() @@ -309,7 +459,7 @@ class WifiSocketManager { @Synchronized private fun disconnectInternal() { - connectionGeneration.incrementAndGet() + nextConnectionGeneration() disconnectActiveSocketOnly() val sJob = serverJob serverJob = null diff --git a/Android/app/src/main/java/com/dct/app/data/logger/CsvMeasurementLogger.kt b/Android/app/src/main/java/com/dct/app/data/logger/CsvMeasurementLogger.kt index f2d3b95..e98afa4 100644 --- a/Android/app/src/main/java/com/dct/app/data/logger/CsvMeasurementLogger.kt +++ b/Android/app/src/main/java/com/dct/app/data/logger/CsvMeasurementLogger.kt @@ -8,19 +8,34 @@ import java.text.SimpleDateFormat import java.util.Date import java.util.Locale +/** PC CSV header identifiers; absent identifiers stay blank instead of being invented. */ +data class PcCsvColumn( + val index: String = "", + val record: String = "", + val decimals: Int = 3, + val displayName: String? = null, + val displayUnit: String? = null +) + /** * 실시간 계측 데이터 CSV 파일 스트리밍 로거 */ class CsvMeasurementLogger( val outputDir: File, val signalHeaders: List>, // List of (SignalName, Unit) - val customFileName: String? = null + val customFileName: String? = null, + private val pcColumns: List? = null, + private val missingValueToken: String = "No value" ) { private var writer: BufferedWriter? = null var outputFile: File? = null private set private var sampleCount: Long = 0 + private var lastFlushNanos = System.nanoTime() + private val row = StringBuilder() + private val formatter = java.util.Formatter(row, Locale.US) + private val pcTimeFormatter = SimpleDateFormat("HH:mm:ss.SSS", Locale.US) @Synchronized fun start(): File? { @@ -36,23 +51,40 @@ class CsvMeasurementLogger( outputFile = file val bw = BufferedWriter(FileWriter(file, Charsets.UTF_8), 32 * 1024) - // 헤더 작성: Time(s), Signal1 [unit], Signal2 [unit], ... - val headerLine = buildString { - append("Time(s)") - for ((name, unit) in signalHeaders) { - append(",") - append(name) - if (unit.isNotBlank()) { - append(" [") - append(unit) - append("]") + if (pcColumns != null) { + val date = SimpleDateFormat("yyyy-MM-dd", Locale.US).format(Date()) + bw.write("File: $fileName\nDate: $date\n\n") + fun writeColumns(prefix: String, values: List) { + bw.write(prefix) + for (value in values) bw.write(" $value,") + bw.newLine() + } + writeColumns(" , ,", signalHeaders.indices.map { pcColumns.getOrNull(it)?.index.orEmpty() }) + writeColumns(" Record, ,", signalHeaders.indices.map { pcColumns.getOrNull(it)?.record.orEmpty() }) + writeColumns(" , ,", signalHeaders.mapIndexed { index, pair -> + pcColumns.getOrNull(index)?.displayName ?: pair.first + }) + writeColumns(" Time, Count,", signalHeaders.mapIndexed { index, pair -> + val unit = pcColumns.getOrNull(index)?.displayUnit ?: pair.second + if (unit.isBlank()) "" else "[ $unit]" + }) + } else { + // Simple layout remains available for existing standalone logger consumers. + val headerLine = buildString { + append("Time(s)") + for ((name, unit) in signalHeaders) { + append(",") + append(name) + if (unit.isNotBlank()) append(" [$unit]") } } + bw.write(headerLine) + bw.newLine() } - bw.write(headerLine) - bw.newLine() bw.flush() writer = bw + sampleCount = 0 + lastFlushNanos = System.nanoTime() return file } catch (e: Exception) { e.printStackTrace() @@ -64,22 +96,33 @@ class CsvMeasurementLogger( fun logSample(elapsedSec: Double, values: List) { val bw = writer ?: return try { - val line = buildString { - append(String.format(Locale.US, "%.4f", elapsedSec)) - for (v in values) { + row.setLength(0) + val line = row.apply { + if (pcColumns != null) { + append(pcTimeFormatter.format(Date())) + append(", ") + append(sampleCount) + } else { + formatter.format("%.4f", elapsedSec) + } + for ((index, v) in values.withIndex()) { append(",") if (v != null && !v.isNaN() && !v.isInfinite()) { - append(String.format(Locale.US, "%.4f", v)) + val decimals = if (pcColumns == null) 4 else + pcColumns.getOrNull(index)?.decimals?.coerceIn(0, 3) ?: 3 + formatter.format("%.${decimals}f", v) } else { - append("0.0000") + append(missingValueToken) } } } - bw.write(line) + bw.write(line.toString()) bw.newLine() sampleCount++ - if (sampleCount % 30 == 0L) { + val now = System.nanoTime() + if (now - lastFlushNanos >= 1_000_000_000L) { bw.flush() + lastFlushNanos = now } } catch (e: IOException) { e.printStackTrace() diff --git a/Android/app/src/main/java/com/dct/app/data/logger/MeasurementLogPaths.kt b/Android/app/src/main/java/com/dct/app/data/logger/MeasurementLogPaths.kt index 99ab915..f7fd70e 100644 --- a/Android/app/src/main/java/com/dct/app/data/logger/MeasurementLogPaths.kt +++ b/Android/app/src/main/java/com/dct/app/data/logger/MeasurementLogPaths.kt @@ -11,7 +11,8 @@ object MeasurementLogPaths { val base = if (usableAbsolute(baseText)) File(baseText) else fallbackBase val normalized = path.replace('\\', '/').trim() val legacyData = normalized == "/Data" || normalized.startsWith("/Data/") - val foreign = Regex("^[A-Za-z]:").containsMatchIn(normalized) && File.separatorChar == '/' || normalized.startsWith("//") + val foreign = Regex("^[A-Za-z]:").containsMatchIn(normalized) && File.separatorChar == '/' || normalized.startsWith("//") || + normalized.startsWith("/tree/") || normalized.startsWith("content://") if (normalized.isNotEmpty() && !usesBaseFolder && !legacyData && !foreign && usableAbsolute(normalized)) { return File(normalized).normalize() } diff --git a/Android/app/src/main/java/com/dct/app/data/logger/MeasurementSafExports.kt b/Android/app/src/main/java/com/dct/app/data/logger/MeasurementSafExports.kt new file mode 100644 index 0000000..d792cc6 --- /dev/null +++ b/Android/app/src/main/java/com/dct/app/data/logger/MeasurementSafExports.kt @@ -0,0 +1,58 @@ +package com.dct.app.data.logger + +import android.content.Context +import android.net.Uri +import android.provider.DocumentsContract +import java.io.File +import java.util.UUID + +/** Finalize seekable CSV/MF4 files privately, then export them to a SAF tree. */ +object MeasurementSafExports { + private const val TARGET = ".target-uri" + private const val READY = ".ready" + + private fun root(context: Context): File = File(context.filesDir, "pending_measurement_exports") + + fun createStage(context: Context, treeUri: Uri): File { + val directory = File(root(context), UUID.randomUUID().toString()) + check(directory.mkdirs()) { "기록 임시 폴더를 만들 수 없습니다." } + File(directory, TARGET).writeText(treeUri.toString(), Charsets.UTF_8) + return directory + } + + fun markReady(directory: File) { + File(directory, READY).writeText("ready", Charsets.UTF_8) + } + + /** A failed export retains its finalized source for retry on the next app launch. */ + @Synchronized + fun exportReady(context: Context): Int { + var exported = 0 + for (directory in root(context).listFiles().orEmpty().filter { it.isDirectory && File(it, READY).isFile }) { + val treeUri = Uri.parse(File(directory, TARGET).readText(Charsets.UTF_8)) + val parent = DocumentsContract.buildDocumentUriUsingTree( + treeUri, DocumentsContract.getTreeDocumentId(treeUri) + ) + for (file in directory.listFiles().orEmpty().filter { it.isFile && !it.name.startsWith('.') }) { + val mime = if (file.extension.equals("csv", true)) "text/csv" else "application/octet-stream" + val destination = DocumentsContract.createDocument(context.contentResolver, parent, mime, file.name) + ?: error("${file.name} 대상 문서를 만들 수 없습니다.") + try { + val output = context.contentResolver.openOutputStream(destination, "w") + ?: error("${file.name} 대상 문서를 열 수 없습니다.") + val copied = output.use { stream -> file.inputStream().use { source -> source.copyTo(stream) } } + check(copied == file.length()) { "${file.name} 내보낸 크기가 원본과 다릅니다." } + } catch (error: Exception) { + runCatching { DocumentsContract.deleteDocument(context.contentResolver, destination) } + throw error + } + check(file.delete()) { "내보낸 기록의 임시 사본을 정리할 수 없습니다: ${file.name}" } + exported++ + } + File(directory, READY).delete() + File(directory, TARGET).delete() + directory.delete() + } + return exported + } +} diff --git a/Android/app/src/main/java/com/dct/app/data/logger/Mf4MeasurementLogger.kt b/Android/app/src/main/java/com/dct/app/data/logger/Mf4MeasurementLogger.kt index 8068815..028e2d2 100644 --- a/Android/app/src/main/java/com/dct/app/data/logger/Mf4MeasurementLogger.kt +++ b/Android/app/src/main/java/com/dct/app/data/logger/Mf4MeasurementLogger.kt @@ -25,7 +25,32 @@ class Mf4MeasurementLogger( private var recordCount: Long = 0L private var cgRecordCountOffset: Long = 0L + private var dtBlockOffset: Long = 0L private val recordSize = 8 + (signalHeaders.size * 8) // 8 bytes time + 8 bytes per signal (Double) + private val sampleBuffer = ByteBuffer.allocate(recordSize).order(ByteOrder.LITTLE_ENDIAN) + private var lastFlushNanos = System.nanoTime() + + private data class ChannelMeta( + val name: String, + val unit: String, + val byteOffset: Int, + val isMaster: Boolean + ) + + private fun createTxBlock(text: String): ByteArray { + val textBytes = text.toByteArray(Charsets.UTF_8) + val contentLen = textBytes.size + 1 // null-terminated + val paddedContentLen = ((contentLen + 7) / 8) * 8 + val blockLen = 24 + paddedContentLen + val buf = ByteBuffer.allocate(blockLen).order(ByteOrder.LITTLE_ENDIAN) + buf.put("##TX".toByteArray(Charsets.US_ASCII)) // 0..3 + buf.putInt(0) // 4..7 reserved + buf.putLong(blockLen.toLong()) // 8..15 block length + buf.putLong(0L) // 16..23 link_count = 0 + buf.put(textBytes) // 24.. + buf.put(0.toByte()) // null terminator + return buf.array() + } @Synchronized fun start(): File? { @@ -43,6 +68,56 @@ class Mf4MeasurementLogger( val fos = FileOutputStream(file) val bos = BufferedOutputStream(fos, 64 * 1024) + // 채널 메타데이터 정의 (Time 마스터 채널 + 신호 채널들) + val channels = mutableListOf() + channels.add(ChannelMeta(name = "Time", unit = "s", byteOffset = 0, isMaster = true)) + for ((i, header) in signalHeaders.withIndex()) { + channels.add( + ChannelMeta( + name = header.first, + unit = header.second, + byteOffset = 8 + (i * 8), + isMaster = false + ) + ) + } + + // 고정 블록 오프셋 정의 (ASAM MDF 4.10) + val idBlockOffset = 0L + val hdBlockOffset = 64L + val dgBlockOffset = 168L // 64 + 104 + val cgBlockOffset = 232L // 168 + 64 + val startOfChannelsOffset = 296L // 232 + 64 + + cgRecordCountOffset = cgBlockOffset + 32L // CG 블록 내 32바이트 위치 (cycle_count) + + // 각 채널(CNBLOCK) 및 문자열(TXBLOCK) 오프셋 사전 계산 + var offsetCursor = startOfChannelsOffset + val cnOffsets = LongArray(channels.size) + val nameTxOffsets = LongArray(channels.size) + val unitTxOffsets = LongArray(channels.size) + val nameTxBytes = Array(channels.size) { ByteArray(0) } + val unitTxBytes = Array(channels.size) { ByteArray(0) } + + for (i in channels.indices) { + cnOffsets[i] = offsetCursor + offsetCursor += 128L // CNBlock 크기 (128B) + + nameTxBytes[i] = createTxBlock(channels[i].name) + nameTxOffsets[i] = offsetCursor + offsetCursor += nameTxBytes[i].size + + if (channels[i].unit.isNotBlank()) { + unitTxBytes[i] = createTxBlock(channels[i].unit) + unitTxOffsets[i] = offsetCursor + offsetCursor += unitTxBytes[i].size + } else { + unitTxOffsets[i] = 0L + } + } + + dtBlockOffset = offsetCursor + // 1. ID Block (64 Bytes) val idBuf = ByteBuffer.allocate(64).order(ByteOrder.LITTLE_ENDIAN) idBuf.put("MDF ".toByteArray(Charsets.US_ASCII)) // 0..7 @@ -59,30 +134,102 @@ class Mf4MeasurementLogger( // 2. HD Block (104 Bytes) val hdBuf = ByteBuffer.allocate(104).order(ByteOrder.LITTLE_ENDIAN) - hdBuf.put("##HD".toByteArray(Charsets.US_ASCII)) - hdBuf.putInt(0) // reserved - hdBuf.putLong(104L) // length of HD block - hdBuf.putLong(1) // link count: 1 link to CG block - hdBuf.putLong(64 + 104L) // link to first CG block (offset 168) - hdBuf.putLong(System.currentTimeMillis() * 1_000_000L) // Start time in nanoseconds + hdBuf.put("##HD".toByteArray(Charsets.US_ASCII)) // 0..3 + hdBuf.putInt(0) // 4..7 reserved + hdBuf.putLong(104L) // 8..15 length of HD block + hdBuf.putLong(6L) // 16..23 link count: 6 links + hdBuf.putLong(dgBlockOffset) // 24..31 hd_dg_first -> DGBLOCK 오프셋 (168) + hdBuf.putLong(0L) // 32..39 hd_fh_first + hdBuf.putLong(0L) // 40..47 hd_ch_first + hdBuf.putLong(0L) // 48..55 hd_at_first + hdBuf.putLong(0L) // 56..63 hd_et_first + hdBuf.putLong(0L) // 64..71 hd_md_comment + hdBuf.putLong(System.currentTimeMillis() * 1_000_000L) // 72..79 start time in ns + hdBuf.putShort(0.toShort()) // 80..81 tz offset + hdBuf.putShort(0.toShort()) // 82..83 dst offset + hdBuf.put(0.toByte()) // 84 time flags + hdBuf.put(0.toByte()) // 85 time class + hdBuf.put(0.toByte()) // 86 flags + hdBuf.put(0.toByte()) // 87 reserved + hdBuf.putDouble(0.0) // 88..95 start angle + hdBuf.putDouble(0.0) // 96..103 start distance bos.write(hdBuf.array()) - // 3. CG Block (64 Bytes) - cgRecordCountOffset = (64 + 104 + 32).toLong() // position of cycle_count + // 3. DG Block (64 Bytes) + val dgBuf = ByteBuffer.allocate(64).order(ByteOrder.LITTLE_ENDIAN) + dgBuf.put("##DG".toByteArray(Charsets.US_ASCII)) // 0..3 + dgBuf.putInt(0) // 4..7 reserved + dgBuf.putLong(64L) // 8..15 length + dgBuf.putLong(4L) // 16..23 link_count: 4 links + dgBuf.putLong(0L) // 24..31 dg_next + dgBuf.putLong(cgBlockOffset) // 32..39 dg_cg_first -> CGBLOCK 오프셋 (232) + dgBuf.putLong(dtBlockOffset) // 40..47 dg_data -> DTBLOCK 오프셋 + dgBuf.putLong(0L) // 48..55 dg_md_comment + dgBuf.put(0.toByte()) // 56 dg_rec_id_size (0: no record ID) + dgBuf.put(ByteArray(7)) // 57..63 reserved + bos.write(dgBuf.array()) + + // 4. CG Block (64 Bytes) val cgBuf = ByteBuffer.allocate(64).order(ByteOrder.LITTLE_ENDIAN) - cgBuf.put("##CG".toByteArray(Charsets.US_ASCII)) - cgBuf.putInt(0) - cgBuf.putLong(64L) // length of CG - cgBuf.putLong(0L) // links - cgBuf.putLong(0L) // record_id - cgBuf.putLong(0L) // cycle_count (updated on stop) - cgBuf.putShort(0.toShort()) // flags - cgBuf.putShort(0.toShort()) // path_separator - cgBuf.putInt(recordSize) // data_bytes (record size) - cgBuf.putInt(0) // invalidation_bytes + cgBuf.put("##CG".toByteArray(Charsets.US_ASCII)) // 0..3 + cgBuf.putInt(0) // 4..7 reserved + cgBuf.putLong(64L) // 8..15 length of CG + cgBuf.putLong(cnOffsets[0]) // 16..23 cg_cn_first -> 첫 번째 CNBLOCK 오프셋 + cgBuf.putLong(0L) // 24..31 record_id + cgBuf.putLong(0L) // 32..39 cycle_count (updated on stop, offset 264) + cgBuf.putShort(0.toShort()) // 40..41 flags + cgBuf.putShort(0.toShort()) // 42..43 path_separator + cgBuf.putInt(recordSize) // 44..47 data_bytes (record size) + cgBuf.putInt(0) // 48..51 invalidation_bytes + cgBuf.put(ByteArray(12)) // 52..63 reserved bos.write(cgBuf.array()) - // 4. Data Block Header (##DT) + // 5. CN Blocks & TX Blocks 체인 기록 + for (i in channels.indices) { + val ch = channels[i] + val nextCnOffset = if (i < channels.size - 1) cnOffsets[i + 1] else 0L + + // CN Block (128 Bytes) + val cnBuf = ByteBuffer.allocate(128).order(ByteOrder.LITTLE_ENDIAN) + cnBuf.put("##CN".toByteArray(Charsets.US_ASCII)) // 0..3 + cnBuf.putInt(0) // 4..7 reserved + cnBuf.putLong(128L) // 8..15 length + cnBuf.putLong(8L) // 16..23 link_count: 8 links + cnBuf.putLong(nextCnOffset) // 24..31 cn_next + cnBuf.putLong(0L) // 32..39 cn_composition + cnBuf.putLong(nameTxOffsets[i]) // 40..47 cn_tx_name -> TXBLOCK + cnBuf.putLong(0L) // 48..55 cn_si_source + cnBuf.putLong(0L) // 56..63 cn_cc_conversion + cnBuf.putLong(0L) // 64..71 cn_data + cnBuf.putLong(unitTxOffsets[i]) // 72..79 cn_md_unit -> TXBLOCK (또는 0) + cnBuf.putLong(0L) // 80..87 cn_md_comment + + // Data Section (40 Bytes) + cnBuf.put(if (ch.isMaster) 2.toByte() else 0.toByte()) // 88 cn_type (2: master, 0: data) + cnBuf.put(if (ch.isMaster) 1.toByte() else 0.toByte()) // 89 cn_sync_type (1: time sync) + cnBuf.put(4.toByte()) // 90 cn_data_type (4: Float LE 64-bit) + cnBuf.put(0.toByte()) // 91 bit_offset + cnBuf.putInt(ch.byteOffset) // 92..95 byte_offset + cnBuf.putInt(64) // 96..99 bit_count (64 bits) + cnBuf.putInt(0) // 100..103 flags + cnBuf.putInt(0) // 104..107 inval_bit_pos + cnBuf.put(0.toByte()) // 108 precision + cnBuf.put(0.toByte()) // 109 reserved + cnBuf.putShort(0.toShort()) // 110..111 min_sample_count + cnBuf.putDouble(0.0) // 112..119 val_range_min + cnBuf.putDouble(0.0) // 120..127 val_range_max + bos.write(cnBuf.array()) + + // Name TXBlock + bos.write(nameTxBytes[i]) + + // Unit TXBlock (있을 경우) + if (unitTxBytes[i].isNotEmpty()) { + bos.write(unitTxBytes[i]) + } + } + + // 6. Data Block Header (##DT) val dtHeader = ByteBuffer.allocate(24).order(ByteOrder.LITTLE_ENDIAN) dtHeader.put("##DT".toByteArray(Charsets.US_ASCII)) dtHeader.putInt(0) @@ -93,6 +240,7 @@ class Mf4MeasurementLogger( bos.flush() outputStream = bos recordCount = 0L + lastFlushNanos = System.nanoTime() return file } catch (e: Exception) { e.printStackTrace() @@ -104,15 +252,20 @@ class Mf4MeasurementLogger( fun logSample(elapsedSec: Double, values: List) { val os = outputStream ?: return try { - val buf = ByteBuffer.allocate(recordSize).order(ByteOrder.LITTLE_ENDIAN) + val buf = sampleBuffer + buf.clear() buf.putDouble(elapsedSec) - for (v in values) { - val num = if (v != null && !v.isNaN() && !v.isInfinite()) v else 0.0 + for (index in signalHeaders.indices) { + val v = values.getOrNull(index) + // A missing channel must not become an apparently measured zero. + val num = if (v != null && v.isFinite()) v else Double.NaN buf.putDouble(num) } os.write(buf.array()) recordCount++ - if (recordCount % 60L == 0L) { + val now = System.nanoTime() + if (now - lastFlushNanos >= 1_000_000_000L) { + lastFlushNanos = now os.flush() } } catch (e: Exception) { @@ -136,14 +289,14 @@ class Mf4MeasurementLogger( if (file.exists() && recordCount > 0L) { try { RandomAccessFile(file, "rw").use { raf -> - // 1. CG Block cycle_count 갱신 - raf.seek(64 + 104 + 24) // offset of cycle count + // 1. CG Block cycle_count 갱신 (CG 블록 내 32바이트 위치) + raf.seek(cgRecordCountOffset) val buf = ByteBuffer.allocate(8).order(ByteOrder.LITTLE_ENDIAN) buf.putLong(recordCount) raf.write(buf.array()) // 2. DT Block length 갱신 (24 bytes header + recordCount * recordSize) - raf.seek(64 + 104 + 64 + 8) // offset of DT length + raf.seek(dtBlockOffset + 8) val dtLenBuf = ByteBuffer.allocate(8).order(ByteOrder.LITTLE_ENDIAN) dtLenBuf.putLong(24L + (recordCount * recordSize)) raf.write(dtLenBuf.array()) diff --git a/Android/app/src/main/java/com/dct/app/data/logger/PcMeasurementNames.kt b/Android/app/src/main/java/com/dct/app/data/logger/PcMeasurementNames.kt new file mode 100644 index 0000000..bcd894e --- /dev/null +++ b/Android/app/src/main/java/com/dct/app/data/logger/PcMeasurementNames.kt @@ -0,0 +1,18 @@ +package com.dct.app.data.logger + +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale + +/** The PC records use the project name (not the .dpc filename) and second precision. */ +object PcMeasurementNames { + fun projectName(projectFileName: String): String = + projectFileName.trim().let { name -> + (if (name.endsWith(".dpc", ignoreCase = true)) name.dropLast(4) else name).ifBlank { "PROJECT" } + } + + fun timestamp(date: Date): String = SimpleDateFormat("yyyy-MM-dd_HH-mm-ss", Locale.US).format(date) + + fun fileName(projectName: String, timestamp: String, source: String, extension: String): String = + "${projectName}_${timestamp}_${source}.${extension}" +} diff --git a/Android/app/src/main/java/com/dct/app/data/logger/PcOperationalLogger.kt b/Android/app/src/main/java/com/dct/app/data/logger/PcOperationalLogger.kt new file mode 100644 index 0000000..6ffe89a --- /dev/null +++ b/Android/app/src/main/java/com/dct/app/data/logger/PcOperationalLogger.kt @@ -0,0 +1,220 @@ +package com.dct.app.data.logger + +import kotlinx.coroutines.* +import kotlinx.coroutines.channels.Channel +import java.io.BufferedWriter +import java.io.File +import java.io.IOException +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale + +/** PC Log formats, independent of measurement recording and the transport/ACK workers. */ +class PcOperationalLogger( + private val directory: () -> File, + dispatcher: CoroutineDispatcher = Dispatchers.IO, + private val clock: () -> Long = System::currentTimeMillis, + private val onFailure: (Throwable) -> Unit = {}, + private val maxQueuedBytes: Int = 8 * 1024 * 1024 +) : AutoCloseable { + private sealed interface Entry { + data class Action(val description: String, val time: Long) : Entry + data class Open(val id: Long, val host: String, val time: Long) : Entry + data class Frame(val id: Long, val direction: String, val bytes: ByteArray, val time: Long) : Entry + data class End(val id: Long, val time: Long) : Entry + data class Incomplete(val id: Long, val time: Long) : Entry + } + private data class Connection(val time: Long, var writer: BufferedWriter? = null, var failed: Boolean = false) + private val queue = Channel(Channel.UNLIMITED) + private val scope = CoroutineScope(SupervisorJob() + dispatcher) + private var accepting = true + private var activeConnection: Long? = null + private var rawEnabled = true + private var queuedBytes = 0L + private var queuedFrames = 0 + private var logDirectory: File? = null // Writer-owned; keep CSV/TXT together for this process. + private val startedAt = clock() + private val worker = run { + require(maxQueuedBytes > 0) + scope.launch { writeEntries() } + } + + init { + action("Start DCT Program") + } + + @Synchronized + fun action(description: String) { + if (accepting) queue.trySend(Entry.Action(description, clock())) + } + + @Synchronized + fun connected(id: Long, host: String) { + if (!accepting) return + activeConnection?.let { queue.trySend(Entry.End(it, clock())) } + activeConnection = id + rawEnabled = true + queue.trySend(Entry.Open(id, host, clock())) + } + + /** Log only successfully written TX frames and checksum-validated RX frames. */ + @Synchronized + fun frame(id: Long, direction: String, bytes: ByteArray, time: Long = clock()) { + if (!accepting || activeConnection != id || !rawEnabled) return + if (queuedBytes + bytes.size > maxQueuedBytes || queuedFrames >= 4096) { + // Never stall ACKs or silently pretend a truncated capture is complete. + rawEnabled = false + queue.trySend(Entry.Incomplete(id, time)) + return + } + queuedBytes += bytes.size + queuedFrames++ + queue.trySend(Entry.Frame(id, direction, bytes.copyOf(), time)) + } + + @Synchronized + fun disconnected(id: Long) { + if (!accepting || activeConnection != id) return + activeConnection = null + queue.trySend(Entry.End(id, clock())) + } + + @Synchronized + override fun close() { + if (!accepting) return + activeConnection?.let { queue.trySend(Entry.End(it, clock())) } + activeConnection = null + queue.trySend(Entry.Action("Terminate DCT Program", clock())) + accepting = false + queue.close() // Drain accepted entries before closing files; do not cancel the writer. + } + + suspend fun awaitClosed() { worker.join() } + + private suspend fun writeEntries() { + val csvName = SimpleDateFormat("yyyy-MM-dd_HH-mm-ss", Locale.US) + val txtName = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US) + val day = SimpleDateFormat("yyyy-MM-dd", Locale.US) + val seconds = SimpleDateFormat("HH:mm:ss", Locale.US) + val millis = SimpleDateFormat("HH:mm:ss.SSS", Locale.US) + val connections = mutableMapOf() + var actions: BufferedWriter? = null + fun report(error: Throwable) { runCatching { onFailure(error) } } + fun closeWriter(writer: BufferedWriter?) { + try { writer?.close() } catch (error: Exception) { report(error) } + } + fun appendAction(description: String, time: Long) { + val safe = description.replace('\r', ' ').replace('\n', ' ') + val field = if (safe.contains(',') || safe.contains('"')) "\"${safe.replace("\"", "\"\"")}\"" else safe + try { + actions?.apply { write("${seconds.format(Date(time))}, $field,\r\n"); flush() } + } catch (error: Exception) { closeWriter(actions); actions = null; report(error) } + } + fun flushRaw() { + connections.values.forEach { connection -> + try { connection.writer?.flush() } catch (error: Exception) { + connection.failed = true + closeWriter(connection.writer) + connection.writer = null + appendAction("Communication log incomplete: ${error.message}", clock()) + report(error) + } + } + } + try { + try { + val file = reserveFile("${csvName.format(Date(startedAt))}_log", "csv") + actions = file.bufferedWriter(Charsets.UTF_8) + actions?.apply { + write("File: ${file.name}\r\nDate: ${day.format(Date(startedAt))}\r\n\r\n Time, Description,\r\n") + flush() + } + } catch (error: Exception) { closeWriter(actions); actions = null; report(error) } + var lastFlush = System.nanoTime() + while (true) { + val received = withTimeoutOrNull(1000) { queue.receiveCatching() } + if (received?.isClosed == true) break + when (val entry = received?.getOrNull()) { + is Entry.Action -> appendAction(entry.description, entry.time) + is Entry.Open -> { + connections[entry.id] = Connection(entry.time) + appendAction("Connected ${entry.host}", entry.time) + } + is Entry.Frame -> { + try { + val connection = connections[entry.id] + if (connection != null && !connection.failed) { + if (connection.writer == null) { + connection.writer = reserveFile("${txtName.format(Date(connection.time))}_log", "txt") + .bufferedWriter(Charsets.UTF_8) + } + connection.writer?.apply { + write("[${entry.direction}][${millis.format(Date(entry.time))}] ") + val hex = "0123456789ABCDEF" + val text = CharArray(entry.bytes.size * 3) + entry.bytes.forEachIndexed { index, byte -> + val value = byte.toInt() and 0xFF + text[index * 3] = hex[value ushr 4] + text[index * 3 + 1] = hex[value and 15] + text[index * 3 + 2] = ' ' + } + write(text) + write("\r\n") + } + } + } catch (error: Exception) { + connections[entry.id]?.let { + it.failed = true; closeWriter(it.writer); it.writer = null + } + appendAction("Communication log incomplete: ${error.message}", entry.time) + report(error) + } finally { + synchronized(this@PcOperationalLogger) { + queuedBytes -= entry.bytes.size + queuedFrames-- + } + } + } + is Entry.End -> { + closeWriter(connections.remove(entry.id)?.writer) + appendAction("Disconnected", entry.time) + } + is Entry.Incomplete -> { + val message = "Communication log incomplete: write queue exceeded $maxQueuedBytes bytes or 4096 frames" + val connection = connections[entry.id] + try { + connection?.writer?.apply { write("# $message\r\n"); flush() } + } catch (error: Exception) { report(error) } + connection?.failed = true + closeWriter(connection?.writer) + connection?.writer = null + appendAction(message, entry.time) + report(IOException(message)) + } + null -> Unit + } + if (received == null || System.nanoTime() - lastFlush >= 1_000_000_000L) { + flushRaw() + lastFlush = System.nanoTime() + } + } + } finally { + connections.values.forEach { closeWriter(it.writer) } + closeWriter(actions) + scope.cancel() + } + } + + private fun reserveFile(stem: String, extension: String): File { + val folder = logDirectory ?: directory() + if (!folder.isDirectory && !folder.mkdirs()) throw IOException("Cannot create Log folder: $folder") + logDirectory = folder + var suffix = 0 + while (true) { + val name = if (suffix == 0) "$stem.$extension" else "$stem-${suffix.toString().padStart(2, '0')}.$extension" + val file = File(folder, name) + if (file.createNewFile()) return file + suffix++ + } + } +} diff --git a/Android/app/src/main/java/com/dct/app/data/maintenance/MaintenanceService.kt b/Android/app/src/main/java/com/dct/app/data/maintenance/MaintenanceService.kt index 55c483f..32c8934 100644 --- a/Android/app/src/main/java/com/dct/app/data/maintenance/MaintenanceService.kt +++ b/Android/app/src/main/java/com/dct/app/data/maintenance/MaintenanceService.kt @@ -19,6 +19,7 @@ import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch /** P3 보안 인증, 펌웨어 업로드, 플래시 로그 다운로드의 실장비 상태 머신. */ @@ -238,6 +239,85 @@ class MaintenanceService( } } + /** + * [ISSUE-R1-01] 단말 내 특정 슬롯의 펌웨어 파일을 삭제합니다 (0x31 서브타입 0x06). + * + * @param slotIndex 삭제할 펌웨어 슬롯 인덱스 (0..255) + * @return 성공 시 Result.success(Unit), 거절 또는 오류 시 Result.failure + */ + suspend fun removeFirmware(slotIndex: Int): Result = runCatching { + requireReady() + require(slotIndex in 0..0xFF) { "펌웨어 슬롯 인덱스가 8비트 범위를 벗어납니다: $slotIndex" } + val response = exchange(DctProtocolRequests.firmwareRemove(slotIndex)) + requireSubtype(response.payload, 0x06, 2, "펌웨어 삭제") + require(u8(response.payload[1]) == 0x01) { "장비가 펌웨어 슬롯($slotIndex) 삭제를 거절했습니다." } + } + + /** + * [ISSUE-R1-01] 단말에 업로드된 펌웨어로 플래시 업데이트를 시작하도록 트리거하고 (0x31 서브타입 0x07), + * 단말의 플래싱 진행률 및 완료 응답(0x31/0xB1 서브타입 0x08)을 수신 처리합니다. + * + * @param slotIndex 업데이트를 진행할 펌웨어 슬롯 인덱스 (기본값 0) + * @return 성공 시 Result.success(Unit), 실패 또는 타임아웃 시 Result.failure + */ + suspend fun triggerFirmwareUpdate(slotIndex: Int = 0): Result = runCatching { + requireReady() + require(slotIndex in 0..0xFF) { "펌웨어 슬롯 인덱스가 8비트 범위를 벗어납니다: $slotIndex" } + + _transferProgress.value = TransferProgress(0, 100L, true, "단말 펌웨어 플래시 업데이트 시작 요청") + try { + // 1. 0x07 트리거 송신 및 초기 응답(0x01) 확인 + val triggerResponse = exchange(DctProtocolRequests.firmwareTriggerUpdate(slotIndex)) + requireSubtype(triggerResponse.payload, 0x07, 2, "펌웨어 업데이트 시작") + require(u8(triggerResponse.payload[1]) == 0x01) { "장비가 펌웨어 플래시 업데이트 시작을 거절했습니다." } + + // 2. 단말의 0x08 진행률 및 완료 알림 수신 루프 + kotlinx.coroutines.withTimeout(FIRMWARE_UPDATE_TIMEOUT_MS) { + packetRepository.packetStream.first { packet -> + if (packet.command == DctCommand.RES_FW_INFO.code && packet.payload.isNotEmpty() && u8(packet.payload[0]) == 0x08) { + val payload = packet.payload + require(payload.size >= 2) { "펌웨어 진행 응답 형식이 잘못되었습니다." } + val status = u8(payload[1]) + when (status) { + 0x01 -> { + val progress = if (payload.size >= 3) u8(payload[2]).coerceIn(0, 100) else 0 + _transferProgress.value = TransferProgress( + progress.toLong(), + 100L, + true, + "단말 펌웨어 플래싱 진행 중 ($progress%)" + ) + packetRepository.sendCommand(DctProtocolRequests.firmwareUpdateResponse(0x01)) + false + } + 0x02 -> { + _transferProgress.value = TransferProgress( + 100L, + 100L, + false, + "단말 펌웨어 업데이트 완료" + ) + packetRepository.sendCommand(DctProtocolRequests.firmwareUpdateResponse(0x02)) + true + } + else -> { + throw IllegalStateException("단말 펌웨어 업데이트 실패 상태 코드: 0x${status.toString(16)}") + } + } + } else { + false + } + } + } + } catch (e: CancellationException) { + _transferProgress.value = _transferProgress.value.copy(isRunning = false, message = "펌웨어 업데이트 취소") + throw e + } catch (e: Throwable) { + _transferProgress.value = _transferProgress.value.copy(isRunning = false, message = e.message ?: "펌웨어 업데이트 실패") + throw e + } + } + suspend fun listSavedLogs(): List { requireReady() val count = parseCount(exchange(DctProtocolRequests.savedLogCount()).payload, 0x01, "로그") @@ -289,6 +369,19 @@ class MaintenanceService( } } + /** + * [ISSUE-R3-03] 단말 내 저장된 특정 인덱스의 로그 파일을 삭제합니다 (0x26 서브타입 0x03). + * + * @param index 삭제할 로그 파일 인덱스 (0..65535) + */ + suspend fun deleteLogFile(index: Int) { + requireReady() + require(index in 0..0xFFFF) { "로그 파일 인덱스가 16비트 범위를 벗어납니다: $index" } + val response = exchange(DctProtocolRequests.savedLogRemove(index)) + requireSubtype(response.payload, 0x03, 2, "로그 삭제") + require(u8(response.payload[1]) == 0x01) { "장비가 로그 파일($index) 삭제를 거절했습니다." } + } + private suspend fun handleSeed(packet: DctPacket) { val payload = packet.payload if (payload.size < 2 || u8(payload[0]) != 0x01) { @@ -362,5 +455,6 @@ class MaintenanceService( private const val MAX_REMOTE_FILES = 4096 private const val MAX_FIRMWARE_BYTES = 64L * 1024 * 1024 private const val MAX_LOG_BYTES = 0xFFFF_FFFFL + private const val FIRMWARE_UPDATE_TIMEOUT_MS = 120_000L } } diff --git a/Android/app/src/main/java/com/dct/app/data/mdb/MdbSchemaParser.kt b/Android/app/src/main/java/com/dct/app/data/mdb/MdbSchemaParser.kt index b8aac7d..9b48b81 100644 --- a/Android/app/src/main/java/com/dct/app/data/mdb/MdbSchemaParser.kt +++ b/Android/app/src/main/java/com/dct/app/data/mdb/MdbSchemaParser.kt @@ -1,5 +1,6 @@ package com.dct.app.data.mdb +import com.dct.app.core.theme.toColorRef import com.healthmarketscience.jackcess.Database import com.healthmarketscience.jackcess.Row import com.healthmarketscience.jackcess.Table @@ -91,7 +92,7 @@ object MdbSchemaParser { val cvsT = getString(row, "CVS_T") ?: "" val rawUnit = getString(row, "UNIT") ?: "" val unit = com.dct.app.core.util.TextStreamDecoder.sanitizeUnit(rawUnit) - val color = getNumber(row, "COLOR")?.toLong() ?: 0L + val color = (getNumber(row, "COLOR")?.toLong() ?: 0L).toColorRef() val min = getDouble(row, "MIN") ?: 0.0 val max = getDouble(row, "MAX") ?: 100.0 val description = getString(row, "DESCRIPTION") ?: "" diff --git a/Android/app/src/main/java/com/dct/app/data/parser/A2lParser.kt b/Android/app/src/main/java/com/dct/app/data/parser/A2lParser.kt index 0d15412..59cd642 100644 --- a/Android/app/src/main/java/com/dct/app/data/parser/A2lParser.kt +++ b/Android/app/src/main/java/com/dct/app/data/parser/A2lParser.kt @@ -97,6 +97,8 @@ object A2lParser { val upperLimit: Double = 100.0, val ecuAddress: Long = 0L, val format: String = "%6.3f", + /** 측정 블록 자체에 FORMAT이 있었는지. 없으면 PC처럼 COMPU_METHOD FORMAT을 사용한다. */ + val hasExplicitFormat: Boolean = false, val unit: String = "", val matrixDim: List = emptyList() ) @@ -119,7 +121,8 @@ object A2lParser { val format: String = "%6.3f", val dataType: String = "UBYTE", val a2lDataType: A2lDataType = A2lDataType.UBYTE, - val attr: MdbAttrType = MdbAttrType.ATTR_0_UINT8 + val attr: MdbAttrType = MdbAttrType.ATTR_0_UINT8, + val calibrationLayoutError: String? = null ) /** @@ -139,8 +142,31 @@ object A2lParser { val a2lDataType: A2lDataType = A2lDataType.UBYTE, val lowerLimit: Double = 0.0, val upperLimit: Double = 100.0, - val attr: MdbAttrType = MdbAttrType.ATTR_0_UINT8 - ) + val attr: MdbAttrType = MdbAttrType.ATTR_0_UINT8, + val indexMode: String = "ROW_DIR", + val calibrationLayoutError: String? = null + ) { + /** UI 값은 행 순서로 유지하고, 장비 주소만 Record Layout의 저장 순서로 변환한다. */ + fun cellAddress(cellIndex: Int): Long { + require(calibrationLayoutError == null) { calibrationLayoutError ?: "지원하지 않는 배열 레이아웃입니다." } + require(matrixDim.size in 1..2 && matrixDim.all { it > 0 }) { "1차원 또는 2차원 배열만 보정할 수 있습니다." } + val xSize = matrixDim[0] + val ySize = matrixDim.getOrElse(1) { 1 } + require(cellIndex >= 0 && cellIndex.toLong() < xSize.toLong() * ySize && cellIndex in values.indices) { + "배열 인덱스가 범위를 벗어났습니다." + } + val memoryIndex = when (indexMode) { + "ROW_DIR" -> cellIndex.toLong() + "COLUMN_DIR" -> (cellIndex % xSize).toLong() * ySize + cellIndex / xSize + else -> throw IllegalArgumentException("지원하지 않는 배열 저장 순서: $indexMode") + } + val address = ecuAddress + memoryIndex * a2lDataType.byteSize + require(ecuAddress in 0..0xFFFF_FFFFL && address in 0..(0xFFFF_FFFFL - a2lDataType.byteSize + 1)) { + "배열 주소가 프로토콜 범위를 벗어났습니다." + } + return address + } + } /** * A2L 전체 파싱 결과 통합 모델 @@ -164,6 +190,7 @@ object A2lParser { private val ECU_ADDRESS_REGEX = Regex("""ECU_ADDRESS\s+(0x[0-9a-fA-F]+|\d+)""", RegexOption.IGNORE_CASE) private val FORMAT_REGEX = Regex("""FORMAT\s+"([^"]+)"""", RegexOption.IGNORE_CASE) private val UNIT_REGEX = Regex("""UNIT\s+"([^"]+)"""", RegexOption.IGNORE_CASE) + private val PHYS_UNIT_REGEX = Regex("""PHYS_UNIT\s+"([^"]+)"""", RegexOption.IGNORE_CASE) private val MATRIX_DIM_REGEX = Regex("""MATRIX_DIM\s+([\d\s]+)""", RegexOption.IGNORE_CASE) private val NUMBER_REGEX = Regex("""NUMBER\s+(\d+)""", RegexOption.IGNORE_CASE) private val DEFAULT_VALUE_REGEX = Regex("""DEFAULT_VALUE(?:_NUMERIC)?\s+([^\s]+)""", RegexOption.IGNORE_CASE) @@ -173,7 +200,10 @@ object A2lParser { private val CAN_ID_MASTER_REGEX = Regex("""CAN_ID_MASTER\s+(0x[0-9a-fA-F]+|\d+)""", RegexOption.IGNORE_CASE) private val CAN_ID_SLAVE_REGEX = Regex("""CAN_ID_SLAVE\s+(0x[0-9a-fA-F]+|\d+)""", RegexOption.IGNORE_CASE) private val BAUDRATE_REGEX = Regex("""BAUDRATE\s+(\d+)""", RegexOption.IGNORE_CASE) - private val BYTE_ORDER_REGEX = Regex("""BYTE_ORDER\s+(\w+)""", RegexOption.IGNORE_CASE) + private val BYTE_ORDER_REGEX = Regex( + """BYTE_ORDER(?:\s+|_)(MSB_FIRST|MSB_LAST)""", + RegexOption.IGNORE_CASE + ) fun parseA2l(inputStream: InputStream): List { return parseA2lFull(inputStream).measurements @@ -181,6 +211,8 @@ object A2lParser { fun parseA2lFull(inputStream: InputStream): A2lParseResult { val lines = com.dct.app.core.util.TextStreamDecoder.readLinesSmart(inputStream) + // RECORD_LAYOUT은 CHARACTERISTIC 뒤에 선언될 수 있으므로 먼저 수집한다. + val recordLayouts = parseRecordLayouts(cleanA2lComments(lines.joinToString("\n"))) val compuMethods = mutableMapOf() val vtabs = mutableMapOf>() @@ -188,6 +220,7 @@ object A2lParser { val measurements = mutableListOf() val rawCharacteristics = mutableListOf() var parsedIfData: XcpIfData? = null + var genericXcpIfData: XcpIfData? = null var i = 0 while (i < lines.size) { @@ -206,7 +239,15 @@ object A2lParser { i++ } if (ifType.contains("XCP", ignoreCase = true)) { - parsedIfData = parseIfDataBlock(blockBuilder.toString()) + val block = blockBuilder.toString() + val parsed = parseIfDataBlock(block) + // PC가 사용하는 전송 설정은 XCP_ON_CAN이다. 다른 XCP IF_DATA가 + // 뒤에 나오더라도 CAN ID/엔디안을 기본값으로 다시 덮지 않는다. + if (Regex("""/begin\s+XCP_ON_CAN\b""", RegexOption.IGNORE_CASE).containsMatchIn(block)) { + parsedIfData = parsed + } else if (genericXcpIfData == null) { + genericXcpIfData = parsed + } } i++ continue @@ -313,6 +354,7 @@ object A2lParser { val unit = com.dct.app.core.util.TextStreamDecoder.sanitizeUnit(rawUnit) val factor = compu?.cvsA ?: 1.0 val offset = compu?.cvsB ?: 0.0 + val measurementFormat = if (meas.hasExplicitFormat) meas.format else compu?.format ?: meas.format val attr = meas.a2lDataType.toMdbAttrType() val byteSize = meas.a2lDataType.byteSize @@ -353,7 +395,7 @@ object A2lParser { messageName = "A2L", messageId = elemAddr, a2lDataType = meas.a2lDataType, - measurementFormat = meas.format + measurementFormat = measurementFormat ) ) } @@ -383,7 +425,7 @@ object A2lParser { messageName = "A2L", messageId = meas.ecuAddress, a2lDataType = meas.a2lDataType, - measurementFormat = meas.format + measurementFormat = measurementFormat ) ) } @@ -398,19 +440,20 @@ object A2lParser { val compu = compuMethods[raw.conversionMethod.lowercase()] val rawUnit = if (raw.unit.isNotBlank()) raw.unit else compu?.unit ?: "" val unit = com.dct.app.core.util.TextStreamDecoder.sanitizeUnit(rawUnit) - val a2lType = A2lDataType.fromString(raw.dataType) + val layout = recordLayouts[raw.deposit.lowercase()] + val dataType = layout?.dataType ?: raw.dataType + val a2lType = A2lDataType.fromString(dataType) val attr = a2lType.toMdbAttrType() + val layoutError = layout?.error ?: if (layout == null) { + "Record Layout '${raw.deposit}' 정의가 없어 장비 보정을 실행할 수 없습니다." + } else null val isArray = raw.matrixDim.isNotEmpty() || raw.type in setOf("VAL_BLK", "CURVE", "MAP", "CUBOID", "CUBE_4", "CUBE_5") if (isArray) { val totalCount = if (raw.matrixDim.isNotEmpty()) raw.matrixDim.reduce { acc, d -> acc * d } else 1 - val initialValues = if (raw.values.isNotEmpty()) { - raw.values.take(totalCount) - } else { - List(totalCount) { 0.0 } - } + val initialValues = List(totalCount) { raw.values.getOrElse(it) { 0.0 } } characteristicsArray.add( A2lCharacteristicArray( name = raw.name, @@ -422,11 +465,13 @@ object A2lParser { values = initialValues, unit = unit, format = raw.format, - dataType = raw.dataType, + dataType = dataType, a2lDataType = a2lType, lowerLimit = raw.lowerLimit, upperLimit = raw.upperLimit, - attr = attr + attr = attr, + indexMode = layout?.indexMode ?: "ROW_DIR", + calibrationLayoutError = layoutError ) ) } else { @@ -444,9 +489,10 @@ object A2lParser { value = if (raw.values.isNotEmpty()) raw.values[0] else 0.0, unit = unit, format = raw.format, - dataType = raw.dataType, + dataType = dataType, a2lDataType = a2lType, - attr = attr + attr = attr, + calibrationLayoutError = layoutError ) ) } @@ -459,7 +505,7 @@ object A2lParser { rawMeasurements = measurements, characteristics = characteristics, characteristicsArray = characteristicsArray, - ifData = parsedIfData, + ifData = parsedIfData ?: genericXcpIfData, compuMethods = compuMethods ) } @@ -636,6 +682,7 @@ object A2lParser { var upperLimit = 100.0 var ecuAddress = 0L var format = "%6.3f" + var hasExplicitFormat = false var unit = "" val matrixDim = mutableListOf() @@ -660,10 +707,13 @@ object A2lParser { } } - val fmtMatch = FORMAT_REGEX.find(block) - if (fmtMatch != null) format = fmtMatch.groupValues[1] + val fmtMatch = FORMAT_REGEX.find(cleanedBlock) + if (fmtMatch != null) { + format = fmtMatch.groupValues[1] + hasExplicitFormat = true + } - val unitMatch = UNIT_REGEX.find(block) + val unitMatch = UNIT_REGEX.find(cleanedBlock) ?: PHYS_UNIT_REGEX.find(cleanedBlock) if (unitMatch != null) unit = unitMatch.groupValues[1] val dimMatch = MATRIX_DIM_REGEX.find(block) @@ -686,6 +736,7 @@ object A2lParser { upperLimit = upperLimit, ecuAddress = ecuAddress, format = format, + hasExplicitFormat = hasExplicitFormat, unit = unit, matrixDim = matrixDim ) @@ -737,7 +788,7 @@ object A2lParser { val fmtMatch = FORMAT_REGEX.find(cleanedBlock) if (fmtMatch != null) format = fmtMatch.groupValues[1] - val unitMatch = UNIT_REGEX.find(cleanedBlock) + val unitMatch = UNIT_REGEX.find(cleanedBlock) ?: PHYS_UNIT_REGEX.find(cleanedBlock) if (unitMatch != null) unit = unitMatch.groupValues[1] val dimMatch = MATRIX_DIM_REGEX.find(cleanedBlock) @@ -784,7 +835,36 @@ object A2lParser { upper.contains("FLOAT") -> "FLOAT32_IEEE" upper.contains("DOUBLE") -> "FLOAT64_IEEE" upper.contains("SBYTE") || upper.contains("INT8") -> "SBYTE" - else -> "UBYTE" + upper.contains("UBYTE") || upper.contains("UINT8") -> "UBYTE" + else -> "" + } + } + + private data class RecordLayout(val dataType: String?, val indexMode: String, val error: String?) + + /** 현재 보정 프로토콜은 시작 주소의 DIRECT FNC_VALUES만 지원한다. 다른 배치는 추정해서 쓰지 않는다. */ + private fun parseRecordLayouts(text: String): Map { + val blockRegex = Regex("""/begin\s+RECORD_LAYOUT\s+(\w+)([\s\S]*?)/end\s+RECORD_LAYOUT\b""", RegexOption.IGNORE_CASE) + val fncRegex = Regex("""\bFNC_VALUES\s+(\d+)\s+(\w+)\s+(\w+)\s+(\w+)""", RegexOption.IGNORE_CASE) + return blockRegex.findAll(text).associate { block -> + val name = block.groupValues[1] + val fnc = fncRegex.find(block.groupValues[2]) + val declaredType = fnc?.groupValues?.get(2)?.uppercase() + val dataType = when (declaredType) { + "A_UINT64" -> "UINT64" + "A_INT64" -> "INT64" + else -> declaredType?.takeIf { type -> A2lDataType.entries.any { it.typeName == type } } + } + val indexMode = fnc?.groupValues?.get(3)?.uppercase() ?: "" + val error = when { + fnc == null -> "Record Layout '$name'에 FNC_VALUES가 없습니다." + dataType == null -> "지원하지 않는 Record Layout 자료형: $declaredType" + fnc.groupValues[1].toIntOrNull() != 1 -> "FNC_VALUES 앞에 데이터가 있는 Record Layout은 보정할 수 없습니다." + fnc.groupValues[4].uppercase() != "DIRECT" -> "간접 주소 Record Layout은 보정할 수 없습니다." + indexMode !in setOf("ROW_DIR", "COLUMN_DIR") -> "지원하지 않는 배열 저장 순서: $indexMode" + else -> null + } + name.lowercase() to RecordLayout(dataType, indexMode, error) } } diff --git a/Android/app/src/main/java/com/dct/app/data/protocol/DctCalibrationProtocol.kt b/Android/app/src/main/java/com/dct/app/data/protocol/DctCalibrationProtocol.kt index b84a71b..d8a010c 100644 --- a/Android/app/src/main/java/com/dct/app/data/protocol/DctCalibrationProtocol.kt +++ b/Android/app/src/main/java/com/dct/app/data/protocol/DctCalibrationProtocol.kt @@ -4,7 +4,7 @@ import com.dct.app.data.mdb.AttrConverter import com.dct.app.domain.model.mdb.* import com.dct.app.domain.model.protocol.* -/** Calibration wire values are big endian (unlike the little-endian measurement stream). */ +/** MDB calibration wire values are big endian, as are PC MDB measurement values. */ object DctCalibrationProtocol { fun encodeInteger(value: Double, attr: MdbAttrType): ByteArray { require(value.isFinite() && value == kotlin.math.floor(value)) { "정수 데이터만 전송할 수 있습니다." } diff --git a/Android/app/src/main/java/com/dct/app/data/protocol/DctPacketCodec.kt b/Android/app/src/main/java/com/dct/app/data/protocol/DctPacketCodec.kt index 6a0a5fd..dcbd3e1 100644 --- a/Android/app/src/main/java/com/dct/app/data/protocol/DctPacketCodec.kt +++ b/Android/app/src/main/java/com/dct/app/data/protocol/DctPacketCodec.kt @@ -20,7 +20,7 @@ class DctPacketCodec( const val MIN_PACKET_LENGTH: Int = 6 // START(1) + LenH(1) + LenL(1) + Cmd(1) + Checksum(1) + END(1) const val HEADER_LENGTH: Int = 4 // START + LenH + LenL + Cmd const val OVERHEAD_LENGTH: Int = 5 // START(1) + LenH(1) + LenL(1) + Checksum(1) + END(1) - const val MAX_PAYLOAD_LENGTH: Int = 65530 + const val MAX_PAYLOAD_LENGTH: Int = 65534 // UInt16 length includes the command byte. } /** diff --git a/Android/app/src/main/java/com/dct/app/data/protocol/DctProtocolRequests.kt b/Android/app/src/main/java/com/dct/app/data/protocol/DctProtocolRequests.kt index dc8393e..22d8ae3 100644 --- a/Android/app/src/main/java/com/dct/app/data/protocol/DctProtocolRequests.kt +++ b/Android/app/src/main/java/com/dct/app/data/protocol/DctProtocolRequests.kt @@ -60,6 +60,30 @@ object DctProtocolRequests { ) } + /** PC SendDBInfo: [Sub-cmd: 0x00][프로젝트명 길이: 1B][프로젝트명 ASCII: N]. */ + fun dbInfo(projectName: String): DctPacket { + val encoded = projectName.toByteArray(Charsets.US_ASCII) + require(encoded.size <= 0xFF) { "Project name is too long" } + return DctPacket.create( + DctCommand.CMD_DB, + byteArrayOf(0x00, encoded.size.toByte()) + encoded + ) + } + + /** PC RequestDCTSavingIntervalTime / DCT Env Query: [Sub-cmd: 1B]. */ + fun dctEnvQuery(subCmd: Byte): DctPacket = + DctPacket.create(DctCommand.CMD_DCT_ENV, byteArrayOf(subCmd)) + + /** + * Empty PC RequestSMCount setup: [0x01][used-module bitmask=0]. + * + * Kept for source compatibility with older callers; new code should use + * [sensorModuleInfo] so the module/channel masks are carried explicitly. + */ + @Deprecated("Use sensorModuleInfo(modules) for a complete 0x12 payload") + fun smInfoCountQuery(): DctPacket = + sensorModuleInfo(emptyList()) + /** PC SendMDBPerceiveData와 동일한 ReclID/Index/ItemSize 등록 형식. */ fun mdbInfo(signals: List): DctPacket { val groups = signals.groupBy { it.reclid }.toSortedMap() @@ -257,6 +281,15 @@ object DctProtocolRequests { return DctPacket.create(DctCommand.CMD_SAVED_LOGFILE_COPY, stream.toByteArray()) } + /** PC RequestRemoveFile: [0x03][index:2BE]. */ + fun savedLogRemove(index: Int): DctPacket { + require(index in 0..0xFFFF) { "Log index must fit unsigned 16-bit" } + return DctPacket.create( + DctCommand.CMD_SAVED_LOGFILE_COPY, + byteArrayOf(0x03, (index ushr 8).toByte(), index.toByte()) + ) + } + /** PC RequestFWCount: [0x04]. */ fun firmwareCount(): DctPacket = DctPacket.create(DctCommand.CMD_FW_INFO, byteArrayOf(0x04)) @@ -307,6 +340,43 @@ object DctProtocolRequests { return DctPacket.create(DctCommand.CMD_FW_INFO, stream.toByteArray()) } + /** PC RequestRemoveFile: [0x06][slot:1]. */ + fun firmwareRemove(slot: Int): DctPacket { + require(slot in 0..0xFF) { "Firmware slot must fit unsigned 8-bit" } + return DctPacket.create(DctCommand.CMD_FW_INFO, byteArrayOf(0x06, slot.toByte())) + } + + /** [ISSUE-R1-01] 0x31 서브타입 0x06 슬롯 삭제 요청 바이너리 프레임 생성. */ + fun buildRemoveFirmwareRequest(slotIndex: Int): ByteArray { + return DctPacketCodec().encode(firmwareRemove(slotIndex)) + } + + /** PC RequestUdateViaDCT: [0x07][slot:1]. */ + fun firmwareTriggerUpdate(slot: Int = 0): DctPacket { + require(slot in 0..0xFF) { "Firmware slot must fit unsigned 8-bit" } + return DctPacket.create(DctCommand.CMD_FW_INFO, byteArrayOf(0x07, slot.toByte())) + } + + /** [ISSUE-R1-01] 0x31 서브타입 0x07 플래시 업데이트 실행 요청 바이너리 프레임 생성. */ + fun buildTriggerFirmwareUpdateRequest(): ByteArray { + return DctPacketCodec().encode(firmwareTriggerUpdate(0)) + } + + fun buildTriggerFirmwareUpdateRequest(slotIndex: Int): ByteArray { + return DctPacketCodec().encode(firmwareTriggerUpdate(slotIndex)) + } + + /** PC RespondUdateViaDCT: [0x08][status:1]. (0x01: 진행 중 ACK, 0x02: 완료 ACK) */ + fun firmwareUpdateResponse(status: Int): DctPacket { + require(status in 0..0xFF) { "Firmware update response status must fit unsigned 8-bit" } + return DctPacket.create(DctCommand.CMD_FW_INFO, byteArrayOf(0x08, status.toByte())) + } + + /** PC PossibleToConnect: [0x05]. */ + fun possibleToConnect(): DctPacket = + DctPacket.create(DctCommand.CMD_POSSIBLE_TO_CONNECT) + + /** PC SendASKKeyValue: [0x02][0x01][keyLength][key][firmwareAddress:4BE][STMin]. */ fun firmwareKeySuccess(key: ByteArray, firmwareAddress: Long, stMin: Int): DctPacket { require(key.isNotEmpty() && key.size <= 8) { "ASK key length must be 1..8" } diff --git a/Android/app/src/main/java/com/dct/app/data/protocol/DctResponseValidator.kt b/Android/app/src/main/java/com/dct/app/data/protocol/DctResponseValidator.kt index f8e1233..c9a8c0f 100644 --- a/Android/app/src/main/java/com/dct/app/data/protocol/DctResponseValidator.kt +++ b/Android/app/src/main/java/com/dct/app/data/protocol/DctResponseValidator.kt @@ -11,12 +11,62 @@ object DctResponseValidator { when (command) { 0x01 -> validateVersion(data) 0x03 -> require(data.size == 6) { "HW ID 응답은 6바이트여야 합니다." } - 0x09, 0x12 -> { + 0x09 -> { + if (request.payload.size == 2 && request.payload[0] == 6.toByte()) { + // RES_DCT_ENV 06: [subtype][status][applied CAN mode]. + // Early DCT_test builds returned a generic one-byte ACK, + // or omitted the echoed mode and sent [subtype][status]. + // Keep those compatibility paths while still rejecting a + // missing status or a wrong echoed mode in detailed replies. + if (data.size == 1) { + require(data[0] == 1.toByte()) { "장비가 CAN 모드 설정을 거절했습니다." } + } else { + require(data.size in 2..3 && data[0] == 6.toByte()) { "CAN 모드 응답 하위 명령/길이 오류" } + require(data[1] == 1.toByte()) { "장비가 CAN 모드 설정을 거절했습니다." } + if (data.size == 3) { + require(data[2] == request.payload[1]) { "장비에 적용된 CAN 모드가 요청과 다릅니다." } + } + } + } else { + // Query subtypes (00/03/...) carry subtype-specific fields. + if (data.size == 1) { + require(data[0] == 1.toByte()) { "장비가 환경 설정 조회를 거절했습니다." } + } else { + require(data.isNotEmpty() && data[0] == request.payload.firstOrNull()) { + "환경 설정 조회 응답 하위 명령 오류" + } + if (request.payload[0] == 0.toByte()) { + // 0x00 returns [subtype][status][use][interval...]. + require(data[1] == 1.toByte()) { "장비가 저장 주기 조회를 거절했습니다." } + } + } + } + } + 0x20 -> { + // Protocol v0.58: [STATUS][TYPE], not [TYPE][STATUS]. + require(data.size == 2 && data[1] == request.payload.singleOrNull()) { "계측 시작 응답 길이/종류 오류" } + require(data[0] == 1.toByte()) { "장비가 계측 시작을 거절했습니다." } + } + 0x08 -> { + // RES_DB 00: [subtype: 0x00][status: 0x01], or single-byte status. + if (data.size == 1) require(data[0] == 1.toByte()) { "장비가 DB 설정을 거절했습니다." } + else { + require(data.size >= 2 && data[0] == request.payload.firstOrNull()) { "DB 응답 하위 명령 오류" } + require(data[1] == 1.toByte()) { "장비가 DB 설정을 거절했습니다." } + } + } + 0x12 -> { // Older peers, including DCT_test, return a single status byte. + // Live HW returns [0x01][SM count] for setup and + // [0x02][SM index][channel count] for the follow-up query. + // The second byte of subtype 0x01 is a count, not an ACK status. + val subtype = request.payload.firstOrNull() if (data.size == 1) require(data[0] == 1.toByte()) { "장비가 설정을 거절했습니다." } else { - require(data.size == 2 && data[0] == request.payload[0]) { "설정 응답 하위 명령/길이 오류" } - require(data[1] == 1.toByte()) { "장비가 설정을 거절했습니다." } + require(data.size >= 2 && data[0] == subtype) { "SM 설정 응답 하위 명령 오류" } + if (subtype == 2.toByte()) { + require(data.size >= 3) { "센서 모듈 채널 수 응답 길이 오류" } + } } } else -> require(data.size == 1 && data[0] == 1.toByte()) { "장비가 요청을 거절했거나 상태 응답이 잘못되었습니다." } diff --git a/Android/app/src/main/java/com/dct/app/data/protocol/DctStreamPayloadParser.kt b/Android/app/src/main/java/com/dct/app/data/protocol/DctStreamPayloadParser.kt index 9ea75fb..6be8e90 100644 --- a/Android/app/src/main/java/com/dct/app/data/protocol/DctStreamPayloadParser.kt +++ b/Android/app/src/main/java/com/dct/app/data/protocol/DctStreamPayloadParser.kt @@ -47,8 +47,8 @@ object DctStreamPayloadParser { val frames = ArrayList(frameCount) repeat(frameCount) { if (offset + 3 > payload.size) return null - val canId = (payload[offset].toLong() and 0xFF) or - ((payload[offset + 1].toLong() and 0xFF) shl 8) + val canId = ((payload[offset].toLong() and 0xFF) shl 8) or + (payload[offset + 1].toLong() and 0xFF) val lengthAndFlag = payload[offset + 2].toInt() and 0xFF val length = lengthAndFlag and 0x7F if (length > 64 || offset + 3 + length > payload.size) return null @@ -81,10 +81,11 @@ object DctStreamPayloadParser { if (offset + 5 > payload.size) return null val channelIndex = payload[offset].toInt() and 0xFF if (!seenChannels.add(channelIndex)) return null - val integerRaw = (payload[offset + 1].toInt() and 0xFF) or - ((payload[offset + 2].toInt() and 0xFF) shl 8) - val decimal = (payload[offset + 3].toInt() and 0xFF) or - ((payload[offset + 4].toInt() and 0xFF) shl 8) + // PC MAKEWORD(low, high): each sensor word is transmitted high byte first. + val integerRaw = ((payload[offset + 1].toInt() and 0xFF) shl 8) or + (payload[offset + 2].toInt() and 0xFF) + val decimal = ((payload[offset + 3].toInt() and 0xFF) shl 8) or + (payload[offset + 4].toInt() and 0xFF) val notConnected = integerRaw == 0xFFFF val value = if (notConnected) null else { val magnitude = (integerRaw and 0x0FFF) + decimal / 100.0 @@ -189,13 +190,17 @@ object CanFrameSignalDecoder { ) { continue } - val raw = CanSignalBitDecoder.decode( + // PC MainFrm.cpp는 먼저 unsigned 저장 버킷으로 bit field를 만든 뒤 + // 신호가 signed이면 8/16/32/64-bit 버킷 타입으로 캐스팅한다. + // 따라서 4/12/24-bit signed 신호도 선언 bitLength 기준 sign extension을 하지 않는다. + val extracted = CanSignalBitDecoder.decode( data = data, startBit = signal.startBit, bitLength = signal.bitLength, isMotorola = signal.isMotorola, - isSigned = signal.attr.isSigned + isSigned = false ) ?: continue + val raw = if (signal.attr.isSigned) pcSignedBucketValue(extracted, signal.bitLength) else extracted val numericRaw = if (!signal.attr.isSigned && signal.bitLength == 64 && raw < 0) { (raw and Long.MAX_VALUE).toDouble() + 9_223_372_036_854_775_808.0 } else { @@ -205,4 +210,12 @@ object CanFrameSignalDecoder { } } } + + /** PC의 uint8/16/32/64 추출 후 int8/16/32/64 캐스팅 규칙. */ + private fun pcSignedBucketValue(raw: Long, bitLength: Int): Long = when (bitLength) { + in 1..8 -> raw.toByte().toLong() + in 9..16 -> raw.toShort().toLong() + in 17..32 -> raw.toInt().toLong() + else -> raw + } } diff --git a/Android/app/src/main/java/com/dct/app/data/protocol/MdbRecordDecoder.kt b/Android/app/src/main/java/com/dct/app/data/protocol/MdbRecordDecoder.kt index 1eadf1c..2413206 100644 --- a/Android/app/src/main/java/com/dct/app/data/protocol/MdbRecordDecoder.kt +++ b/Android/app/src/main/java/com/dct/app/data/protocol/MdbRecordDecoder.kt @@ -4,10 +4,20 @@ import com.dct.app.domain.model.mdb.MdbAttrType import com.dct.app.domain.model.mdb.SignalDefinition import java.util.Locale -/** MDB 한 레코드를 부분 적용 없이 검증하고 디코딩합니다. */ +/** MDB 한 레코드를 PC와 같은 Big-endian으로 디코딩합니다. + * + * 장비/로그의 MDB 정의가 앱에 로드된 정의와 다를 수 있으므로 레코드 전체 길이가 + * 다르다는 이유만으로 연결이나 해당 패킷을 버리지 않습니다. 각 신호를 독립적으로 + * 범위 검사하여 수신된 바이트가 충분한 신호는 그대로 디코딩하고, 잘린 신호만 + * 건너뜁니다. + */ object MdbRecordDecoder { sealed interface DecodeResult { - data class Success(val signals: List) : DecodeResult + /** 정상 신호는 반영되었지만 MDB 정의와 payload 길이가 다른 경우 warning을 남깁니다. */ + data class Success( + val signals: List, + val warning: String? = null + ) : DecodeResult data class Failure(val reason: String) : DecodeResult } @@ -17,62 +27,84 @@ object MdbRecordDecoder { val physicalValue: Double ) - fun decode(recordId: Int, data: ByteArray, definitions: List): DecodeResult { - val recordSignals = definitions.filter { (it.reclid and 0xFF) == recordId } - .sortedBy { it.arrayIndex } - if (recordSignals.isEmpty()) { - return DecodeResult.Failure("정의되지 않은 MDB ReclID $recordId") - } - if (recordSignals.map { it.idx and 0xFF }.distinct().size != recordSignals.size) { - return DecodeResult.Failure("MDB ReclID $recordId 내 신호 Index가 중복됩니다.") - } - if (recordSignals.map { it.name.lowercase(Locale.ROOT) }.distinct().size != recordSignals.size) { - return DecodeResult.Failure("MDB ReclID $recordId 내 신호 이름이 중복됩니다.") - } + private data class RecordPlan(val signals: List, val size: Int, val error: String? = null) - val expectedSize = recordSignals.maxOf { signal -> - signal.arrayIndex + signal.attr.byteSize - } - if (recordSignals.any { it.arrayIndex < 0 } || expectedSize > data.size) { - return DecodeResult.Failure( - "MDB ReclID $recordId 데이터가 부족합니다. 필요=$expectedSize, 수신=${data.size}" - ) - } + /** One immutable plan per database revision; payload bounds are still checked for every record. */ + class PreparedDefinitions(definitions: List) { + private val records = definitions.groupBy { it.reclid and 0xFF } + .mapValues { (id, signals) -> prepare(id, signals) } - val occupied = BooleanArray(expectedSize) - for (signal in recordSignals) { - for (offset in signal.arrayIndex until signal.arrayIndex + signal.attr.byteSize) { - if (occupied[offset]) { - return DecodeResult.Failure("MDB ReclID $recordId 신호 바이트 범위가 중복됩니다.") - } - occupied[offset] = true - } + fun decode(recordId: Int, data: ByteArray): DecodeResult = decodePrepared(recordId, data, records[recordId]) + } + + fun decode(recordId: Int, data: ByteArray, definitions: List): DecodeResult = + PreparedDefinitions(definitions).decode(recordId, data) + + private fun prepare(recordId: Int, definitions: List): RecordPlan { + val signals = definitions.map { it.copy() }.sortedBy { it.arrayIndex } + fun invalid(reason: String) = RecordPlan(signals, 0, reason) + if (signals.map { it.idx }.distinct().size != signals.size) + return invalid("MDB ReclID $recordId 내 신호 Index가 중복됩니다.") + if (signals.map { it.name.lowercase(Locale.ROOT) }.distinct().size != signals.size) + return invalid("MDB ReclID $recordId 내 신호 이름이 중복됩니다.") + var previousEnd = 0L + for (signal in signals) { + if (signal.arrayIndex < 0) return invalid("MDB ReclID $recordId 신호 바이트 위치가 음수입니다.") + if (signal.arrayIndex.toLong() < previousEnd) + return invalid("MDB ReclID $recordId 신호 바이트 범위가 중복됩니다.") + previousEnd = signal.arrayIndex.toLong() + signal.attr.byteSize + if (previousEnd > Int.MAX_VALUE) return invalid("MDB ReclID $recordId 신호 바이트 범위가 너무 큽니다.") } + return RecordPlan(signals, previousEnd.toInt()) + } + + private fun decodePrepared(recordId: Int, data: ByteArray, plan: RecordPlan?): DecodeResult { + if (plan == null) return DecodeResult.Failure("정의되지 않은 MDB ReclID $recordId") + plan.error?.let { return DecodeResult.Failure(it) } + val recordSignals = plan.signals val decoded = ArrayList(recordSignals.size) + var skippedSignals = 0 for (signal in recordSignals) { val offset = signal.arrayIndex + // A length mismatch is a schema/version difference, not a transport + // failure. Decode every complete signal and skip only the one whose + // bytes are unavailable in this payload. + if (offset < 0 || offset.toLong() + signal.attr.byteSize > data.size) { + skippedSignals++ + continue + } val raw: Number = when (signal.attr) { MdbAttrType.ATTR_0_UINT8 -> data[offset].toInt() and 0xFF MdbAttrType.ATTR_1_INT8 -> data[offset] MdbAttrType.ATTR_2_UINT16 -> - (data[offset].toInt() and 0xFF) or ((data[offset + 1].toInt() and 0xFF) shl 8) + ((data[offset].toInt() and 0xFF) shl 8) or + (data[offset + 1].toInt() and 0xFF) MdbAttrType.ATTR_3_INT16 -> - ((data[offset].toInt() and 0xFF) or - ((data[offset + 1].toInt() and 0xFF) shl 8)).toShort() + (((data[offset].toInt() and 0xFF) shl 8) or + (data[offset + 1].toInt() and 0xFF)).toShort() MdbAttrType.ATTR_4_UINT32 -> - (data[offset].toLong() and 0xFF) or - ((data[offset + 1].toLong() and 0xFF) shl 8) or - ((data[offset + 2].toLong() and 0xFF) shl 16) or - ((data[offset + 3].toLong() and 0xFF) shl 24) + ((data[offset].toLong() and 0xFF) shl 24) or + ((data[offset + 1].toLong() and 0xFF) shl 16) or + ((data[offset + 2].toLong() and 0xFF) shl 8) or + (data[offset + 3].toLong() and 0xFF) MdbAttrType.ATTR_5_INT32 -> - (data[offset].toInt() and 0xFF) or - ((data[offset + 1].toInt() and 0xFF) shl 8) or - ((data[offset + 2].toInt() and 0xFF) shl 16) or - ((data[offset + 3].toInt() and 0xFF) shl 24) + ((data[offset].toInt() and 0xFF) shl 24) or + ((data[offset + 1].toInt() and 0xFF) shl 16) or + ((data[offset + 2].toInt() and 0xFF) shl 8) or + (data[offset + 3].toInt() and 0xFF) } decoded += DecodedSignal(signal, raw, signal.calculatePhysicalValue(raw)) } - return DecodeResult.Success(decoded) + val warning = when { + data.size < plan.size -> + "MDB ReclID $recordId 데이터 길이 불일치(필요=${plan.size}, 수신=${data.size}), " + + "완전한 신호 ${decoded.size}개 반영, ${skippedSignals}개 건너뜀" + data.size > plan.size -> + "MDB ReclID $recordId 데이터 길이 불일치(정의=${plan.size}, 수신=${data.size}), " + + "초과 바이트 ${data.size - plan.size}B 무시" + else -> null + } + return DecodeResult.Success(decoded, warning) } } diff --git a/Android/app/src/main/java/com/dct/app/data/protocol/SlidingRingBufferParser.kt b/Android/app/src/main/java/com/dct/app/data/protocol/SlidingRingBufferParser.kt index ec112ac..74ce2fb 100644 --- a/Android/app/src/main/java/com/dct/app/data/protocol/SlidingRingBufferParser.kt +++ b/Android/app/src/main/java/com/dct/app/data/protocol/SlidingRingBufferParser.kt @@ -1,8 +1,7 @@ -package com.dct.app.data.protocol +package com.dct.app.data.protocol import com.dct.app.domain.model.protocol.DctCommand import com.dct.app.domain.model.protocol.DctPacket -import java.util.concurrent.ConcurrentLinkedQueue /** * TCP 스트림 및 시리얼 데이터 수신을 위한 고성능 슬라이딩 링 버퍼 기반 패킷 파서. @@ -24,16 +23,15 @@ class SlidingRingBufferParser( ) : PacketParser { companion object { - const val DEFAULT_BUFFER_CAPACITY: Int = 65536 // 64 KB - const val DEFAULT_MAX_PACKET_SIZE: Int = 8192 // 8 KB + const val DEFAULT_MAX_PACKET_SIZE: Int = DctPacketCodec.MAX_PAYLOAD_LENGTH + 6 + const val DEFAULT_BUFFER_CAPACITY: Int = DEFAULT_MAX_PACKET_SIZE * 2 } private val ringBuffer = ByteArray(bufferCapacity) + private val scratchFrameBuffer = ByteArray(maxPacketSize) private var head = 0 private var tail = 0 private var availableBytes = 0 - - private val packetQueue = ConcurrentLinkedQueue() private var sequenceCounter = 0 @Synchronized @@ -41,9 +39,20 @@ class SlidingRingBufferParser( val result = mutableListOf() if (length <= 0) return result - // 1. 링 버퍼에 수신 바이트 적재 - writeToRingBuffer(buffer, 0, length) + require(length <= buffer.size) { "Input length exceeds buffer size" } + // Drain between chunks: a coalesced read larger than the ring must not erase older frames. + var offset = 0 + while (offset < length) { + if (availableBytes == bufferCapacity) skipBytes(1) // Only an unrepresentable/noisy frame can fill it. + val count = minOf(length - offset, bufferCapacity - availableBytes) + writeToRingBuffer(buffer, offset, count) + offset += count + drainPackets(result) + } + return result + } + private fun drainPackets(result: MutableList) { // 2. Peek-based Sliding Window Scan & Backtracking Loop while (availableBytes > 0) { // 2.1 현재 버퍼 내에서 START_BYTE (0xA1) 헤더의 오프셋 탐색 @@ -84,9 +93,16 @@ class SlidingRingBufferParser( skipBytes(1) continue } + // STOP ACK has only an optional one-byte status, never bulk data. + // Without this bound repeated A1 noise looks like a valid 41 KB STOP + // frame after enabling the full UInt16 telemetry length range. + if (cmdByte == DctCommand.RES_STOP_TRANSMIT_DATA.code && packLen > 2) { + skipBytes(1) + continue + } // 2.3 길이 무결성 검증 (packLen >= 1, MIN_PACKET_LENGTH <= expectedTotalLength <= maxPacketSize) - if (packLen < 1 || expectedTotalLength < DctPacketCodec.MIN_PACKET_LENGTH || expectedTotalLength > maxPacketSize) { + if (packLen < 1 || expectedTotalLength < DctPacketCodec.MIN_PACKET_LENGTH || expectedTotalLength > minOf(maxPacketSize, bufferCapacity)) { // 잘못된 길이 정보이므로 현재 0xA1은 가짜 헤더(노이즈)임! // 1바이트만 스킵(백트래킹)하고 0xA1 다음 바이트부터 다시 재탐색 skipBytes(1) @@ -107,12 +123,11 @@ class SlidingRingBufferParser( continue } - // 2.6 프레임 바이트 추출 및 디코딩/체크섬 검증 - val frameBytes = ByteArray(expectedTotalLength) - peekBytes(frameBytes, 0, expectedTotalLength) + // 2.6 프레임 바이트 추출 및 디코딩/체크섬 검증 (재사용 가능한 스크래치 버퍼를 사용하여 힙 할당 방지) + peekBytes(scratchFrameBuffer, 0, expectedTotalLength) val decoded = codec.decode( - frame = frameBytes, + frame = scratchFrameBuffer, offset = 0, length = expectedTotalLength, sequence = sequenceCounter @@ -123,36 +138,28 @@ class SlidingRingBufferParser( skipBytes(expectedTotalLength) sequenceCounter++ result.add(decoded) - packetQueue.offer(decoded) } else { // 체크섬 검증 실패 등 -> 가짜 헤더로 판정하고 1바이트만 스킵하여 백트래킹 skipBytes(1) } } - return result } @Synchronized - override fun pollPacket(): DctPacket? { - return packetQueue.poll() - } + override fun pollPacket(): DctPacket? = null @Synchronized override fun reset() { head = 0 tail = 0 availableBytes = 0 - packetQueue.clear() sequenceCounter = 0 } val availableByteCount: Int @Synchronized get() = availableBytes - val queuedPacketCount: Int - get() = packetQueue.size - private fun writeToRingBuffer(data: ByteArray, offset: Int, length: Int) { if (length <= 0) return diff --git a/Android/app/src/main/java/com/dct/app/data/repository/DpcProjectManager.kt b/Android/app/src/main/java/com/dct/app/data/repository/DpcProjectManager.kt index 50be6a5..b4bd864 100644 --- a/Android/app/src/main/java/com/dct/app/data/repository/DpcProjectManager.kt +++ b/Android/app/src/main/java/com/dct/app/data/repository/DpcProjectManager.kt @@ -3,6 +3,8 @@ package com.dct.app.data.repository import android.content.Context import android.content.Intent import android.net.Uri +import com.dct.app.core.theme.PcColorRef +import com.dct.app.core.theme.toColorRef import com.dct.app.domain.model.project.DpcProject import org.json.JSONArray import org.json.JSONObject @@ -11,6 +13,7 @@ import java.io.File import java.io.FileOutputStream import java.io.InputStreamReader import java.io.OutputStreamWriter +import java.security.MessageDigest /** * PC 버전 (.dpc JSON) 프로젝트 파일 직렬화 / 역직렬화 및 SAF 영속성 매니저 @@ -39,6 +42,93 @@ object DpcProjectManager { } } + /** Property 편집은 확인 전 기존 캐시를 건드리지 않고, 같은 이름의 파일도 별도 버전으로 보관한다. */ + fun copyUriToVersionedStorage(context: Context, uri: Uri, targetFileName: String): File? { + return try { + val safeName = File(targetFileName).name + val directory = File(context.filesDir, "imported_files/${java.util.UUID.randomUUID()}") + if (!directory.mkdirs()) return null + val destination = File(directory, safeName) + val input = context.contentResolver.openInputStream(uri) ?: return null + input.use { source -> destination.outputStream().use { output -> source.copyTo(output) } } + destination + } catch (_: Exception) { + null + } + } + + /** Keep a private working copy, then publish a verified PC-relative source in DCT Work. */ + fun importSourceFile(context: Context, uri: Uri, fileName: String, folder: String): File? { + val cached = copyUriToVersionedStorage(context, uri, fileName) ?: return null + return publishSourceFile(context, cached, fileName, folder) + } + + /** Never overwrite a different source with the same name; the returned name goes into the DPC. */ + fun publishSourceFile(context: Context, source: File, fileName: String, folder: String): File? { + if (folder !in setOf("MDB", "DBC", "A2L") || !source.isFile) return null + val safeName = File(fileName).name + if (safeName.isBlank() || safeName == "." || safeName == "..") return null + val directory = File(getDefaultSubFolder(folder, context)) + fun digest(file: File): String { + val hash = MessageDigest.getInstance("SHA-256") + file.inputStream().use { input -> + val buffer = ByteArray(64 * 1024) + while (true) { + val count = input.read(buffer) + if (count < 0) break + hash.update(buffer, 0, count) + } + } + return hash.digest().joinToString("") { (it.toInt() and 0xFF).toString(16).padStart(2, '0') } + } + return try { + val sourceHash = digest(source) + var destination = File(directory, safeName) + if (destination.isFile && digest(destination) != sourceHash) { + val stem = safeName.substringBeforeLast('.', safeName) + val extension = safeName.substringAfterLast('.', "").let { if (it.isBlank()) "" else ".$it" } + destination = File(directory, "$stem-${sourceHash.take(12)}$extension") + } + if (destination.isFile) return destination.takeIf { digest(it) == sourceHash } + val temporary = File(directory, ".${destination.name}.${java.util.UUID.randomUUID()}.tmp") + try { + source.inputStream().use { input -> temporary.outputStream().use { output -> input.copyTo(output) } } + if (digest(temporary) != sourceHash || !temporary.renameTo(destination)) return null + destination + } finally { + if (temporary.exists()) temporary.delete() + } + } catch (_: Exception) { + null + } + } + + /** Non-destructive migration of older Android projects whose DPC points at empty public folders. */ + fun publishAvailableProjectSources(context: Context, project: DpcProject): DpcProject { + fun available(path: String?, name: String, folder: String): File? { + if (name.isBlank()) return null + return listOfNotNull(path?.let(::File), File(context.filesDir, "imported_files/$name"), + File(getDefaultSubFolder(folder, context), name)).firstOrNull { it.isFile } + } + var result = project + available(project.localMdbPath, project.mdbFileName, "MDB")?.let { source -> + publishSourceFile(context, source, project.mdbFileName, "MDB")?.let { published -> + result = result.copy(mdbFileName = published.name, localMdbPath = published.absolutePath) + } + } + available(project.localCanDbcPath, project.canDbcFileName, "DBC")?.let { source -> + publishSourceFile(context, source, project.canDbcFileName, "DBC")?.let { published -> + result = result.copy(canDbcFileName = published.name, localCanDbcPath = published.absolutePath) + } + } + available(project.localA2lPath, project.a2lFileName, "A2L")?.let { source -> + publishSourceFile(context, source, project.a2lFileName, "A2L")?.let { published -> + result = result.copy(a2lFileName = published.name, localA2lPath = published.absolutePath) + } + } + return result + } + /** * PC 버전(common_def.h)과 100% 호환되는 8대 표준 서브폴더 목록 */ @@ -150,7 +240,7 @@ object DpcProjectManager { JSONObject() } - root.put("name", project.projectFileName.ifEmpty { "PROJECT" }) + root.put("name", com.dct.app.data.logger.PcMeasurementNames.projectName(project.projectFileName)) if (!root.has("version")) root.put("version", "0.0.0.2") root.put("isave_logfile", if (project.isaveLogfile) 1 else 0) root.put("cartype", project.vehicleCode) @@ -164,7 +254,13 @@ object DpcProjectManager { root.put("copy_savedfolder", project.copySavedFolder.ifEmpty { if (effectiveBase.isNotBlank()) "$effectiveBase/Data" else "" }) val logFolder = project.measuredFilesFolder.ifEmpty { getDefaultMeasuredFolder() } - if (logFolder.isNotBlank()) { + if (project.measuredFolderUri != null) { + // The Android tree URI is not a PC filesystem path. PC keeps using Data. + root.put("logfile_path", "/Data") + root.put("logfile_path_use_basefolder", 1) + root.put("android_measured_folder_uri", project.measuredFolderUri.toString()) + } else if (logFolder.isNotBlank()) { + root.remove("android_measured_folder_uri") if (effectiveBase.isNotBlank() && logFolder.startsWith(effectiveBase, ignoreCase = true)) { val rel = logFolder.substring(effectiveBase.length) root.put("logfile_path", rel.ifEmpty { "/Data" }) @@ -204,30 +300,31 @@ object DpcProjectManager { hw.put("sm_count", usedSmCount) root.put("hw_device", hw) - // sm_property + // sensormodule_property: PC C++ EnvSetting.cpp 표준 스키마 준수 if (project.sensorModules.isNotEmpty()) { val smProp = JSONObject() - val smArr = JSONArray() + val propArr = JSONArray() for (sm in project.sensorModules) { val smObj = JSONObject() - smObj.put("index", sm.moduleIndex) - smObj.put("is_used", if (sm.isUsed) 1 else 0) - val chArr = JSONArray() + smObj.put("sm_index", sm.moduleIndex) + smObj.put("sm_use", if (sm.isUsed) 1 else 0) + val itemsArr = JSONArray() for (ch in sm.channels) { val chObj = JSONObject() - chObj.put("index", ch.channelIndex) chObj.put("name", ch.name) - chObj.put("is_voltage", if (ch.isVoltage) 1 else 0) - chObj.put("unit", ch.unit) - chObj.put("description", ch.description) - chObj.put("color", ch.color) - chArr.put(chObj) + chObj.put("channel_idx", ch.channelIndex) + chObj.put("Descrip", ch.description) + chObj.put("Color", ch.color.toColorRef()) + chObj.put("GRAPH", 1) + chObj.put("VOLTAGE", if (ch.isVoltage) 1 else 0) + itemsArr.put(chObj) } - smObj.put("channels", chArr) - smArr.put(smObj) + smObj.put("items", itemsArr) + propArr.put(smObj) } - smProp.put("modules", smArr) - root.put("sm_property", smProp) + smProp.put("property", propArr) + root.put("sensormodule_property", smProp) + root.remove("sm_property") } // ask_info: PC EnvSetting 키 이름과 형식을 그대로 유지한다. @@ -369,23 +466,32 @@ object DpcProjectManager { if (project.canSignals.isNotEmpty()) { for (sig in project.canSignals) { val it = JSONObject() + // PC EnvSetting indexes DBC settings by parser-stable + // signal index. Keep the index even when Android uses a + // message/name based runtime key. + it.put("Index", sig.idx) it.put("Name", sig.name) val col = project.canSignalColors[sig.name] ?: sig.color - it.put("USER_COLOR", col) + it.put("USER_COLOR", col.toColorRef()) val desc = project.canSignalDescriptions[sig.name] ?: sig.description it.put("Descrip", desc) + it.put("GRAPH", if (isCanSignalBoundToGraph(project, sig)) 1 else 0) it.put("item_thickness", 2) it.put("item_dashstyle", 0) + it.put("thickness", 2) + it.put("dashstyle", 0) itemsArr.put(it) } } else { for ((name, col) in project.canSignalColors) { val it = JSONObject() it.put("Name", name) - it.put("USER_COLOR", col) + it.put("USER_COLOR", col.toColorRef()) it.put("Descrip", project.canSignalDescriptions[name] ?: "") it.put("item_thickness", 2) it.put("item_dashstyle", 0) + it.put("thickness", 2) + it.put("dashstyle", 0) itemsArr.put(it) } } @@ -414,13 +520,23 @@ object DpcProjectManager { it.put("ATTR", sig.attr.code) it.put("MIN", sig.min) it.put("MAX", sig.max) + it.put("RECLID", sig.reclid) + it.put("UNIT", sig.unit) + it.put("COLOR", sig.color.toColorRef()) + it.put("ARRAYIDX", sig.arrayIndex) + it.put("ITEMLEN", sig.itemSize) + it.put("CVS_A", sig.cvsA) + it.put("CVS_B", sig.cvsB) + it.put("GRAPH", if (isMdbSignalBoundToGraph(project, sig)) 1 else 0) it.put("Value", 0) val col = project.mdbSignalColors[sig.name] ?: sig.color - it.put("USER_COLOR", col) + it.put("USER_COLOR", col.toColorRef()) val desc = project.mdbSignalDescriptions[sig.name] ?: sig.description it.put("Descrip", desc) it.put("item_thickness", 2) it.put("item_dashstyle", 0) + it.put("thickness", 2) + it.put("dashstyle", 0) sigItems.put(it) } } @@ -452,9 +568,14 @@ object DpcProjectManager { mdbRecvProp.put("property", propArr) root.put("mdb_recv_property", mdbRecvProp) - // MDB Send Property + // PC reads writable constants from mdb_send_property. Android + // keeps the same entries in the receive property for its table + // model, so mirror the constant group instead of emitting an + // empty property that makes constants disappear on PC load. val sendProp = JSONObject() - sendProp.put("property", JSONArray()) + sendProp.put("property", JSONArray().apply { + put(JSONObject(conGrp.toString())) + }) root.put("mdb_send_property", sendProp) // MDB Compensation A Property @@ -512,50 +633,66 @@ object DpcProjectManager { if (project.a2lSignals.isNotEmpty()) { for (sig in project.a2lSignals) { val it = JSONObject() + // PC EnvSetting uses the ECU address as the A2L graph + // identity; retain it even though Android also exposes + // the a2l_
runtime key. + it.put("address", sig.adl.toLong() and 0xFFFFFFFFL) it.put("Name", sig.name) val col = project.a2lSignalColors[sig.name] ?: sig.color - it.put("USER_COLOR", col) + it.put("USER_COLOR", col.toColorRef()) val desc = project.a2lSignalDescriptions[sig.name] ?: sig.description it.put("Descrip", desc) + it.put("IsArray", if (sig.name.contains('[')) 1 else 0) + it.put("GRAPH", if (isA2lSignalBoundToGraph(project, sig)) 1 else 0) it.put("item_thickness", 2) it.put("item_dashstyle", 0) + it.put("thickness", 2) + it.put("dashstyle", 0) itemsArr.put(it) } } else { for ((name, col) in project.a2lSignalColors) { val it = JSONObject() it.put("Name", name) - it.put("USER_COLOR", col) + it.put("USER_COLOR", col.toColorRef()) it.put("Descrip", project.a2lSignalDescriptions[name] ?: "") it.put("item_thickness", 2) it.put("item_dashstyle", 0) + it.put("thickness", 2) + it.put("dashstyle", 0) itemsArr.put(it) } } measObj.put("items", itemsArr) a2lProp.put("measurement", measObj) - val charObj = JSONObject() - charObj.put("items", JSONArray()) + // Characteristics are not represented by SignalDefinition. If a + // PC project already had them, retain the original records rather + // than replacing them with an empty list during an Android save. + val charObj = root.optJSONObject("a2l_property")?.optJSONObject("characteristic") + ?.let { JSONObject(it.toString()) } ?: JSONObject() + if (!charObj.has("items")) charObj.put("items", JSONArray()) a2lProp.put("characteristic", charObj) root.put("a2l_property", a2lProp) } - // Formula Property + // formula_property: PC C++ EnvSetting.cpp 표준 스키마 준수 if (project.formulas.isNotEmpty()) { val formulaRoot = JSONObject() val formulaArr = JSONArray() - for (f in project.formulas) { + for ((idx, f) in project.formulas.withIndex()) { val it = JSONObject() - it.put("Name", f.name) - it.put("Expression", f.expression) + it.put("name", f.name) + it.put("expression", f.expression) + it.put("forSM", f.isForSm) + it.put("index", idx) it.put("Unit", f.unit) - it.put("Color", f.color) - it.put("IsForSM", if (f.isForSm) 1 else 0) + it.put("Color", f.color.toColorRef()) it.put("Descrip", f.description) formulaArr.put(it) } formulaRoot.put("items", formulaArr) - root.put("formula_info", formulaRoot) + root.put("formula_property", formulaRoot) + root.remove("formula_info") } root.put("android_parameter_descriptions", JSONObject(project.parameterDescriptions)) @@ -564,6 +701,10 @@ object DpcProjectManager { if ((root.optJSONObject(DpcWorkspaceJson.KEY)?.optInt("schema_version", 1) ?: 1) <= DpcWorkspaceJson.VERSION) { root.put(DpcWorkspaceJson.KEY, DpcWorkspaceJson.encode(workspace)) } + // PC stores graph windows separately from the Android workspace. + // Emit both representations so a project saved on Android opens + // with its graph items in the original PC application as well. + PcDpcCompatibility.encodeGraphProperties(root, workspace, project) } return root.toString(3) } @@ -576,7 +717,8 @@ object DpcProjectManager { val oldCanMode = root.optInt("oldcanmode", if (root.optBoolean("oldcanmode", false)) 1 else 0) == 1 val writeCsv = root.optInt("writecsv", if (root.optBoolean("writecsv", false)) 1 else 0) == 1 val writeMf4 = root.optInt("writemf4", if (root.optBoolean("writemf4", false)) 1 else 0) == 1 - val logfilePath = root.optString("logfile_path", "") + val measuredUri = root.optString("android_measured_folder_uri", "").takeIf { it.isNotBlank() }?.let(Uri::parse) + val logfilePath = measuredUri?.path ?: root.optString("logfile_path", "") val baseFolder = root.optString("base_folder", "") val copySavedFolder = root.optString("copy_savedfolder", "") val maker = root.optInt("maker", 2) @@ -645,7 +787,7 @@ object DpcProjectManager { val desc = it.optString("Descrip", "") if (sigName.isNotBlank()) { if (userColor >= 0L) { - canSignalColors[sigName] = userColor + canSignalColors[sigName] = userColor.toColorRef() } if (desc.isNotBlank()) { canSignalDescriptions[sigName] = desc @@ -673,7 +815,7 @@ object DpcProjectManager { val desc = it.optString("Descrip", "") if (sigName.isNotBlank()) { if (userColor >= 0L) { - mdbSignalColors[sigName] = userColor + mdbSignalColors[sigName] = userColor.toColorRef() } if (desc.isNotBlank()) { mdbSignalDescriptions[sigName] = desc @@ -700,7 +842,7 @@ object DpcProjectManager { val desc = it.optString("Descrip", "") if (sigName.isNotBlank()) { if (userColor >= 0L) { - a2lSignalColors[sigName] = userColor + a2lSignalColors[sigName] = userColor.toColorRef() } if (desc.isNotBlank()) { a2lSignalDescriptions[sigName] = desc @@ -740,11 +882,43 @@ object DpcProjectManager { com.dct.app.domain.model.project.DpcAskInfo() } - // SM_PROPERTY 역직렬화 + // Sensor Module 역직렬화 (PC sensormodule_property 및 안드로이드 sm_property 양방향 호환) val smList = mutableListOf() - val smProp = root.optJSONObject("sm_property") - if (smProp != null) { - val smArr = smProp.optJSONArray("modules") + val pcSmProp = root.optJSONObject("sensormodule_property") + val legacySmProp = root.optJSONObject("sm_property") + + if (pcSmProp != null && pcSmProp.optJSONArray("property") != null) { + val propArr = pcSmProp.optJSONArray("property")!! + for (i in 0 until propArr.length()) { + val smItem = propArr.optJSONObject(i) ?: continue + val mIdx = smItem.optInt("sm_index", i) + val isUsed = smItem.optInt("sm_use", if (smItem.optBoolean("sm_use", false)) 1 else 0) == 1 + val chList = mutableListOf() + val itemsArr = smItem.optJSONArray("items") + if (itemsArr != null) { + for (c in 0 until itemsArr.length()) { + val chItem = itemsArr.optJSONObject(c) ?: continue + val cIdx = chItem.optInt("channel_idx", c) + val defaultName = "${String.format(java.util.Locale.US, "%02d", mIdx + 1)}-${String.format(java.util.Locale.US, "%02d", cIdx + 1)} ch" + val cName = chItem.optString("name", defaultName) + val isVolt = if (chItem.has("VOLTAGE")) { + chItem.optInt("VOLTAGE", if (chItem.optBoolean("VOLTAGE", true)) 1 else 0) == 1 + } else { + chItem.optInt("is_voltage", if (chItem.optBoolean("is_voltage", true)) 1 else 0) == 1 + } + val unit = chItem.optString("unit", if (isVolt) "V" else "'C") + val desc = chItem.optString("Descrip", chItem.optString("description", "")) + val color = chItem.optLong("Color", chItem.optLong("color", PcColorRef.DEFAULT_SENSOR)).toColorRef() + chList.add(com.dct.app.domain.model.project.DpcSensorModuleChannel(cIdx, cName, isVolt, unit, desc, color)) + } + } + if (chList.isEmpty()) { + chList.addAll(com.dct.app.domain.model.project.createDefaultSmChannels(mIdx)) + } + smList.add(com.dct.app.domain.model.project.DpcSensorModuleConfig(mIdx, isUsed, chList)) + } + } else if (legacySmProp != null) { + val smArr = legacySmProp.optJSONArray("modules") if (smArr != null) { for (i in 0 until smArr.length()) { val smItem = smArr.optJSONObject(i) ?: continue @@ -760,7 +934,7 @@ object DpcProjectManager { val isVolt = chItem.optInt("is_voltage", if (chItem.optBoolean("is_voltage", false)) 1 else 0) == 1 val unit = chItem.optString("unit", if (isVolt) "V" else "'C") val desc = chItem.optString("description", "") - val color = chItem.optLong("color", 0xFF009A4CL) + val color = chItem.optLong("color", PcColorRef.DEFAULT_SENSOR).toColorRef() chList.add(com.dct.app.domain.model.project.DpcSensorModuleChannel(cIdx, cName, isVolt, unit, desc, color)) } } @@ -775,25 +949,33 @@ object DpcProjectManager { smList.addAll(com.dct.app.domain.model.project.createDefaultSensorModules()) } - // Formula Parsing + // Formula Parsing (PC formula_property 및 안드로이드 formula_info 양방향 호환) val formulaList = mutableListOf() - if (root.has("formula_info")) { - val fInfo = root.optJSONObject("formula_info") - val fItems = fInfo?.optJSONArray("items") + val fInfo = root.optJSONObject("formula_property") ?: root.optJSONObject("formula_info") + if (fInfo != null) { + val fItems = fInfo.optJSONArray("items") if (fItems != null) { for (i in 0 until fItems.length()) { val it = fItems.optJSONObject(i) ?: continue - val fName = it.optString("Name", "") - val fExpr = it.optString("Expression", "") + val fName = it.optString("name", it.optString("Name", "")) + val fExpr = it.optString("expression", it.optString("Expression", "")) if (fName.isNotBlank() && fExpr.isNotBlank()) { + val forSm = if (it.has("forSM")) { + it.optBoolean("forSM", false) + } else { + it.optInt("IsForSM", if (it.optBoolean("IsForSM", false)) 1 else 0) == 1 + } + val unit = it.optString("Unit", it.optString("unit", "")) + val color = it.optLong("Color", it.optLong("color", PcColorRef.DEFAULT_SIGNAL)).toColorRef() + val desc = it.optString("Descrip", it.optString("descrip", it.optString("description", ""))) formulaList.add( com.dct.app.domain.model.formula.FormulaDefinition( name = fName, expression = fExpr, - unit = it.optString("Unit", ""), - color = it.optLong("Color", 0xFF2196F3), - isForSm = it.optInt("IsForSM", 0) == 1, - description = it.optString("Descrip", "") + unit = unit, + color = color, + isForSm = forSm, + description = desc ) ) } @@ -801,6 +983,13 @@ object DpcProjectManager { } } + // Prefer the lossless Android extension when present. Older PC-only + // projects have no such key, so synthesize an equivalent page from + // graphWnd_property/multiXgraphWnd_property instead of dropping the + // user's graph windows on import. + val workspace = DpcWorkspaceJson.decode(root.optJSONObject(DpcWorkspaceJson.KEY)) + ?: PcDpcCompatibility.decodeWorkspace(root) + return DpcProject( vehicleCode = carType, projectFileName = name, @@ -815,6 +1004,7 @@ object DpcProjectManager { canDbcFileUri = canDbcUri, localCanDbcPath = localCanDbcPath, measuredFilesFolder = logfilePath, + measuredFolderUri = measuredUri, writeCsv = writeCsv, writeMf4 = writeMf4, canMode = canMode, @@ -835,7 +1025,7 @@ object DpcProjectManager { a2lSignalDescriptions = a2lSignalDescriptions, formulas = formulaList, rawJsonRoot = root, - workspace = DpcWorkspaceJson.decode(root.optJSONObject(DpcWorkspaceJson.KEY)), + workspace = workspace, parameterDescriptions = root.optJSONObject("android_parameter_descriptions")?.let { obj -> obj.keys().asSequence().take(4096).filter { obj.opt(it) is String }.associateWith { obj.getString(it) } } ?: emptyMap(), @@ -885,6 +1075,7 @@ object DpcProjectManager { // 3. 외부 SAF Uri가 지정되어 있는 경우 동기화 내보내기 val uri = project.projectFileUri + var isUriSaved = uri == null if (uri != null) { try { val flags = Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION @@ -896,13 +1087,14 @@ object DpcProjectManager { OutputStreamWriter(os, Charsets.UTF_8).use { writer -> writer.write(json) } + isUriSaved = true } } catch (e: Exception) { e.printStackTrace() } } - return isLocalSaved + return isLocalSaved && isUriSaved } fun loadProject(context: Context, uri: Uri): DpcProject? { @@ -1038,4 +1230,44 @@ object DpcProjectManager { } return false } + + /** Returns whether a source definition is present in a saved graph window. */ + private fun graphBindings(project: DpcProject): List = + project.workspace?.pages.orEmpty().flatMap { page -> + page.signals + page.windows.flatMap { it.signals } + } + + private fun isMdbSignalBoundToGraph( + project: DpcProject, + signal: com.dct.app.domain.model.mdb.SignalDefinition + ): Boolean { + val bindings = graphBindings(project) + return bindings.any { binding -> + binding.signalId.equals("sig_${signal.reclid}_${signal.idx}", ignoreCase = true) || + binding.signalName.equals(signal.name, ignoreCase = true) + } + } + + private fun isCanSignalBoundToGraph( + project: DpcProject, + signal: com.dct.app.domain.model.mdb.SignalDefinition + ): Boolean { + val bindings = graphBindings(project) + return bindings.any { binding -> + com.dct.app.domain.model.mdb.CanSignalKey.matches(binding.signalId, signal) || + binding.signalName.equals(signal.name, ignoreCase = true) + } + } + + private fun isA2lSignalBoundToGraph( + project: DpcProject, + signal: com.dct.app.domain.model.mdb.SignalDefinition + ): Boolean { + val bindings = graphBindings(project) + return bindings.any { binding -> + binding.signalId.equals("a2l_${signal.adl}", ignoreCase = true) || + binding.signalName.equals(signal.name, ignoreCase = true) + } + } + } diff --git a/Android/app/src/main/java/com/dct/app/data/repository/DpcWorkspaceJson.kt b/Android/app/src/main/java/com/dct/app/data/repository/DpcWorkspaceJson.kt index 675eb47..c62828d 100644 --- a/Android/app/src/main/java/com/dct/app/data/repository/DpcWorkspaceJson.kt +++ b/Android/app/src/main/java/com/dct/app/data/repository/DpcWorkspaceJson.kt @@ -1,11 +1,13 @@ package com.dct.app.data.repository +import com.dct.app.core.theme.PcColorRef +import com.dct.app.core.theme.toColorRef import com.dct.app.domain.model.chart.* import com.dct.app.domain.model.project.* import org.json.JSONArray import org.json.JSONObject -/** Android extension only; PC's opaque MFC layout is never rewritten here. */ +/** Android workspace extension; PC graph/tile keys are mirrored by PcDpcCompatibility. */ object DpcWorkspaceJson { const val KEY = "android_workspace" const val VERSION = 1 @@ -59,7 +61,10 @@ object DpcWorkspaceJson { .put("target_table_name", w.targetTableName).put("target_parameter_name", w.targetParameterName) .put("auto_scroll", w.autoScroll).put("signals", array(w.signals.map(::encodeSignal))) .put("target_signals", array(w.targetSignals)).put("tile_mode", w.isTileMode) - .put("hidden_signal_ids", array(w.hiddenSignalIds)).put("highlight_colors", JSONObject(w.highlightColors)) + .put("hidden_signal_ids", array(w.hiddenSignalIds)) + .put("highlight_colors", JSONObject().apply { + w.highlightColors.forEach { (id, color) -> put(id, color.toColorRef()) } + }) .put("content_state", JSONObject(w.contentState)) .apply { w.chart?.let { put("chart", encodeChart(it)) } } @@ -80,7 +85,8 @@ object DpcWorkspaceJson { autoScroll = w.optBoolean("auto_scroll", true), signals = objects(w.optJSONArray("signals")).mapNotNull(::decodeSignal), targetSignals = strings(w.optJSONArray("target_signals")), isTileMode = w.optBoolean("tile_mode", true), hiddenSignalIds = strings(w.optJSONArray("hidden_signal_ids")).toSet(), - highlightColors = highlights?.keys()?.asSequence()?.take(4096)?.associateWith { highlights.optLong(it) } ?: emptyMap(), + highlightColors = highlights?.keys()?.asSequence()?.take(4096) + ?.associateWith { highlights.optLong(it).toColorRef() } ?: emptyMap(), contentState = w.optJSONObject("content_state")?.let { obj -> obj.keys().asSequence().take(256).filter { obj.opt(it) is String } .associateWith { obj.getString(it).take(32768) } @@ -91,14 +97,14 @@ object DpcWorkspaceJson { private fun encodeSignal(s: DpcSignalBindingState): JSONObject = JSONObject() .put("signal_id", s.signalId).put("signal_name", s.signalName).put("axis", s.axisPosition.name) - .put("color", s.color).put("unit", s.unit).put("line_width", s.lineWidthDp) + .put("color", s.color.toColorRef()).put("unit", s.unit).put("line_width", s.lineWidthDp) .put("dash_style", s.dashStyle).put("visible", s.isVisible).put("interpolation", s.interpolationMode.name) private fun decodeSignal(s: JSONObject): DpcSignalBindingState? { val id = s.optString("signal_id").takeIf { it.isNotBlank() } ?: return null return DpcSignalBindingState( id, s.optString("signal_name", id), enum(s.optString("axis"), AxisPosition.LEFT_0), - s.optLong("color", 0xFF2196F3L), s.optString("unit"), + s.optLong("color", PcColorRef.DEFAULT_SIGNAL).toColorRef(), s.optString("unit"), s.number("line_width", 2.0, 1.0, 5.0).toFloat(), s.optInt("dash_style", 0).coerceIn(0, 4), s.optBoolean("visible", true), enum(s.optString("interpolation"), InterpolationMode.LINEAR) ) @@ -108,9 +114,13 @@ object DpcWorkspaceJson { .put("time_window_sec", c.timeWindowSec).put("time_window_preset", c.timeWindowPreset.name) .put("auto_time_window", c.isAutoTimeWindow).put("x_axis_auto", c.isXAxisAuto) .put("y_axis_auto", c.isYAxisAuto).put("legend_position", c.legendPosition) + .apply { + c.xAxisMinSec?.takeIf { it.isFinite() }?.let { put("x_axis_min_sec", it) } + c.xAxisMaxSec?.takeIf { it.isFinite() }?.let { put("x_axis_max_sec", it) } + } .put("axes", array(c.axes.map { a -> - JSONObject().put("position", a.position.name).put("min", a.minVal).put("max", a.maxVal) - .put("auto", a.isAuto).put("color", a.color).put("unit", a.unit).put("label", a.label) + JSONObject().put("position", a.position.name).put("min", a.minVal).put("max", a.maxVal) + .put("auto", a.isAuto).put("color", a.color.toColorRef()).put("unit", a.unit).put("label", a.label) .put("tick_count", a.tickCount).put("offset_dp", a.offsetDp).put("visible", a.isVisible) })) @@ -119,14 +129,16 @@ object DpcWorkspaceJson { val position = AxisPosition.entries.find { it.name == a.optString("position") } ?: return@mapNotNull null val min = a.number("min", -50.0, -1e300, 1e300) val max = a.number("max", 100.0, -1e300, 1e300).coerceAtLeast(min) - ChartAxisConfig(position, min, max, a.optBoolean("auto", true), a.optLong("color", 0xFF172B4DL), + ChartAxisConfig(position, min, max, a.optBoolean("auto", true), a.optLong("color", PcColorRef.DEFAULT_AXIS).toColorRef(), a.optString("unit"), a.optString("label"), a.optInt("tick_count", 5).coerceIn(2, 100), a.number("offset_dp", 0.0, 0.0, 1000.0).toFloat(), a.optBoolean("visible", true)) }.distinctBy { it.position }, timeWindowSec = c.number("time_window_sec", 30.0, 0.001, 86400.0), timeWindowPreset = enum(c.optString("time_window_preset"), TimeWindowPreset.SEC_30), isAutoTimeWindow = c.optBoolean("auto_time_window", false), isXAxisAuto = c.optBoolean("x_axis_auto", true), - isYAxisAuto = c.optBoolean("y_axis_auto", true), legendPosition = c.optInt("legend_position", 0).coerceIn(0, 3) + isYAxisAuto = c.optBoolean("y_axis_auto", true), legendPosition = c.optInt("legend_position", 0).coerceIn(0, 3), + xAxisMinSec = c.optDouble("x_axis_min_sec", Double.NaN).takeIf { it.isFinite() }, + xAxisMaxSec = c.optDouble("x_axis_max_sec", Double.NaN).takeIf { it.isFinite() } ) private fun array(items: Iterable<*>): JSONArray = JSONArray().apply { items.forEach { put(it) } } diff --git a/Android/app/src/main/java/com/dct/app/data/repository/PacketRepositoryImpl.kt b/Android/app/src/main/java/com/dct/app/data/repository/PacketRepositoryImpl.kt index fc4a062..d00a0d2 100644 --- a/Android/app/src/main/java/com/dct/app/data/repository/PacketRepositoryImpl.kt +++ b/Android/app/src/main/java/com/dct/app/data/repository/PacketRepositoryImpl.kt @@ -2,9 +2,11 @@ package com.dct.app.data.repository import com.dct.app.core.common.DctResult import com.dct.app.core.common.PacketDistributor +import com.dct.app.core.common.PacketPipelineMetrics import com.dct.app.core.error.DctError import com.dct.app.data.communication.WifiSocketManager import com.dct.app.data.protocol.DctProtocolRequests +import com.dct.app.data.protocol.DctStreamPayloadParser import com.dct.app.domain.model.a2l.A2lDataType import com.dct.app.domain.model.mdb.SignalDefinition import com.dct.app.domain.model.protocol.DctCommand @@ -18,7 +20,10 @@ import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineStart import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.isActive +import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.StateFlow @@ -36,12 +41,31 @@ class PacketRepositoryImpl( val wifiSocketManager: WifiSocketManager? = null, private val packetDistributor: PacketDistributor = PacketDistributor(), private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO, - private val responseTimeoutMs: Long = DEFAULT_RESPONSE_TIMEOUT_MS + private val responseTimeoutMs: Long = DEFAULT_RESPONSE_TIMEOUT_MS, + private val dispatchQueueCapacity: Int = DEFAULT_DISPATCH_QUEUE_CAPACITY, + val metrics: PacketPipelineMetrics = PacketPipelineMetrics(), + private val heartbeatIntervalMs: Long = DEFAULT_HEARTBEAT_INTERVAL_MS, + private val heartbeatTimeoutMs: Long = DEFAULT_HEARTBEAT_TIMEOUT_MS, + private val heartbeatFailureThreshold: Int = DEFAULT_HEARTBEAT_FAILURE_THRESHOLD, + private val enableHeartbeat: Boolean = true, + private val heartbeatDispatcher: CoroutineDispatcher = Dispatchers.IO ) : PacketRepository { private val scope = CoroutineScope(ioDispatcher + SupervisorJob()) private val requestMutex = Mutex() private val pendingResponses = ConcurrentHashMap>() + /** Ambiguous response codes are blocked for this session until the late reply is drained. */ + private val responseQuarantineSession = ConcurrentHashMap() + private var heartbeatJob: Job? = null + private val inboundPacketGeneration = java.util.concurrent.atomic.AtomicLong(0L) + /** + * Keeps TCP response matching ahead of slow graph/log subscribers without + * allowing an unbounded heap backlog. A full queue applies backpressure to + * the reader; already received packets are never silently discarded. + */ + private val packetDispatchQueue = Channel(dispatchQueueCapacity.coerceAtLeast(1)) private var xcpIsMsbFirst = false + @Volatile + private var pendingInitialConfiguration: DctMeasurementConfiguration? = null private val _isConnected = MutableStateFlow(false) override val isConnected: StateFlow = _isConnected.asStateFlow() @@ -54,9 +78,29 @@ class PacketRepositoryImpl( override val packetStream: SharedFlow = packetDistributor.packetStream + override fun setInitialMeasurementConfiguration(configuration: DctMeasurementConfiguration) { + pendingInitialConfiguration = configuration + } + init { wifiSocketManager?.let { manager -> scope.launch(start = CoroutineStart.UNDISPATCHED) { collectTransportPackets(manager.packetStream) } + scope.launch(start = CoroutineStart.UNDISPATCHED) { + for (packet in packetDispatchQueue) { + metrics.onDispatchDequeued() + try { + packetDistributor.dispatchPacket(packet) + } catch (e: CancellationException) { + throw e + } catch (e: Throwable) { + android.util.Log.e( + "DctPacketRepository", + "패킷 배포 실패 (다음 패킷 계속): session=${packet.transportSessionId}, command=${DctCommand.toHexString(packet.command)}", + e + ) + } + } + } scope.launch(start = CoroutineStart.UNDISPATCHED) { manager.isConnected.drop(1).collect { transportConnected -> if (!transportConnected && _connectionState.value !in setOf( @@ -74,6 +118,10 @@ class PacketRepositoryImpl( } override suspend fun connect(target: String): DctResult = withContext(ioDispatcher) { + // Consume the snapshot once. MainViewModel supplies it immediately before + // each Wi-Fi callback, so a failed/stale session cannot leak its settings + // into a later reconnect. + val initialConfiguration = pendingInitialConfiguration.also { pendingInitialConfiguration = null } transition(DeviceConnectionState.CONNECTING) _isStreaming.value = false if (!hasLiveTransport()) { @@ -82,33 +130,121 @@ class PacketRepositoryImpl( } transition(DeviceConnectionState.HANDSHAKING) - val handshake = listOf( - DctProtocolRequests.enable(), - DctProtocolRequests.version(), - DctProtocolRequests.hardwareId(), - DctProtocolRequests.syncTime() - ) - for (request in handshake) { - val result = sendCommand(request) - if (result !is DctResult.Success || result.data == null) { + + // 1. SW -> HW: 0x00 (COMM_ENABLE) + val enableRes = sendCommand(DctProtocolRequests.enable()) + if (enableRes !is DctResult.Success || enableRes.data == null) { + transition(DeviceConnectionState.ERROR) + return@withContext if (enableRes is DctResult.Failure) enableRes else failure("Handshake response missing for 0x00") + } + try { + com.dct.app.data.protocol.DctResponseValidator.validateSetup(DctProtocolRequests.enable(), enableRes.data) + } catch (e: IllegalArgumentException) { + transition(DeviceConnectionState.ERROR) + return@withContext failure("연결 초기화 0x00: ${e.message}") + } + + // 2. SW -> HW: 0x01 (VERSION) 요청 발송 - 0x81 응답은 파란색 박스 교환 이후 도착하므로 deferred 선등록 + if (responseQuarantineSession[DctCommand.RES_VERSION.code] == wifiSocketManager?.currentSessionId) { + transition(DeviceConnectionState.ERROR) + return@withContext failure("이전 버전 응답이 아직 대기 중입니다. 연결은 유지됩니다.") + } + val versionDeferred = CompletableDeferred() + pendingResponses[DctCommand.RES_VERSION.code] = versionDeferred + val versionReq = DctProtocolRequests.version() + val versionSessionCommand = versionReq.copy(transportSessionId = wifiSocketManager?.currentSessionId ?: 0) + try { + if (!sendLive(versionSessionCommand)) { + pendingResponses.remove(DctCommand.RES_VERSION.code, versionDeferred) transition(DeviceConnectionState.ERROR) - return@withContext if (result is DctResult.Failure) result else failure( - "Handshake response missing for ${DctCommand.toHexString(request.command)}" - ) + return@withContext failure("버전 요청(0x01) 송신에 실패했습니다.") + } + + // 3. 파란색 박스 인터리빙 시퀀스. 0x09/0x12는 payload 없이 + // 보낼 수 있는 조회가 아니므로, 프로젝트 스냅샷이 있을 때만 + // PC의 완전한 CAN 모드/SM 설정 payload를 추가합니다. + val envQueries = buildList { + add(DctProtocolRequests.dctEnvQuery(0x00.toByte())) + add(DctProtocolRequests.dctEnvQuery(0x03.toByte())) + initialConfiguration?.let { config -> + add(DctProtocolRequests.canMode(config.isHighSpeedCan, config.isOldModel)) + if (config.sensorModules.isNotEmpty()) { + add(DctProtocolRequests.sensorModuleInfo(config.sensorModules)) + } + } + } + for (query in envQueries) { + val res = sendCommand(query) + if (res !is DctResult.Success || res.data == null) { + transition(DeviceConnectionState.ERROR) + return@withContext if (res is DctResult.Failure) res else failure( + "Handshake response missing for ${DctCommand.toHexString(query.command)}" + ) + } + try { + com.dct.app.data.protocol.DctResponseValidator.validateSetup(query, res.data) + } catch (e: IllegalArgumentException) { + // A malformed/NACK setup response must fail this handshake, but + // it is not a transport error. Keep the TCP/Wi-Fi session alive so + // a caller can inspect logs or retry without a second P2P pairing. + transition(DeviceConnectionState.ERROR) + return@withContext failure("연결 초기화 ${DctCommand.toHexString(query.command)}: ${e.message}") + } + } + + // 4. 0x81 (VERSION) 응답 도착 확인 및 유효성 검증 + val versionResult = try { + withTimeout(responseTimeoutMs) { + versionDeferred.await() + } + } catch (e: Exception) { + if (e is CancellationException && e !is kotlinx.coroutines.TimeoutCancellationException) throw e + pendingResponses.remove(DctCommand.RES_VERSION.code, versionDeferred) + transition(DeviceConnectionState.ERROR) + return@withContext failure("버전 응답(0x81) 시간 초과: ${e.message}") + } finally { + pendingResponses.remove(DctCommand.RES_VERSION.code, versionDeferred) } try { - com.dct.app.data.protocol.DctResponseValidator.validateSetup(request, result.data) + com.dct.app.data.protocol.DctResponseValidator.validateSetup(versionReq, versionResult) } catch (e: IllegalArgumentException) { transition(DeviceConnectionState.ERROR) - return@withContext failure("연결 초기화 ${DctCommand.toHexString(request.command)}: ${e.message}") + return@withContext failure("연결 초기화 0x01: ${e.message}") } - } - transition(DeviceConnectionState.READY) - DctResult.Success(Unit) + // 5. 후속 핸드셰이크: 0x03 (HW MAC ID), 0x04 (Time Sync) + val postHandshake = listOf( + DctProtocolRequests.hardwareId(), + DctProtocolRequests.syncTime() + ) + for (request in postHandshake) { + val result = sendCommand(request) + if (result !is DctResult.Success || result.data == null) { + transition(DeviceConnectionState.ERROR) + return@withContext if (result is DctResult.Failure) result else failure( + "Handshake response missing for ${DctCommand.toHexString(request.command)}" + ) + } + try { + com.dct.app.data.protocol.DctResponseValidator.validateSetup(request, result.data) + } catch (e: IllegalArgumentException) { + transition(DeviceConnectionState.ERROR) + return@withContext failure("연결 초기화 ${DctCommand.toHexString(request.command)}: ${e.message}") + } + } + + transition(DeviceConnectionState.READY) + DctResult.Success(Unit) + } finally { + if (!versionDeferred.isCompleted) { + responseQuarantineSession[DctCommand.RES_VERSION.code] = versionSessionCommand.transportSessionId + } + pendingResponses.remove(DctCommand.RES_VERSION.code, versionDeferred) + } } override suspend fun disconnect(): DctResult = withContext(ioDispatcher) { + stopHeartbeatLoop() try { if (_isStreaming.value) stopStreaming() if (hasLiveTransport()) sendCommand(DctProtocolRequests.disable()) @@ -122,38 +258,61 @@ class PacketRepositoryImpl( } override suspend fun sendCommand(command: DctPacket): DctResult = withContext(ioDispatcher) { + sendCommandWithTimeout(command, responseTimeoutMs) + } + + /** + * Wire protocol에는 요청 sequence가 없으므로 같은 응답 코드의 늦은 패킷을 + * 재시도 응답과 구분할 수 없다. 오래 걸릴 수 있는 제어 명령은 처음부터 + * 충분히 기다려 정상 응답을 격리 상태로 만들지 않는다. + */ + private suspend fun sendCommandWithTimeout( + command: DctPacket, + timeoutMs: Long + ): DctResult = withContext(ioDispatcher) { val expectedResponse = expectedResponse(command.command) + val sessionCommand = command.copy(transportSessionId = wifiSocketManager?.currentSessionId ?: 0) if (!hasLiveTransport()) return@withContext failure("전송 가능한 실장비 연결이 없습니다.") if (expectedResponse == null) { - return@withContext if (sendLive(command)) DctResult.Success(null) + return@withContext if (sendLive(sessionCommand)) DctResult.Success(null) else failure("명령 송신 실패: ${DctCommand.toHexString(command.command)}") } requestMutex.withLock { + val quarantinedSession = responseQuarantineSession[expectedResponse] + if (quarantinedSession == sessionCommand.transportSessionId) { + return@withLock failure("이전 ${DctCommand.toHexString(command.command)} 응답 지연을 정리하는 중입니다. 지연 응답 수신 후 재시도하거나 연결을 명시적으로 다시 설정하세요.") + } val deferred = CompletableDeferred() pendingResponses[expectedResponse] = deferred try { - if (!sendLive(command)) { - return@withLock failure("명령 송신 실패: ${DctCommand.toHexString(command.command)}") + withTimeout(timeoutMs) { + if (!sendLive(sessionCommand)) { + // A timed-out writer may already have started the frame. + responseQuarantineSession[expectedResponse] = sessionCommand.transportSessionId + failure("명령 송신 실패: ${DctCommand.toHexString(command.command)}") + } else { + DctResult.Success(deferred.await()) + } } - DctResult.Success(withTimeout(responseTimeoutMs) { deferred.await() }) } catch (e: kotlinx.coroutines.TimeoutCancellationException) { - // The protocol has no transaction ID. A late ACK must not satisfy the next write. - transition(DeviceConnectionState.ERROR) - _isStreaming.value = false - wifiSocketManager?.disconnectSuspend() - failure( - "응답 시간 초과: ${DctCommand.toHexString(command.command)} -> ${DctCommand.toHexString(expectedResponse)}. 장비 반영 여부 확인 후 다시 연결하세요." + responseQuarantineSession[expectedResponse] = sessionCommand.transportSessionId + android.util.Log.w("DctPacketRepository", + "Command response timeout; TCP session kept alive: session=${sessionCommand.transportSessionId}, command=${DctCommand.toHexString(command.command)}", e) + DctResult.Failure( + DctError.ProtocolError.ResponseTimeout( + DctCommand.toHexString(command.command), + DctCommand.toHexString(expectedResponse) + ) ) } catch (e: CancellationException) { - // Once sent, cancellation also leaves an ambiguous transaction on this byte stream. - transition(DeviceConnectionState.ERROR) - _isStreaming.value = false - withContext(kotlinx.coroutines.NonCancellable) { wifiSocketManager?.disconnectSuspend() } + responseQuarantineSession[expectedResponse] = sessionCommand.transportSessionId + android.util.Log.w("DctPacketRepository", "Command cancelled; TCP session kept alive", e) throw e } catch (e: Exception) { + responseQuarantineSession[expectedResponse] = sessionCommand.transportSessionId DctResult.Failure(DctError.Unknown(e.message ?: "Command execution error"), e) } finally { pendingResponses.remove(expectedResponse, deferred) @@ -174,24 +333,56 @@ class PacketRepositoryImpl( throw IllegalArgumentException("$step: ${e.message}", e) } buildList { + // 1. 상단 공통: 차종 코드 (0x06) if (configuration.vehicleCode.isNotBlank()) { add(packet("차종 설정") { DctProtocolRequests.carType(configuration.vehicleCode, configuration.maker) }) } + // 2. 상단 공통: 프로젝트 DB 코드 (0x08 0x00) + if (configuration.projectName.isNotBlank()) { + add(packet("프로젝트 DB 설정") { + DctProtocolRequests.dbInfo(configuration.projectName) + }) + } + + // 3. 조건부 배타 분기 (MDB vs A2L) + if (configuration.mdbSignals.isNotEmpty()) { + // [MDB 분기]: 0x10 0x00 (MDB Info) -> 0x13 (Selected IDs) + add(packet("MDB 정의") { DctProtocolRequests.mdbInfo(configuration.mdbSignals) }) + add(packet("MDB 선택 ID") { + DctProtocolRequests.mdbSelectedIds(configuration.selectedMdbSignals) + }) + } else if (configuration.a2lSignals.isNotEmpty()) { + // [A2L 분기]: 0x40 (XCP Use) -> 0x41 (XCP IFDATA) -> 0x42 (XCP Measurements) + // PC SendA2LUseState sends 0x01 when an A2L project is + // loaded and 0x02 only when no A2L path is selected. + add(packet("XCP 사용 모드") { DctProtocolRequests.xcpUse(0x01) }) + val ifData = configuration.xcpIfData ?: com.dct.app.data.parser.A2lParser.XcpIfData() + add(packet("XCP CAN 설정") { DctProtocolRequests.xcpIfData(ifData) }) + add(packet("XCP 측정 목록") { DctProtocolRequests.xcpMeasurements(configuration.a2lSignals, ifData.isMsbFirst) }) + } else { + // No MDB/A2L source means XCP must explicitly be + // disabled, otherwise a preceding A2L project can keep + // streaming stale DAQ data on the next project. + add(packet("XCP 미사용 모드") { DctProtocolRequests.xcpUse(0x02) }) + } + + // 4. CAN mode is a complete 0x09/0x06 request, not a + // subtype-only query. Reapply it here as well so a project + // change on an already paired device takes effect. add(packet("CAN 모드") { DctProtocolRequests.canMode(configuration.isHighSpeedCan, configuration.isOldModel) }) - add(packet("MDB 정의") { DctProtocolRequests.mdbInfo(configuration.mdbSignals) }) - add(packet("MDB 선택 ID") { - DctProtocolRequests.mdbSelectedIds(configuration.selectedMdbSignals) + // 5. SM setup is also sent with its module/channel bitmasks. + // An empty list intentionally emits [01 00] to clear stale + // module selection from the previous project. + add(packet("센서 모듈") { + DctProtocolRequests.sensorModuleInfo(configuration.sensorModules) }) + // 6. Send CAN DBC even when empty; [00 00 00] explicitly + // clears the previous project's DBC table on the device. add(packet("CAN DBC") { DctProtocolRequests.canDbcInfo(configuration.canMessages) }) - if (configuration.sensorModules.isNotEmpty()) { - add(packet("센서 모듈") { - DctProtocolRequests.sensorModuleInfo(configuration.sensorModules) - }) - } } } catch (e: IllegalArgumentException) { return@withContext failure("장비 설정 데이터가 프로토콜 범위를 벗어났습니다: ${e.message}") @@ -217,9 +408,22 @@ class PacketRepositoryImpl( if (_connectionState.value != DeviceConnectionState.READY) { return@withContext failure("계측 시작은 READY 상태에서만 가능합니다.") } - val result = sendCommand(DctProtocolRequests.startAll()) + val sessionId = wifiSocketManager?.currentSessionId ?: 0L + awaitPreviousStopBoundary(sessionId) + if (!hasLiveTransport() || _connectionState.value != DeviceConnectionState.READY) { + return@withContext failure("계측 재시작 대기 중 장비 연결이 해제되었습니다.") + } + // Wi-Fi Direct 브리지의 control writer가 밀린 실제 로그에서 0xA0이 + // 약 12초 후 도착했다. 일반 설정 명령의 빠른 실패 기준은 유지하되 + // 계측 시작 ACK만 PC 통신 환경에 맞는 별도 유예 시간을 사용한다. + val result = sendCommandWithTimeout( + DctProtocolRequests.startAll(), + START_STREAM_RESPONSE_TIMEOUT_MS + ) val response = (result as? DctResult.Success)?.data - if (response?.payload?.firstOrNull() == STATUS_SUCCESS) { + if (response != null && runCatching { + com.dct.app.data.protocol.DctResponseValidator.validateSetup(DctProtocolRequests.startAll(), response) + }.isSuccess) { _isStreaming.value = true transition(DeviceConnectionState.MEASURING) DctResult.Success(Unit) @@ -237,10 +441,57 @@ class PacketRepositoryImpl( _isStreaming.value = false transition(DeviceConnectionState.READY) DctResult.Success(Unit) + } else if (result is DctResult.Failure && + result.error is DctError.ProtocolError.ResponseTimeout && + hasLiveTransport() + ) { + // STOP is idempotent at the measurement boundary. Once the + // request has been written, finish the local measurement session + // even when the peer's ACK is late or lost, while preserving the + // live TCP/P2P connection for the next command or retry. + _isStreaming.value = false + transition(DeviceConnectionState.READY) + android.util.Log.w( + "DctPacketRepository", + "STOP ACK 지연으로 로컬 계측만 종료하고 TCP 세션은 유지합니다." + ) + DctResult.Success(Unit) } else if (result is DctResult.Failure) result else failure("장비가 계측 정지를 확인하지 않았습니다.") } + /** + * The wire protocol has no transaction id. When STOP locally completes + * after its three-second ACK budget, the peer may still be draining the + * previous telemetry run. Do not overlap a new START with that unfinished + * transition: wait for the quarantined late A1, but keep the TCP session. + */ + private suspend fun awaitPreviousStopBoundary(sessionId: Long) { + val stopResponse = DctCommand.RES_STOP_TRANSMIT_DATA.code + if (responseQuarantineSession[stopResponse] != sessionId) return + + android.util.Log.i( + "DctPacketRepository", + "계측 재시작 전 지연 STOP ACK 대기: session=$sessionId" + ) + val drained = kotlinx.coroutines.withTimeoutOrNull(RESTART_STOP_BOUNDARY_TIMEOUT_MS) { + while (hasLiveTransport() && responseQuarantineSession[stopResponse] == sessionId) { + kotlinx.coroutines.delay(STOP_BOUNDARY_POLL_MS) + } + responseQuarantineSession[stopResponse] != sessionId + } == true + + if (!drained && hasLiveTransport()) { + // A1 and A0 are distinct response codes, so an entirely lost A1 + // cannot be mistaken for the following START response. Continue + // after the bounded settling period instead of forcing reconnect. + android.util.Log.w( + "DctPacketRepository", + "STOP ACK 추가 대기 초과; TCP 세션을 유지하고 START를 시도합니다: session=$sessionId" + ) + } + } + override suspend fun readConstant(address: Long, size: Int): DctResult = withContext(ioDispatcher) { val result = sendCommand(DctProtocolRequests.constantRead(address, size)) val response = (result as? DctResult.Success)?.data @@ -310,25 +561,77 @@ class PacketRepositoryImpl( while (true) { try { stream.collect { packet -> - pendingResponses.remove(packet.command)?.complete(packet) - when (packet.command) { - DctCommand.SEND_CAN_FRAME.code -> sendLive( - DctProtocolRequests.canStreamAck( - com.dct.app.data.protocol.DctStreamPayloadParser.parseCanStream(packet.payload) != null - ) + if (packet.transportSessionId != 0L && + packet.transportSessionId != wifiSocketManager?.currentSessionId) return@collect + // PC는 패킷 종류와 무관하게 정상 수신이 계속되면 장비가 + // 살아 있는 것으로 판단해 연결 감시 시간을 갱신한다. + inboundPacketGeneration.incrementAndGet() + // Validate and enqueue the stream ACK before publishing to + // UI/log subscribers. SharedFlow emission is intentionally + // lossless and may suspend on a slow consumer; ACKs must not + // wait behind that work or the PC will fill its backpressure + // window and mistake the delay for a dead connection. + var decodedStreamPayload: Any? = null + val ack = when (packet.command) { + DctCommand.SEND_CAN_FRAME.code -> { + val parsed = DctStreamPayloadParser.parseCanStream(packet.payload) + DctProtocolRequests.canStreamAck(parsed != null).also { + // Pass the validated parse result forward so + // MainViewModel does not decode the same frame twice. + decodedStreamPayload = parsed + } + } + DctCommand.SEND_SM_DATA.code -> { + val parsed = DctStreamPayloadParser.parseSensorStream(packet.payload) + DctProtocolRequests.smStreamAck(parsed != null).also { + decodedStreamPayload = parsed + } + } + DctCommand.SEND_XCP_MEASUREMENT_DATA.code -> { + val parsed = DctStreamPayloadParser.parseXcpStream(packet.payload) + DctProtocolRequests.xcpStreamAck(parsed != null).also { + decodedStreamPayload = parsed + } + } + else -> null + } + if (ack != null) { + val offered = offerStreamAck( + ack.copy(transportSessionId = packet.transportSessionId) ) - DctCommand.SEND_SM_DATA.code -> sendLive( - DctProtocolRequests.smStreamAck( - com.dct.app.data.protocol.DctStreamPayloadParser.parseSensorStream(packet.payload) != null + if (offered) metrics.ackEnqueued.incrementAndGet() + else { + metrics.ackRejected.incrementAndGet() + android.util.Log.w( + "DctPacketRepository", + "스트림 ACK enqueue 실패: session=${packet.transportSessionId}, " + + "command=${DctCommand.toHexString(packet.command)}" ) + } + } + + val quarantined = responseQuarantineSession.remove(packet.command, packet.transportSessionId) + val waiting = if (quarantined) null else pendingResponses.remove(packet.command) + if (waiting != null) { + + waiting.complete(packet) + android.util.Log.i( + "DctPacketRepository", + "RX response matched: session=${packet.transportSessionId}, command=${DctCommand.toHexString(packet.command)}, payload=${packet.payload.size}B" ) - DctCommand.SEND_XCP_MEASUREMENT_DATA.code -> sendLive( - DctProtocolRequests.xcpStreamAck( - com.dct.app.data.protocol.DctStreamPayloadParser.parseXcpStream(packet.payload) != null - ) + } else if (DctCommand.isSolicitedResponse(packet.command)) { + android.util.Log.w( + "DctPacketRepository", + "RX response without waiter: session=${packet.transportSessionId}, command=${DctCommand.toHexString(packet.command)}, payload=${packet.payload.size}B" ) } - packetDistributor.dispatchPacket(packet) + // Do not discard packets when the graph is busy. The + // bounded queue makes sustained overload observable and + // applies TCP backpressure instead of growing forever. + val dispatchPacket = packet.copy(decodedStreamPayload = decodedStreamPayload) + packetDispatchQueue.send(dispatchPacket) + metrics.onDispatchEnqueued() + metrics.maybeLog("DctPacketRepository") } } catch (e: CancellationException) { throw e @@ -339,6 +642,21 @@ class PacketRepositoryImpl( } } + /** + * Stream ACKs are normally accepted immediately. If the bounded writer + * queue is briefly full, retry for a few milliseconds so a transient write + * burst does not silently turn into a missing ACK. The retry is bounded and + * still happens before the packet enters the graph queue. + */ + private suspend fun offerStreamAck(packet: DctPacket): Boolean { + val manager = wifiSocketManager ?: return false + repeat(4) { attempt -> + if (manager.offerCommand(packet)) return true + kotlinx.coroutines.delay(2L shl attempt) + } + return false + } + private suspend fun sendLive(packet: DctPacket): Boolean { wifiSocketManager?.let { manager -> if (manager.isConnected.value && manager.sendCommand(packet)) return true @@ -352,6 +670,112 @@ class PacketRepositoryImpl( private fun transition(state: DeviceConnectionState) { _connectionState.value = state _isConnected.value = state == DeviceConnectionState.READY || state == DeviceConnectionState.MEASURING + if (state == DeviceConnectionState.READY || state == DeviceConnectionState.MEASURING) { + startHeartbeatLoop() + } else { + stopHeartbeatLoop() + } + } + + /** + * [ISSUE-R1-02] 연결 수립 시 백그라운드 코루틴으로 주기적(3초 기본)으로 + * DctCommand.CMD_POSSIBLE_TO_CONNECT (0x05)를 발송하고 생존 여부를 확인합니다. + */ + private fun startHeartbeatLoop() { + if (!enableHeartbeat) return + if (heartbeatJob?.isActive == true) return + heartbeatJob = scope.launch(heartbeatDispatcher) { + var consecutiveFailures = 0 + while (isActive && hasLiveTransport() && + (_connectionState.value == DeviceConnectionState.READY || + _connectionState.value == DeviceConnectionState.MEASURING) + ) { + kotlinx.coroutines.delay(heartbeatIntervalMs) + if (!isActive || !hasLiveTransport() || + (_connectionState.value != DeviceConnectionState.READY && + _connectionState.value != DeviceConnectionState.MEASURING) + ) break + + val success = performHeartbeat() + consecutiveFailures = if (success) 0 else consecutiveFailures + 1 + if (!success && consecutiveFailures >= heartbeatFailureThreshold.coerceAtLeast(1) && + isActive && hasLiveTransport() && + (_connectionState.value == DeviceConnectionState.READY || + _connectionState.value == DeviceConnectionState.MEASURING) + ) { + // 0x05/0x85는 보조 생존 진단이다. 장비/테스트 프로그램이 + // 제어 응답이나 대용량 스트림을 늦게 보내는 동안 Heartbeat만 + // 누락될 수 있으므로 이것만으로 정상 TCP를 닫지 않는다. + // 실제 단선은 TCP EOF/I/O 오류 또는 P2P 그룹 해제에서 확정한다. + reportHeartbeatTimeout(consecutiveFailures) + consecutiveFailures = 0 + } + } + } + } + + private fun stopHeartbeatLoop() { + heartbeatJob?.cancel() + heartbeatJob = null + } + + /** + * 계측 중(isMeasuring)일 때의 데이터 스트림 및 다른 동기 제어 명령과 충돌하지 않도록 + * 안전하게 Mutex를 조율하며 0x05 발송 및 0x85 응답을 대기합니다. + */ + private suspend fun performHeartbeat(): Boolean { + if (!hasLiveTransport()) return false + val sessionId = wifiSocketManager?.currentSessionId ?: return false + val expected = DctCommand.RES_POSSIBLE_TO_CONNECT.code + val inboundGenerationBeforeProbe = inboundPacketGeneration.get() + + // 다른 제어 명령(UI 파라미터 변경 등)이 진행 중이면 이미 통신이 활성화된 상태이므로 경합을 피함 + val locked = requestMutex.tryLock() + if (!locked) { + return true + } + + val deferred = CompletableDeferred() + pendingResponses[expected] = deferred + val packet = DctProtocolRequests.possibleToConnect().copy(transportSessionId = sessionId) + try { + if (!sendLive(packet)) { + pendingResponses.remove(expected, deferred) + return false + } + return withTimeout(heartbeatTimeoutMs) { + val response = deferred.await() + response.command == expected + } + } catch (e: kotlinx.coroutines.TimeoutCancellationException) { + val otherPacketReceived = inboundPacketGeneration.get() > inboundGenerationBeforeProbe + if (otherPacketReceived) { + android.util.Log.d( + "DctPacketRepository", + "Alive Check 0x85는 지연됐지만 정상 수신 데이터가 있어 연결을 유지합니다." + ) + return true + } + android.util.Log.w("DctPacketRepository", "Alive Check (0x05) 응답 및 기타 수신 데이터 없음") + return false + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + android.util.Log.w("DctPacketRepository", "Alive Check (0x05) 송출 오류: ${e.message}") + return false + } finally { + pendingResponses.remove(expected, deferred) + requestMutex.unlock() + } + } + + /** Heartbeat 누락은 진단만 남기며 활성 TCP 세션의 상태를 변경하지 않는다. */ + private fun reportHeartbeatTimeout(consecutiveFailures: Int) { + android.util.Log.w( + "DctPacketRepository", + "Alive Check 경고: 하트비트와 정상 데이터 수신이 연속 ${consecutiveFailures}회 없었지만 " + + "TCP 연결은 유지합니다. 실제 EOF/I/O 오류 또는 Wi-Fi Direct 그룹 해제를 기다립니다." + ) } private fun failPendingRequests(reason: String) { @@ -388,7 +812,17 @@ class PacketRepositoryImpl( if (result is DctResult.Failure) result else failure(reason) companion object { + // Enough headroom for short UI stalls without hiding multi-second + // processing backlogs or retaining an excessive number of large A3 + // payload copies in memory. + const val DEFAULT_DISPATCH_QUEUE_CAPACITY = 512 private const val DEFAULT_RESPONSE_TIMEOUT_MS = 3_000L + private const val START_STREAM_RESPONSE_TIMEOUT_MS = 15_000L + const val DEFAULT_HEARTBEAT_INTERVAL_MS = 3_000L + const val DEFAULT_HEARTBEAT_TIMEOUT_MS = 5_000L + const val DEFAULT_HEARTBEAT_FAILURE_THRESHOLD = 2 + private const val RESTART_STOP_BOUNDARY_TIMEOUT_MS = 12_000L + private const val STOP_BOUNDARY_POLL_MS = 25L private const val STATUS_SUCCESS: Byte = 0x01 } } diff --git a/Android/app/src/main/java/com/dct/app/data/repository/PcDpcCompatibility.kt b/Android/app/src/main/java/com/dct/app/data/repository/PcDpcCompatibility.kt new file mode 100644 index 0000000..ac37ec6 --- /dev/null +++ b/Android/app/src/main/java/com/dct/app/data/repository/PcDpcCompatibility.kt @@ -0,0 +1,508 @@ +package com.dct.app.data.repository + +import com.dct.app.core.theme.PcColorRef +import com.dct.app.core.theme.toColorRef +import com.dct.app.domain.model.chart.AxisPosition +import com.dct.app.domain.model.chart.InterpolationMode +import com.dct.app.domain.model.chart.TimeWindowPreset +import com.dct.app.domain.model.mdb.CanSignalKey +import com.dct.app.domain.model.mdb.SignalDefinition +import com.dct.app.domain.model.project.DpcPageState +import com.dct.app.domain.model.project.DpcChartState +import com.dct.app.domain.model.project.DpcProject +import com.dct.app.domain.model.project.DpcSignalBindingState +import com.dct.app.domain.model.project.DpcWindowState +import com.dct.app.domain.model.project.DpcWorkspaceState +import org.json.JSONArray +import org.json.JSONObject + +/** + * Adapter for the graph portions of the PC C++ .dpc format. + * + * The PC application stores graph windows in `graphWnd_property` and + * `multiXgraphWnd_property`, while Android stores the complete MDI/page state + * in `android_workspace`. Keeping this conversion in one small adapter lets + * both formats coexist without making the runtime renderer depend on JSON. + */ +internal object PcDpcCompatibility { + private const val PC_GRAPH = "graphWnd_property" + private const val PC_GRAPH_ITEMS = "graphWndInfo" + private const val PC_MULTI_GRAPH = "multiXgraphWnd_property" + private const val PC_MULTI_ITEMS = "multiXgraphWndInfo" + private const val GRAPH_TYPE = "GRAPH" + private const val MULTI_GRAPH_TYPE = "MULTI_AXIS_GRAPH" + private const val ALPHA_TYPE = "ALPHANUMERICAL" + private const val MAX_WINDOWS = 256 + private const val MAX_ITEMS_PER_WINDOW = 4096 + + /** + * Reads PC graph windows when the Android workspace extension is absent. + * Returns null for projects that do not contain any graph state. + */ + fun decodeWorkspace(root: JSONObject): DpcWorkspaceState? { + val windows = mutableListOf() + decodeWindowArray(root.optJSONObject(PC_GRAPH), PC_GRAPH_ITEMS, GRAPH_TYPE, root, windows) + decodeWindowArray(root.optJSONObject(PC_MULTI_GRAPH), PC_MULTI_ITEMS, MULTI_GRAPH_TYPE, root, windows) + decodeTileWindowArray(root.optJSONObject("tileWnd_property"), root, windows) + + val pageSignals = decodeGlobalGraphItems(root, root) + if (windows.isEmpty() && pageSignals.isEmpty()) return null + + val page = DpcPageState( + id = "pc_page_1", + title = root.optString("name", "Page 1").ifBlank { "Page 1" }, + windows = windows, + activeWindowId = windows.firstOrNull()?.id, + signals = pageSignals, + alphanumericalSignals = windows.filter { it.type == ALPHA_TYPE } + .flatMap { it.targetSignals }.distinct(), + timeWindowSec = TimeWindowPreset.MIN_1.durationSec.toFloat(), + timeWindowPreset = TimeWindowPreset.MIN_1, + createdAt = 0L + ) + return DpcWorkspaceState(page.id, listOf(page)) + } + + private fun decodeTileWindowArray( + property: JSONObject?, + root: JSONObject, + output: MutableList + ) { + val items = property?.optJSONArray("tileWndInfo") ?: return + for (i in 0 until minOf(items.length(), MAX_WINDOWS)) { + val item = items.optJSONObject(i) ?: continue + val index = item.optInt("index", i) + val baseId = "pc_tile_$index" + val id = if (output.none { it.id == baseId }) baseId else "${baseId}_$i" + val targets = mutableListOf() + val hidden = mutableSetOf() + val colors = mutableMapOf() + val tileItems = item.optJSONArray("items") + for (j in 0 until minOf(tileItems?.length() ?: 0, MAX_ITEMS_PER_WINDOW)) { + val tile = tileItems?.optJSONObject(j) ?: continue + val binding = decodeGraphItem(tile, root, GRAPH_TYPE, j) ?: continue + targets += binding.signalId + if (tile.has("item_hideshow") && !tile.optBoolean("item_hideshow", true)) { + hidden += binding.signalId + } + tile.optLong("item_tilecolor", Long.MIN_VALUE).takeIf { it != Long.MIN_VALUE }?.let { + colors[binding.signalId] = it.toColorRef() + } + } + output += DpcWindowState( + id = id, + type = ALPHA_TYPE, + title = item.optString("titlename", "Alphanumerical").ifBlank { "Alphanumerical" }, + x = 20f + (output.size % 4) * 24f, + y = 20f + (output.size % 4) * 24f, + width = 400f, + height = 320f, + zIndex = output.size.toFloat(), + targetSignals = targets.distinct(), + isTileMode = item.optBoolean("istilemode", true), + hiddenSignalIds = hidden, + highlightColors = colors + ) + } + } + + private fun decodeWindowArray( + property: JSONObject?, + infoKey: String, + type: String, + root: JSONObject, + output: MutableList + ) { + val items = property?.optJSONArray(infoKey) ?: return + val seen = output.mapTo(mutableSetOf()) { it.id } + for (i in 0 until minOf(items.length(), MAX_WINDOWS)) { + val item = items.optJSONObject(i) ?: continue + val index = item.optInt("index", i) + val id = "pc_${if (type == MULTI_GRAPH_TYPE) "multi" else "graph"}_$index" + .let { candidate -> if (seen.add(candidate)) candidate else "${candidate}_$i" } + val defaultTitle = if (type == MULTI_GRAPH_TYPE) "MultiAxisGraph" else "GraphWindow 01" + val title = item.optString("titlename", defaultTitle).ifBlank { defaultTitle } + val legend = item.optInt("legendpos", if (type == MULTI_GRAPH_TYPE) 2 else 0) + .coerceIn(0, 3) + val signals = (0 until minOf(item.optJSONArray("items")?.length() ?: 0, MAX_ITEMS_PER_WINDOW)) + .mapNotNull { item.optJSONArray("items")?.optJSONObject(it) } + .mapNotNullIndexed { signalIndex, graphItem -> + decodeGraphItem(graphItem, root, type, signalIndex) + } + .distinctBy { it.signalId } + output += DpcWindowState( + id = id, + type = type, + title = title, + // PC EnvSetting does not persist an MDI rectangle. Android's + // normal window placement is therefore the safest default. + x = 20f + (output.size % 4) * 24f, + y = 20f + (output.size % 4) * 24f, + width = if (type == MULTI_GRAPH_TYPE) 650f else 560f, + height = 420f, + zIndex = output.size.toFloat(), + timeWindowPreset = TimeWindowPreset.MIN_1, + signals = signals, + chart = DpcChartState( + timeWindowSec = TimeWindowPreset.MIN_1.durationSec, + timeWindowPreset = TimeWindowPreset.MIN_1, + isAutoTimeWindow = false, + isXAxisAuto = true, + isYAxisAuto = true, + legendPosition = legend + ) + ) + } + } + + /** Converts the PC graphItem_property list into page-level bindings. */ + private fun decodeGlobalGraphItems(root: JSONObject, source: JSONObject): List { + val items = source.optJSONObject("graphItem_property")?.optJSONArray("items") ?: return emptyList() + return (0 until minOf(items.length(), MAX_ITEMS_PER_WINDOW)) + .mapNotNull { items.optJSONObject(it) } + .mapNotNullIndexed { index, item -> decodeGraphItem(item, root, GRAPH_TYPE, index) } + .distinctBy { it.signalId } + } + + private fun decodeGraphItem( + item: JSONObject, + root: JSONObject, + type: String, + order: Int + ): DpcSignalBindingState? { + val itemType = item.optInt("item_type", item.optInt("ITEMTYPE", -1)) + if (itemType !in 0..4) return null + val visible = if (item.has("item_hideshow")) item.optBoolean("item_hideshow", true) else true + val width = item.optInt("item_thickness", 2).coerceIn(1, 5).toFloat() + val dash = item.optInt("item_dashstyle", 0).coerceIn(0, 4) + val axis = if (type == MULTI_GRAPH_TYPE) axisFor(order) else AxisPosition.LEFT_0 + val name: String + val id: String + when (itemType) { + 0 -> { + val reclid = item.optInt("item_reclID", item.optInt("RECLID", 0)) + val index = item.optInt("item_index", item.optInt("INDEX", 0)) + id = "sig_${reclid}_$index" + name = item.optString("item_name", item.optString("Name", id)).ifBlank { id } + } + 1 -> { + val index = item.optInt("item_index", item.optInt("INDEX", 0)) + id = "can_$index" + name = item.optString("item_name", item.optString("Name", "CAN_$index")) + .ifBlank { "CAN_$index" } + } + 2 -> { + // PC stores both values zero-based. Android's stable runtime + // key is the human-facing 01-based SM_XX_YY form. + val sm = item.optInt("item_smindex", 0).coerceAtLeast(0) + 1 + val ch = item.optInt("item_chindex", 0).coerceAtLeast(0) + 1 + id = "sm_${sm.toString().padStart(2, '0')}_${ch.toString().padStart(2, '0')}" + name = item.optString("item_name", "$sm-$ch ch").ifBlank { "$sm-$ch ch" } + } + 3 -> { + val index = item.optInt("item_index", item.optInt("INDEX", order)) + val formulaName = formulaNameAt(root, index) + id = "formula_${formulaName ?: index}" + name = formulaName ?: item.optString("item_name", id).ifBlank { id } + } + 4 -> { + val address = item.optLong("item_index", item.optLong("address", 0L)) + val itemName = item.optString("item_name", item.optString("Name", "")) + id = if (address != 0L) "a2l_$address" else "a2l_${itemName.ifBlank { order.toString() }}" + name = itemName.ifBlank { id } + } + else -> return null + } + val color = item.optLong("USER_COLOR", item.optLong("color", propertyColor(root, itemType, name))).toColorRef() + return DpcSignalBindingState( + signalId = id, + signalName = name, + axisPosition = axis, + color = color, + unit = item.optString("UNIT", item.optString("unit", "")), + lineWidthDp = width, + dashStyle = dash, + isVisible = visible, + interpolationMode = if (root.optJSONObject("graphItem_property")?.optBoolean("graph_linear", true) == false) { + InterpolationMode.PWM_STEP + } else { + InterpolationMode.LINEAR + } + ) + } + + /** Adds/updates the PC graph keys while retaining unrelated vendor keys. */ + fun encodeGraphProperties(root: JSONObject, workspace: DpcWorkspaceState, project: DpcProject) { + val graphWindows = workspace.pages.flatMap { it.windows } + .filter { it.type == GRAPH_TYPE || it.type == "CHART" } + val multiWindows = workspace.pages.flatMap { it.windows } + .filter { it.type == MULTI_GRAPH_TYPE } + + val graphProperty = root.optJSONObject(PC_GRAPH)?.let(::copyObject) ?: JSONObject() + graphProperty.put(PC_GRAPH_ITEMS, JSONArray().apply { + graphWindows.forEachIndexed { index, window -> put(encodeWindow(window, index, project)) } + }) + root.put(PC_GRAPH, graphProperty) + + val multiProperty = root.optJSONObject(PC_MULTI_GRAPH)?.let(::copyObject) ?: JSONObject() + multiProperty.put(PC_MULTI_ITEMS, JSONArray().apply { + multiWindows.forEachIndexed { index, window -> put(encodeWindow(window, index, project)) } + }) + root.put(PC_MULTI_GRAPH, multiProperty) + + val tileWindows = workspace.pages.flatMap { it.windows } + .filter { it.type == ALPHA_TYPE } + val tileProperty = root.optJSONObject("tileWnd_property")?.let(::copyObject) ?: JSONObject() + tileProperty.put("tileWndInfo", JSONArray().apply { + tileWindows.forEachIndexed { index, window -> put(encodeTileWindow(window, index, project)) } + }) + root.put("tileWnd_property", tileProperty) + + val globalSignals = workspace.pages.flatMap { page -> + page.signals + page.windows.flatMap { it.signals } + }.distinctBy { it.signalId } + val graphItems = root.optJSONObject("graphItem_property")?.let(::copyObject) ?: JSONObject() + graphItems.put("graph_linear", project.graphLinear) + graphItems.put("items", JSONArray().apply { + globalSignals.mapNotNull { encodeGlobalItem(it, project) } + .distinctBy { globalItemKey(it) } + .forEach(::put) + }) + root.put("graphItem_property", graphItems) + + // The PC loader also consumes per-source `graph_items` lists when it + // restores the signal tree. Populate those lists from the same + // bindings so graph state is usable in both PC loading paths. + updateSourceGraphItems(root, "mdb_recv_property", globalSignals) { signal -> + encodeGraphItem(signal, project)?.takeIf { it.optInt("item_type", -1) == 0 } + ?.optInt("item_index") + } + updateSourceGraphItems(root, "candbc_property", globalSignals) { signal -> + encodeGraphItem(signal, project)?.takeIf { it.optInt("item_type", -1) == 1 } + ?.optInt("item_index") + } + } + + private fun updateSourceGraphItems( + root: JSONObject, + propertyName: String, + signals: List, + indexOf: (DpcSignalBindingState) -> Int? + ) { + val property = root.optJSONObject(propertyName) ?: return + val groups = property.optJSONArray("property") ?: return + val indexes = signals.mapNotNull(indexOf).toSet() + for (i in 0 until groups.length()) { + val group = groups.optJSONObject(i) ?: continue + val sourceIndexes = (0 until (group.optJSONArray("items")?.length() ?: 0)) + .mapNotNull { group.optJSONArray("items")?.optJSONObject(it)?.optInt("Index") } + .toSet() + group.put("graph_items", JSONArray().apply { + indexes.filter { it in sourceIndexes }.sorted().forEach { put(JSONObject().put("Index", it)) } + }) + } + } + + private fun encodeWindow(window: DpcWindowState, fallbackIndex: Int, project: DpcProject): JSONObject { + val index = pcIndex(window.id, window.title, fallbackIndex) + val type = if (window.type == MULTI_GRAPH_TYPE) MULTI_GRAPH_TYPE else GRAPH_TYPE + return JSONObject().put("index", index) + .put("legendpos", window.chart?.legendPosition ?: if (type == MULTI_GRAPH_TYPE) 2 else 0) + .put("titlename", window.title) + .put("items", JSONArray().apply { + window.signals.forEach { signal -> encodeGraphItem(signal, project)?.let(::put) } + }) + } + + private fun encodeTileWindow(window: DpcWindowState, fallbackIndex: Int, project: DpcProject): JSONObject { + val index = pcIndex(window.id, window.title, fallbackIndex) + val targets = if (window.targetSignals.isNotEmpty()) { + window.targetSignals + } else { + window.signals.map { it.signalId } + } + val metadata = window.signals.associateBy { it.signalId } + return JSONObject().put("index", index) + .put("istilemode", window.isTileMode) + .put("titlename", window.title) + .put("items", JSONArray().apply { + targets.forEach { id -> + val binding = metadata[id] ?: DpcSignalBindingState( + signalId = id, + signalName = id, + axisPosition = AxisPosition.LEFT_0, + color = window.highlightColors[id]?.toColorRef() ?: PcColorRef.DEFAULT_SIGNAL, + isVisible = id !in window.hiddenSignalIds + ) + encodeGraphItem(binding, project)?.let { item -> + item.put("item_tilecolor", (window.highlightColors[id] ?: binding.color).toColorRef()) + item.put("item_hideshow", id !in window.hiddenSignalIds && binding.isVisible) + put(item) + } + } + }) + } + + private fun encodeGraphItem(signal: DpcSignalBindingState, project: DpcProject): JSONObject? { + val item = JSONObject() + .put("item_hideshow", signal.isVisible) + .put("item_thickness", signal.lineWidthDp.roundToInt().coerceIn(1, 5)) + .put("item_dashstyle", signal.dashStyle.coerceIn(0, 4)) + val id = signal.signalId.trim() + // Older Android XCP workspaces used sig_0_ for A2L + // measurements. Resolve those against the loaded A2L metadata before + // applying the MDB `sig__` rule below. + val legacyA2l = project.a2lSignals.firstOrNull { definition -> + signal.signalName.equals(definition.name, ignoreCase = true) && + (id.startsWith("sig_0_", ignoreCase = true) || id.startsWith("a2l_", ignoreCase = true)) + } + when { + legacyA2l != null -> { + item.put("item_type", 4).put("item_index", legacyA2l.adl.toLong()) + .put("item_name", legacyA2l.name) + } + id.startsWith("sig_", ignoreCase = true) -> { + val parts = id.substringAfter('_').split('_') + if (parts.size < 2) return null + item.put("item_type", 0).put("item_reclID", parts[0].toIntOrNull() ?: return null) + .put("item_index", parts[1].toIntOrNull() ?: return null) + .put("item_name", signal.signalName) + } + id.startsWith("can_", ignoreCase = true) -> { + val can = CanSignalKey.find(id, project.canSignals) + val index = can?.idx ?: parseCanIndex(id) ?: return null + item.put("item_type", 1).put("item_index", index) + // This extra field is ignored by PC but makes hand inspection + // and round-trips deterministic when DBC order changes. + if (signal.signalName.isNotBlank()) item.put("item_name", signal.signalName) + } + id.startsWith("sm_", ignoreCase = true) -> { + val parts = id.substringAfter('_').split('_') + if (parts.size < 2) return null + val sm = parts[0].toIntOrNull() ?: return null + val ch = parts[1].toIntOrNull() ?: return null + item.put("item_type", 2).put("item_smindex", (sm - 1).coerceAtLeast(0)) + .put("item_chindex", (ch - 1).coerceAtLeast(0)) + } + id.startsWith("formula_", ignoreCase = true) -> { + val token = id.substringAfter('_') + val index = formulaPcIndex(project, token) + ?: project.formulas.indexOfFirst { it.name.equals(token, ignoreCase = true) } + .takeIf { it >= 0 } + ?: token.toIntOrNull() + ?: return null + item.put("item_type", 3).put("item_index", index) + } + id.startsWith("a2l_", ignoreCase = true) -> { + val a2l = findA2l(signal, project.a2lSignals) + val address = a2l?.adl?.toLong()?.and(0xFFFFFFFFL) + ?: id.substringAfter('_').toLongOrNull()?.and(0xFFFFFFFFL) + ?: return null + item.put("item_type", 4).put("item_index", address) + .put("item_name", a2l?.name ?: signal.signalName) + } + else -> return null + } + return item + } + + private fun encodeGlobalItem(signal: DpcSignalBindingState, project: DpcProject): JSONObject? { + val item = encodeGraphItem(signal, project) ?: return null + val type = item.optInt("item_type", -1) + return when (type) { + 0 -> JSONObject().put("ITEMTYPE", 0).put("RECLID", item.optInt("item_reclID")) + .put("INDEX", item.optInt("item_index")) + 1 -> JSONObject().put("ITEMTYPE", 1).put("INDEX", item.optInt("item_index")) + else -> null + } + } + + private fun globalItemKey(item: JSONObject): String = + "${item.optInt("ITEMTYPE", -1)}:${item.optInt("RECLID", -1)}:${item.optInt("INDEX", -1)}" + + private fun axisFor(index: Int): AxisPosition = when { + index < AxisPosition.LEFT_AXES.size -> AxisPosition.LEFT_AXES[index] + else -> AxisPosition.RIGHT_AXES[(index - AxisPosition.LEFT_AXES.size).coerceIn(0, 3)] + } + + private fun pcIndex(id: String, title: String, fallback: Int): Int { + val token = id.substringAfterLast('_') + token.toIntOrNull()?.let { return it } + Regex("(?:GraphWindow|Graph|차트)\\s*(\\d+)", RegexOption.IGNORE_CASE) + .find(title)?.groupValues?.getOrNull(1)?.toIntOrNull()?.let { return it } + return fallback + } + + private fun parseCanIndex(id: String): Int? { + val tokens = id.substringAfter("can_", "").split('_') + return tokens.firstOrNull()?.toIntOrNull() + } + + private fun findA2l(signal: DpcSignalBindingState, definitions: List): SignalDefinition? { + val id = signal.signalId.removePrefix("a2l_") + val addr = id.toLongOrNull() + return definitions.firstOrNull { (addr != null && it.adl.toLong() == addr) || it.name.equals(signal.signalName, true) } + } + + private fun formulaNameAt(root: JSONObject, index: Int): String? { + fun find(items: JSONArray?): String? { + for (i in 0 until (items?.length() ?: 0)) { + val item = items?.optJSONObject(i) ?: continue + val storedIndex = item.optInt("index", i) + if (storedIndex == index) { + item.optString("name", "").takeIf { it.isNotBlank() }?.let { return it } + } + } + return items?.optJSONObject(index)?.optString("name", "")?.takeIf { it.isNotBlank() } + } + return find(root.optJSONObject("formula_property")?.optJSONArray("items")) + ?: find(root.optJSONObject("formula_info")?.optJSONArray("items")) + } + + private fun formulaPcIndex(project: DpcProject, name: String): Int? { + fun find(items: JSONArray?): Int? { + for (i in 0 until (items?.length() ?: 0)) { + val item = items?.optJSONObject(i) ?: continue + if (item.optString("name", item.optString("Name", "")).equals(name, true)) { + return item.optInt("index", i) + } + } + return null + } + return find(project.rawJsonRoot?.optJSONObject("formula_property")?.optJSONArray("items")) + ?: find(project.rawJsonRoot?.optJSONObject("formula_info")?.optJSONArray("items")) + } + + private fun propertyColor(root: JSONObject, type: Int, name: String): Long { + val property = when (type) { + 0 -> "mdb_recv_property" + 1 -> "candbc_property" + 4 -> "a2l_property" + else -> null + } ?: return PcColorRef.DEFAULT_SIGNAL + val groups = root.optJSONObject(property)?.optJSONArray("property") + ?: root.optJSONObject(property)?.optJSONObject("measurement")?.let { JSONArray().put(it) } + ?: return PcColorRef.DEFAULT_SIGNAL + for (i in 0 until groups.length()) { + val items = groups.optJSONObject(i)?.optJSONArray("items") ?: continue + for (j in 0 until items.length()) { + val item = items.optJSONObject(j) ?: continue + if (item.optString("Name").equals(name, true)) { + val color = item.optLong("USER_COLOR", -1L) + if (color >= 0L) return color.toColorRef() + } + } + } + return PcColorRef.DEFAULT_SIGNAL + } + + private fun copyObject(value: JSONObject): JSONObject = JSONObject(value.toString()) + + private fun Iterable.mapNotNullIndexed(transform: (Int, T) -> R?): List { + val result = ArrayList() + forEachIndexed { index, value -> transform(index, value)?.let(result::add) } + return result + } + + private fun Float.roundToInt(): Int = kotlin.math.round(this).toInt() +} diff --git a/Android/app/src/main/java/com/dct/app/data/ringbuffer/TimeSeriesRingBuffer.kt b/Android/app/src/main/java/com/dct/app/data/ringbuffer/TimeSeriesRingBuffer.kt index 3ae1285..6fca346 100644 --- a/Android/app/src/main/java/com/dct/app/data/ringbuffer/TimeSeriesRingBuffer.kt +++ b/Android/app/src/main/java/com/dct/app/data/ringbuffer/TimeSeriesRingBuffer.kt @@ -38,7 +38,7 @@ class TimeSeriesRingBuffer(val capacity: Int = 100_000) { * @param value 물리 계측값 */ @Synchronized - fun addSample(timestampSec: Double, value: Double) { + fun addSample(timestampSec: Double, value: Double, forceAppend: Boolean = false) { if (!timestampSec.isFinite() || !value.isFinite()) return if (size > 0) { @@ -46,13 +46,18 @@ class TimeSeriesRingBuffer(val capacity: Int = 100_000) { val prevTime = timestamps[lastIdx] // PC C++ 호환: 직전 점과 타임스탬프가 동일(차이 < 1ms)하면 수직선 왜곡 방지를 위해 덮어씀 - if (kotlin.math.abs(timestampSec - prevTime) < 0.001) { + if (!forceAppend && kotlin.math.abs(timestampSec - prevTime) < 0.001) { values[lastIdx] = value return } // 타임스탬프 역전 방지 클램핑 (네트워크 지터 방어) - val monotonicTime = if (timestampSec < prevTime) prevTime + 0.001 else timestampSec + // A transport ingress sequence can force-append frames that were + // decoded from one TCP read. Preserve their sub-millisecond order + // instead of applying the legacy duplicate-time overwrite rule. + val monotonicTime = if (timestampSec <= prevTime) { + if (forceAppend) Math.nextUp(prevTime) else prevTime + 0.001 + } else timestampSec if (size < capacity) { val insertIdx = (head + size) % capacity @@ -79,6 +84,39 @@ class TimeSeriesRingBuffer(val capacity: Int = 100_000) { addSample(timestampMs.toDouble() / 1000.0, value) } + /** + * 데이터 단절을 표시하는 시간축 마커를 추가합니다. + * 값은 NaN으로 저장되며 렌더러가 이 지점에서 path를 끊습니다. + */ + @Synchronized + fun addGap(timestampSec: Double) { + if (!timestampSec.isFinite()) return + if (size > 0) { + val lastIdx = (head + size - 1) % capacity + val prevTime = timestamps[lastIdx] + val monotonicTime = if (timestampSec < prevTime) prevTime + 0.001 else timestampSec + if (kotlin.math.abs(monotonicTime - prevTime) < 0.001) { + values[lastIdx] = Double.NaN + return + } + if (size < capacity) { + val insertIdx = (head + size) % capacity + timestamps[insertIdx] = monotonicTime + values[insertIdx] = Double.NaN + size++ + } else { + timestamps[head] = monotonicTime + values[head] = Double.NaN + head = (head + 1) % capacity + } + } else { + timestamps[0] = timestampSec + values[0] = Double.NaN + head = 0 + size = 1 + } + } + /** * 0-based 논리 인덱스 [0, size-1]의 타임스탬프 조회 (O(1)) * 0은 가장 오래된 샘플, size-1은 가장 최근 샘플 diff --git a/Android/app/src/main/java/com/dct/app/domain/model/chart/ChartAxisConfig.kt b/Android/app/src/main/java/com/dct/app/domain/model/chart/ChartAxisConfig.kt index 2e7134d..c21014a 100644 --- a/Android/app/src/main/java/com/dct/app/domain/model/chart/ChartAxisConfig.kt +++ b/Android/app/src/main/java/com/dct/app/domain/model/chart/ChartAxisConfig.kt @@ -1,5 +1,7 @@ package com.dct.app.domain.model.chart +import com.dct.app.core.theme.PcColorRef + /** * 개별 Y축 설정 모델 (PC C++ CChartAxis 및 MultiXGraphWnd 축 설정 반영) * @@ -7,7 +9,7 @@ package com.dct.app.domain.model.chart * @param minVal Y축 최소 표시값 (기본값 -50.0) * @param maxVal Y축 최대 표시값 (기본값 100.0) * @param isAuto 자동 스케일링 여부 (ScreenAutomatic) - * @param color 축 라벨 및 눈금 색상 (ARGB Long) + * @param color 축 라벨 및 눈금 색상 (PC Win32 COLORREF `0x00BBGGRR`) * @param unit 물리 단위 문자열 (예: "RPM", "°C", "V", "%") * @param label 축 설명 레이블 * @param tickCount 눈금 분할 개수 (기본 5개) @@ -19,7 +21,7 @@ data class ChartAxisConfig( val minVal: Double = -50.0, val maxVal: Double = 100.0, val isAuto: Boolean = true, - val color: Long = 0xFF172B4DL, + val color: Long = PcColorRef.DEFAULT_AXIS, val unit: String = "", val label: String = "", val tickCount: Int = 5, diff --git a/Android/app/src/main/java/com/dct/app/domain/model/chart/ChartDrawingRules.kt b/Android/app/src/main/java/com/dct/app/domain/model/chart/ChartDrawingRules.kt index aa0075e..825d2ba 100644 --- a/Android/app/src/main/java/com/dct/app/domain/model/chart/ChartDrawingRules.kt +++ b/Android/app/src/main/java/com/dct/app/domain/model/chart/ChartDrawingRules.kt @@ -1,11 +1,81 @@ package com.dct.app.domain.model.chart +import java.time.Instant +import java.time.LocalDate +import java.time.LocalDateTime +import java.time.LocalTime +import java.time.ZoneId +import java.time.format.DateTimeFormatter +import java.util.Locale +import kotlin.math.roundToLong + fun isValidChartAxisRange(min: Double, max: Double): Boolean = min.isFinite() && max.isFinite() && max > min && (max - min).isFinite() /** Round-trip precision for an editable bound, not a rounded display label. */ fun chartAxisInputText(value: Double): String = if (value.isFinite()) value.toString() else "" +private val chartDateTimeInputFormatters = listOf( + DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss.SSS", Locale.US), + DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss", Locale.US) +) +private val chartTimeInputFormatters = listOf( + DateTimeFormatter.ofPattern("HH:mm:ss.SSS", Locale.US), + DateTimeFormatter.ofPattern("HH:mm:ss", Locale.US), + DateTimeFormatter.ofPattern("HH:mm", Locale.US) +) + +/** + * Human-readable X-axis bound. Chart samples use epoch seconds internally, + * but exposing those values directly produces strings such as `1.78E9` in + * the Manual dialog. Keep milliseconds while formatting in the device's + * local timezone, matching the Canvas X-axis labels. + */ +fun chartTimeInputText(value: Double, zone: ZoneId = ZoneId.systemDefault()): String { + if (!value.isFinite()) return "" + return DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss.SSS", Locale.US) + .withZone(zone) + .format(Instant.ofEpochMilli((value * 1000.0).roundToLong())) +} + +/** + * Parse a Manual X-axis bound. Full local date/time and short clock-time + * forms are accepted; plain epoch seconds remain supported for old projects + * and for users who prefer numeric coordinates. + */ +fun parseChartTimeInput( + text: String, + referenceSec: Double? = null, + zone: ZoneId = ZoneId.systemDefault() +): Double? { + val normalized = text.trim().replace('T', ' ') + if (normalized.isEmpty()) return null + + normalized.toDoubleOrNull()?.takeIf { it.isFinite() }?.let { return it } + + chartDateTimeInputFormatters.firstNotNullOfOrNull { formatter -> + runCatching { + LocalDateTime.parse(normalized, formatter) + .atZone(zone) + .toInstant() + .toEpochMilli() / 1000.0 + }.getOrNull() + }?.let { return it } + + val date = referenceSec?.takeIf { it.isFinite() }?.let { + Instant.ofEpochMilli((it * 1000.0).roundToLong()).atZone(zone).toLocalDate() + } ?: LocalDate.now(zone) + return chartTimeInputFormatters.firstNotNullOfOrNull { formatter -> + runCatching { + LocalTime.parse(normalized, formatter) + .atDate(date) + .atZone(zone) + .toInstant() + .toEpochMilli() / 1000.0 + }.getOrNull() + } +} + fun chartAxisTickText(value: Double, ticks: List): String { val step = ticks.zipWithNext { a, b -> b - a }.filter { it > 0 && it.isFinite() }.minOrNull() ?: return chartAxisInputText(value) diff --git a/Android/app/src/main/java/com/dct/app/domain/model/chart/ChartSignalSeries.kt b/Android/app/src/main/java/com/dct/app/domain/model/chart/ChartSignalSeries.kt index cdd469f..ef30523 100644 --- a/Android/app/src/main/java/com/dct/app/domain/model/chart/ChartSignalSeries.kt +++ b/Android/app/src/main/java/com/dct/app/domain/model/chart/ChartSignalSeries.kt @@ -8,7 +8,7 @@ import com.dct.app.data.ringbuffer.TimeSeriesRingBuffer * @param signalId 고유 신호 식별자 * @param signalName 화면에 표시될 신호명 * @param axisPosition 바인딩된 Y축 위치 (LEFT_0..3, RIGHT_0..3) - * @param color 파형 렌더링 색상 (ARGB Long) + * @param color 파형 렌더링 색상 (PC Win32 COLORREF `0x00BBGGRR`) * @param unit 물리 단위 * @param lineWidthDp 선 두께 (dp: 1.0f ~ 5.0f) * @param dashStyle 선 스타일 (0: Solid, 1: Dash, 2: Dot, 3: Dash dot, 4: Dash dot dot) diff --git a/Android/app/src/main/java/com/dct/app/domain/model/chart/DownsamplingAlgorithms.kt b/Android/app/src/main/java/com/dct/app/domain/model/chart/DownsamplingAlgorithms.kt index ab40ec1..4526a4b 100644 --- a/Android/app/src/main/java/com/dct/app/domain/model/chart/DownsamplingAlgorithms.kt +++ b/Android/app/src/main/java/com/dct/app/domain/model/chart/DownsamplingAlgorithms.kt @@ -122,84 +122,51 @@ object DownsamplingAlgorithms { val result = ArrayList>(width * 4) val tSpan = if (tMax - tMin > 1e-9) tMax - tMin else 1.0 - // 버킷 단위로 샘플링 + // Track primitive indices; allocate points only for the emitted bucket extrema. var currentBucket = -1 - var firstInBucket: Pair? = null - var minInBucket: Pair? = null - var maxInBucket: Pair? = null - var lastInBucket: Pair? = null - var minTime = 0.0 - var maxTime = 0.0 + var first = -1 + var minimum = -1 + var maximum = -1 + var last = -1 + val ordered = IntArray(4) fun flushBucket() { - if (firstInBucket != null) { - val f = firstInBucket!! - val mn = minInBucket ?: f - val mx = maxInBucket ?: f - val l = lastInBucket ?: f - - result.add(f) - - if (mode == InterpolationMode.LINEAR) { - // Linear 모드: 1픽셀 내 인위적 Min-Max 왕복으로 인한 수직 절벽선(Pulse화) 방지 - // 시간 발생 순서에 따라 중간 극값 1개만 배치하거나 끝점만 연결하여 시간 순서 보존 - if (minTime != maxTime) { - if (minTime < maxTime) { - if (mn != f && mn != l) result.add(mn) - if (mx != f && mx != l && mx != mn) result.add(mx) - } else { - if (mx != f && mx != l) result.add(mx) - if (mn != f && mn != l && mn != mx) result.add(mn) - } - } - if (l != f && l != mn && l != mx) { - result.add(l) - } - } else { - // PWM_STEP (Pulse 모드): 스텝 신호의 상하한 엣지를 명확히 표현 - if (minTime < maxTime) { - if (mn != f && mn != l) result.add(mn) - if (mx != f && mx != l && mx != mn) result.add(mx) - } else { - if (mx != f && mx != l) result.add(mx) - if (mn != f && mn != l && mn != mx) result.add(mn) - } - if (l != f && l != mn && l != mx) { - result.add(l) - } + if (first >= 0) { + ordered[0] = first + ordered[1] = minimum + ordered[2] = maximum + ordered[3] = last + ordered.sort() + var previous = -1 + for (index in ordered) { + if (index != previous) result.add(buffer.getTimestampAt(index) to buffer.getValueAt(index)) + previous = index } } - firstInBucket = null - minInBucket = null - maxInBucket = null - lastInBucket = null + first = -1 } for (i in firstIdx..lastIdx) { val t = buffer.getTimestampAt(i) val v = buffer.getValueAt(i) + if (!t.isFinite() || !v.isFinite()) { + flushBucket() + if (result.lastOrNull()?.second?.isFinite() != false) result.add(t to Double.NaN) + currentBucket = -1 + continue + } val bucket = (((t - tMin) / tSpan) * width).toInt().coerceIn(0, width - 1) - if (bucket != currentBucket) { flushBucket() currentBucket = bucket - firstInBucket = Pair(t, v) - minInBucket = Pair(t, v) - maxInBucket = Pair(t, v) - lastInBucket = Pair(t, v) - minTime = t - maxTime = t + first = i + minimum = i + maximum = i } else { - lastInBucket = Pair(t, v) - if (v < (minInBucket?.second ?: Double.MAX_VALUE)) { - minInBucket = Pair(t, v) - minTime = t - } - if (v > (maxInBucket?.second ?: -Double.MAX_VALUE)) { - maxInBucket = Pair(t, v) - maxTime = t - } + if (v < buffer.getValueAt(minimum)) minimum = i + if (v > buffer.getValueAt(maximum)) maximum = i } + last = i } flushBucket() diff --git a/Android/app/src/main/java/com/dct/app/domain/model/chart/MultiAxisCoordinateTransformer.kt b/Android/app/src/main/java/com/dct/app/domain/model/chart/MultiAxisCoordinateTransformer.kt index c0a4efd..a4082d3 100644 --- a/Android/app/src/main/java/com/dct/app/domain/model/chart/MultiAxisCoordinateTransformer.kt +++ b/Android/app/src/main/java/com/dct/app/domain/model/chart/MultiAxisCoordinateTransformer.kt @@ -23,7 +23,8 @@ data class PlotBounds( object MultiAxisCoordinateTransformer { const val MAX_LEFT_AXIS = 4 - const val DEFAULT_AXIS_WIDTH_PX = 54f + /** Additional horizontal space reserved for each Y axis. */ + const val DEFAULT_AXIS_WIDTH_PX = 72f /** * 시간(t) -> 스크린 X 픽셀 변환 (공통 시간축) @@ -72,6 +73,21 @@ object MultiAxisCoordinateTransformer { return plotBottom - (ratio * plotHeight).toFloat() } + /** Keep an in-range signal's full stroke clear of the plot border without changing axis coordinates. */ + fun valueToVisibleStrokeY( + value: Double, + vMin: Double, + vMax: Double, + plotTop: Float, + plotHeight: Float, + strokeWidth: Float + ): Float { + val screenY = valueToScreenY(value, vMin, vMax, plotTop, plotHeight) + if (value < vMin || value > vMax || plotHeight <= 0f || !screenY.isFinite()) return screenY + val inset = (strokeWidth / 2f + 1.5f).coerceIn(0f, plotHeight / 2f) + return screenY.coerceIn(plotTop + inset, plotTop + plotHeight - inset) + } + /** * 스크린 Y 픽셀 -> 특정 축의 물리값(v) 역변환 */ diff --git a/Android/app/src/main/java/com/dct/app/domain/model/chart/TimeAxisViewport.kt b/Android/app/src/main/java/com/dct/app/domain/model/chart/TimeAxisViewport.kt index 01a5613..60aa7f1 100644 --- a/Android/app/src/main/java/com/dct/app/domain/model/chart/TimeAxisViewport.kt +++ b/Android/app/src/main/java/com/dct/app/domain/model/chart/TimeAxisViewport.kt @@ -1,49 +1,86 @@ package com.dct.app.domain.model.chart -/** Per-window range: fixed presets fill initially, then roll continuously at the live edge. */ +/** Per-window range: fixed presets fill initially, roll at the live edge, or accumulate in Hold mode. */ internal class TimeAxisViewport { data class Range(val min: Double, val max: Double) private var range: Range? = null val currentRange: Range? get() = range + /** True when the range was explicitly pinned by a Manual/navigation action. */ + val isLockedToRange: Boolean get() = manualPin private var hadData = false private var lastSeen: Double? = null + private var manualPin = false - fun reset() { range = null; hadData = false; lastSeen = null } + fun reset() { + range = null + hadData = false + lastSeen = null + manualPin = false + } - fun pin(min: Double, max: Double, latest: Double? = null) { + /** + * Pins a range. [lockToRange] is used for an explicit Manual range so the + * first sample arriving after the dialog is confirmed cannot replace it + * with the normal initial-fill range. + */ + fun pin(min: Double, max: Double, latest: Double? = null, lockToRange: Boolean = false) { range = Range(min, max) hadData = latest != null lastSeen = latest + manualPin = lockToRange + } + + /** Hold retains the first sample and the initial span; it grows only at the right edge. */ + private fun initialAccumulatedRange(first: Double, last: Double, minimumSpan: Double, step: Double): Range { + val right = first + minimumSpan.coerceAtLeast(1.0) + // A large initial batch must fit as well, without multiple render-frame jumps. + return Range(first, if (last >= right) last + step else right) } fun resolve(oldest: Double?, latest: Double?, now: Double, span: Double, preset: TimeWindowPreset, auto: Boolean, follow: Boolean, width: Float): Range { val previous = range - if (!follow && previous != null) return previous val hasData = oldest != null && latest != null + val hold = preset.isHold + // A manually entered range is authoritative even when the first data + // sample arrives after it was entered (hasData changes false -> true). + if (manualPin && !hold && !follow && previous != null) { + hadData = hasData + lastSeen = latest + return previous + } + // Hold expands at a boundary even though it does not roll the left edge. + // Manual/navigation ranges remain frozen, as in the PC application. + if (!hold && !follow && previous != null && hasData == hadData) return previous val first = oldest ?: now val last = latest ?: now val step = preset.tickIntervalSec ?: ChartAxisTicks.automaticInterval( - ((previous?.max ?: last) - first).coerceAtLeast(1.0), width + (if (auto || hold) (previous?.max ?: last) - first else span).coerceAtLeast(1.0), width ).seconds val next = when { previous == null || hasData != hadData -> { - if (auto && hasData) Range(first, last + step) - else if (!follow || preset.tickIntervalSec == null) Range(last - span, last) + if (hold && hasData) initialAccumulatedRange(first, last, span, step) + else if (auto && hasData) Range(first, last + step) else { val right = maxOf(first + span, last) Range(right - span, right) } } !hasData -> previous + hold -> Range(first, if (last >= previous.max && latest != lastSeen) last + step else previous.max) auto -> Range(first, if (last >= previous.max) last + step else previous.max) latest == lastSeen -> previous - preset.tickIntervalSec != null -> { - // Tick spacing controls labels only, not scrolling. Once the initial range - // fills, keep the latest sample at the right edge without opening a blank tick. - if (last >= previous.max) Range(last - span, last) else previous + else -> { + // PC GraphWnd/MultiXGraphWnd reserve one selected tick when + // data reaches the right edge, then hold until it fills again. + // Preserve the active span (including a user zoom) on each jump. + if (last >= previous.max) { + // At extreme zoom a normal tick can exceed the entire + // visible span. Keep the newest point inside the viewport. + val right = last + minOf(step, span * 0.5) + Range(right - span, right) + } else previous } - else -> Range(last - span, last) } range = next hadData = hasData diff --git a/Android/app/src/main/java/com/dct/app/domain/model/chart/TimeWindowPreset.kt b/Android/app/src/main/java/com/dct/app/domain/model/chart/TimeWindowPreset.kt index 0f0a0d8..5ce55a1 100644 --- a/Android/app/src/main/java/com/dct/app/domain/model/chart/TimeWindowPreset.kt +++ b/Android/app/src/main/java/com/dct/app/domain/model/chart/TimeWindowPreset.kt @@ -9,6 +9,8 @@ enum class TimeWindowPreset( val displayName: String ) { AUTO(0.0, "Auto"), + /** Accumulate the whole recording and keep its first/last sample in view. */ + HOLD(-2.0, "Hold"), SEC_10(10.0, "10s"), SEC_30(30.0, "30s"), MIN_1(60.0, "1m"), @@ -33,8 +35,11 @@ enum class TimeWindowPreset( /** PC ClearGraphPointData starts with six intervals. */ val resetSpanSec: Double get() = tickIntervalSec?.times(6.0) ?: 60.0 + val isHold: Boolean get() = this == HOLD + companion object { fun fromDurationSec(durationSec: Double): TimeWindowPreset { + if (kotlin.math.abs(durationSec - HOLD.durationSec) < 0.1) return HOLD return entries.firstOrNull { it != AUTO && it != MANUAL && kotlin.math.abs(it.durationSec - durationSec) < 0.1 } ?: if (durationSec <= 0.0) AUTO else MANUAL } diff --git a/Android/app/src/main/java/com/dct/app/domain/model/dashboard/TileWidgetItem.kt b/Android/app/src/main/java/com/dct/app/domain/model/dashboard/TileWidgetItem.kt index fccf40b..5f7bad3 100644 --- a/Android/app/src/main/java/com/dct/app/domain/model/dashboard/TileWidgetItem.kt +++ b/Android/app/src/main/java/com/dct/app/domain/model/dashboard/TileWidgetItem.kt @@ -1,6 +1,7 @@ package com.dct.app.domain.model.dashboard import com.dct.app.data.ringbuffer.TimeSeriesRingBuffer +import com.dct.app.core.theme.PcColorRef import java.util.Locale /** @@ -10,7 +11,7 @@ import java.util.Locale * @param signalName 화면에 표시될 신호명 * @param value 현재 측정 물리값 * @param unit 물리 단위 - * @param color 신호 고유 테마 색상 (ARGB Long) + * @param color 신호 고유 테마 색상 (PC Win32 COLORREF `0x00BBGGRR`) * @param realLen 소수점 자릿수 정밀도 (0: 정수, 1: 소수점 1자리, 2: 2자리, >=3: 3자리) * @param minLimit 절대 최소 위험 임계치 (Danger) * @param maxLimit 절대 최대 위험 임계치 (Danger) @@ -23,7 +24,7 @@ data class TileWidgetItem( val signalName: String, val value: Double = 0.0, val unit: String = "", - val color: Long = 0xFF1976D2L, + val color: Long = PcColorRef.DEFAULT_DASHBOARD, val realLen: Int = 1, val minLimit: Double? = null, val maxLimit: Double? = null, diff --git a/Android/app/src/main/java/com/dct/app/domain/model/formula/FormulaDefinition.kt b/Android/app/src/main/java/com/dct/app/domain/model/formula/FormulaDefinition.kt index 8032bc6..1d0b2c4 100644 --- a/Android/app/src/main/java/com/dct/app/domain/model/formula/FormulaDefinition.kt +++ b/Android/app/src/main/java/com/dct/app/domain/model/formula/FormulaDefinition.kt @@ -1,5 +1,6 @@ package com.dct.app.domain.model.formula +import com.dct.app.core.theme.PcColorRef import java.util.UUID /** @@ -10,7 +11,7 @@ data class FormulaDefinition( val name: String, val expression: String, val unit: String = "", - val color: Long = 0xFF2196F3, + val color: Long = PcColorRef.DEFAULT_SIGNAL, val isForSm: Boolean = false, val description: String = "" -) \ No newline at end of file +) diff --git a/Android/app/src/main/java/com/dct/app/domain/model/mdb/CanSignalKey.kt b/Android/app/src/main/java/com/dct/app/domain/model/mdb/CanSignalKey.kt new file mode 100644 index 0000000..7b932de --- /dev/null +++ b/Android/app/src/main/java/com/dct/app/domain/model/mdb/CanSignalKey.kt @@ -0,0 +1,40 @@ +package com.dct.app.domain.model.mdb + +/** DBC 파싱 순서에 영향받지 않는 CAN 신호 식별자와 구형 프로젝트 키 호환 처리. */ +object CanSignalKey { + fun stable(signal: SignalDefinition): String = "can_${signal.messageId}_${signal.name}" + + fun legacy(signal: SignalDefinition): String = "can_${signal.idx}" + + /** + * Match persisted CAN identifiers without depending on parser order or + * formatting details. Older workspaces used `can__`, while + * newer ones use `can__`; hand-edited files occasionally + * contain hexadecimal message IDs. All of these are aliases for the same + * DBC signal and must feed the same graph series. + */ + fun matches(key: String, signal: SignalDefinition): Boolean { + val candidate = key.trim() + if (candidate.equals(stable(signal), ignoreCase = true) || + candidate.equals(legacy(signal), ignoreCase = true) || + candidate.equals("can_${signal.idx}_${signal.name}", ignoreCase = true) || + candidate.equals(signal.name, ignoreCase = true) || + candidate.equals("can_${signal.name}", ignoreCase = true) + ) return true + + val body = candidate.removePrefixIgnoreCase("can_") ?: return false + val separator = body.indexOf('_') + if (separator <= 0 || separator == body.lastIndex) return false + val idToken = body.substring(0, separator) + val nameToken = body.substring(separator + 1) + val id = idToken.removePrefixIgnoreCase("0x")?.toLongOrNull(16) + ?: idToken.toLongOrNull() + return id == signal.messageId && nameToken.equals(signal.name, ignoreCase = true) + } + + private fun String.removePrefixIgnoreCase(prefix: String): String? = + if (startsWith(prefix, ignoreCase = true)) substring(prefix.length) else null + + fun find(key: String, signals: List): SignalDefinition? = + signals.firstOrNull { matches(key, it) } +} diff --git a/Android/app/src/main/java/com/dct/app/domain/model/mdb/MdbModels.kt b/Android/app/src/main/java/com/dct/app/domain/model/mdb/MdbModels.kt index d1bc934..4d3b8be 100644 --- a/Android/app/src/main/java/com/dct/app/domain/model/mdb/MdbModels.kt +++ b/Android/app/src/main/java/com/dct/app/domain/model/mdb/MdbModels.kt @@ -116,8 +116,19 @@ data class SignalDefinition( * PC MFC CVarLstItemData::m_nRealLen 및 ReportMDBSignalFormView 100% 일치 표시 포맷팅 */ fun formatMeasurementValue(value: Double): String { - val safe = Regex("%[0-9]{0,2}\\.[0-9]{1,2}[fFeEgG]").matches(measurementFormat) - return String.format(java.util.Locale.US, if (safe) measurementFormat else "%.3f", value).trim() + // ASAP2는 "%8.4"처럼 변환 문자가 생략된 FORMAT도 허용한다. + // PC A2L 화면은 물리값을 0.001 단위로 반올림한 뒤 해당 FORMAT을 적용한다. + val normalized = when { + Regex("%[0-9]{0,2}\\.[0-9]{1,2}[fFeEgG]").matches(measurementFormat) -> measurementFormat + Regex("%[0-9]{0,2}\\.[0-9]{1,2}").matches(measurementFormat) -> "${measurementFormat}f" + else -> "%.3f" + } + val formattedValue = if (a2lDataType != null) { + kotlin.math.round(value * 1000.0) / 1000.0 + } else { + value + } + return String.format(java.util.Locale.US, normalized, formattedValue).trim() } fun formatRawHex(rawValue: Long?): String { diff --git a/Android/app/src/main/java/com/dct/app/domain/model/project/DpcProject.kt b/Android/app/src/main/java/com/dct/app/domain/model/project/DpcProject.kt index 7c8e6be..89d5012 100644 --- a/Android/app/src/main/java/com/dct/app/domain/model/project/DpcProject.kt +++ b/Android/app/src/main/java/com/dct/app/domain/model/project/DpcProject.kt @@ -1,6 +1,7 @@ package com.dct.app.domain.model.project import android.net.Uri +import com.dct.app.core.theme.PcColorRef /** * UDS ISO 14229 표준 ECU 진단 식별자(DID) 구성 정보 @@ -32,7 +33,7 @@ data class DpcSensorModuleChannel( val isVoltage: Boolean = false, // false = Temperature ('C), true = Voltage (V) val unit: String = if (isVoltage) "V" else "'C", val description: String = "", - val color: Long = 0xFF009A4CL + val color: Long = PcColorRef.DEFAULT_SENSOR ) /** diff --git a/Android/app/src/main/java/com/dct/app/domain/model/project/DpcWorkspaceState.kt b/Android/app/src/main/java/com/dct/app/domain/model/project/DpcWorkspaceState.kt index 55651f3..ae4d86d 100644 --- a/Android/app/src/main/java/com/dct/app/domain/model/project/DpcWorkspaceState.kt +++ b/Android/app/src/main/java/com/dct/app/domain/model/project/DpcWorkspaceState.kt @@ -85,5 +85,8 @@ data class DpcChartState( val isAutoTimeWindow: Boolean = false, val isXAxisAuto: Boolean = true, val isYAxisAuto: Boolean = true, - val legendPosition: Int = 0 + val legendPosition: Int = 0, + /** Explicit Manual X-axis bounds in epoch-like seconds, when configured. */ + val xAxisMinSec: Double? = null, + val xAxisMaxSec: Double? = null ) diff --git a/Android/app/src/main/java/com/dct/app/domain/model/protocol/DctCommand.kt b/Android/app/src/main/java/com/dct/app/domain/model/protocol/DctCommand.kt index 3eb21d6..0a1b948 100644 --- a/Android/app/src/main/java/com/dct/app/domain/model/protocol/DctCommand.kt +++ b/Android/app/src/main/java/com/dct/app/domain/model/protocol/DctCommand.kt @@ -108,6 +108,17 @@ enum class DctCommand(val code: Byte, val description: String) { return unsigned >= 0x80 } + /** Stream frames are unsolicited data, not responses to a pending request. */ + fun isStreamResponse(code: Byte): Boolean = when (code) { + SEND_CAN_FRAME.code, + SEND_SM_DATA.code, + SEND_XCP_MEASUREMENT_DATA.code -> true + else -> false + } + + /** True only for a response which should match a request waiter. */ + fun isSolicitedResponse(code: Byte): Boolean = isResponse(code) && !isStreamResponse(code) + fun toHexString(code: Byte): String { return String.format("0x%02X", code.toInt() and 0xFF) } diff --git a/Android/app/src/main/java/com/dct/app/domain/model/protocol/DctMeasurementConfiguration.kt b/Android/app/src/main/java/com/dct/app/domain/model/protocol/DctMeasurementConfiguration.kt index 4060f51..6853543 100644 --- a/Android/app/src/main/java/com/dct/app/domain/model/protocol/DctMeasurementConfiguration.kt +++ b/Android/app/src/main/java/com/dct/app/domain/model/protocol/DctMeasurementConfiguration.kt @@ -1,14 +1,18 @@ package com.dct.app.domain.model.protocol +import com.dct.app.data.parser.A2lParser import com.dct.app.domain.model.mdb.SignalDefinition import com.dct.app.domain.model.project.DpcSensorModuleConfig /** 실제 DCT 장비에 측정 시작 전 적용할 프로젝트 설정의 불변 스냅샷. */ data class DctMeasurementConfiguration( + val projectName: String = "", val vehicleCode: String = "", val maker: Int = 2, val mdbSignals: List = emptyList(), val selectedMdbSignals: List = mdbSignals, + val a2lSignals: List = emptyList(), + val xcpIfData: A2lParser.XcpIfData? = null, val canMessages: List = emptyList(), val sensorModules: List = emptyList(), val isHighSpeedCan: Boolean = true, diff --git a/Android/app/src/main/java/com/dct/app/domain/model/protocol/DctPacket.kt b/Android/app/src/main/java/com/dct/app/domain/model/protocol/DctPacket.kt index 64d8c1e..2fdb476 100644 --- a/Android/app/src/main/java/com/dct/app/domain/model/protocol/DctPacket.kt +++ b/Android/app/src/main/java/com/dct/app/domain/model/protocol/DctPacket.kt @@ -10,7 +10,18 @@ data class DctPacket( val command: Byte, val payload: ByteArray = ByteArray(0), - val sequence: Int = 0 + val sequence: Int = 0, + // Local transport metadata only; deliberately excluded from wire encoding and equality. + val transportSessionId: Long = 0, + val receivedAtMillis: Long = 0, + /** Monotonic ingress clock captured for this individual decoded frame. */ + val receivedAtNanos: Long = 0, + /** Epoch-like seconds derived from a monotonic clock/wall-clock pair. */ + val receivedAtSec: Double = Double.NaN, + /** Process-local ingress order; never serialized on the DCT wire. */ + val ingressSequence: Long = 0, + /** Repository-side decoded stream payload cache; never serialized or compared. */ + val decodedStreamPayload: Any? = null ) { val commandEnum: DctCommand? get() = DctCommand.fromByte(command) diff --git a/Android/app/src/main/java/com/dct/app/domain/repository/PacketRepository.kt b/Android/app/src/main/java/com/dct/app/domain/repository/PacketRepository.kt index e03c41f..64824e1 100644 --- a/Android/app/src/main/java/com/dct/app/domain/repository/PacketRepository.kt +++ b/Android/app/src/main/java/com/dct/app/domain/repository/PacketRepository.kt @@ -33,6 +33,17 @@ interface PacketRepository { */ suspend fun connect(target: String = ""): DctResult + /** + * PC 핸드셰이크의 0x09/0x12 설정 교환에 사용할 초기 프로젝트 설정을 + * 다음 connect 호출에 전달합니다. 프로젝트를 아직 READY 상태로 만들기 + * 전에는 configureMeasurement를 호출할 수 없으므로, 연결 전에 이 값을 + * 별도로 주입하는 방식으로 초기 CAN/SM payload를 완성합니다. + * + * 기본 구현은 연결 대상이 설정을 지원하지 않는 테스트/대체 레포지토리에서도 + * 기존 API가 그대로 동작하도록 no-op입니다. + */ + fun setInitialMeasurementConfiguration(configuration: DctMeasurementConfiguration) {} + /** * 연결을 종료합니다. */ diff --git a/Android/app/src/main/java/com/dct/app/domain/usecase/chart/ChartBatchRenderer.kt b/Android/app/src/main/java/com/dct/app/domain/usecase/chart/ChartBatchRenderer.kt index 545c64b..911619b 100644 --- a/Android/app/src/main/java/com/dct/app/domain/usecase/chart/ChartBatchRenderer.kt +++ b/Android/app/src/main/java/com/dct/app/domain/usecase/chart/ChartBatchRenderer.kt @@ -1,6 +1,9 @@ package com.dct.app.domain.usecase.chart import com.dct.app.core.common.DispatcherProvider +import com.dct.app.core.common.PacketPipelineMetrics +import com.dct.app.core.theme.PcColorRef +import com.dct.app.core.theme.toColorRef import com.dct.app.data.ringbuffer.TimeSeriesRingBuffer import com.dct.app.domain.model.chart.AxisPosition import com.dct.app.domain.model.chart.ChartAxisConfig @@ -29,13 +32,28 @@ import java.util.concurrent.atomic.AtomicBoolean */ class ChartBatchRenderer( private val dispatcherProvider: DispatcherProvider, - val throttleIntervalMs: Long = 16L + val throttleIntervalMs: Long = 16L, + private val pipelineMetrics: PacketPipelineMetrics = PacketPipelineMetrics() ) : MultiAxisChartController { private val _seriesMap = ConcurrentHashMap() private val seriesOrder = java.util.concurrent.CopyOnWriteArrayList() private val _axesMap = ConcurrentHashMap() + /** + * Range/mode captured before the first navigation gesture on an axis. + * This is deliberately transient: project files persist the current axis + * configuration, while the undo target belongs to the current interaction + * session only. + */ + private data class AxisNavigationBaseline( + val minVal: Double, + val maxVal: Double, + val isAuto: Boolean + ) + + private val axisNavigationBaselines = mutableMapOf() + private val _seriesListFlow = MutableStateFlow>(emptyList()) override val seriesListFlow: StateFlow> = _seriesListFlow.asStateFlow() @@ -44,7 +62,7 @@ class ChartBatchRenderer( minVal = -50.0, maxVal = 100.0, isAuto = true, - color = 0xFF424242L, + color = PcColorRef.DEFAULT_NEUTRAL, unit = "", label = "LEFT_0", tickCount = 5, @@ -104,12 +122,132 @@ class ChartBatchRenderer( var screenWidthPx: Int = 1200 // 렌더링 기준 가시 픽셀 폭 private var renderJob: Job? = null + private var renderScope: CoroutineScope? = null + private var pendingSnapshotJob: Job? = null + private var renderLoopRequested = false + @Volatile private var renderingEnabled = true + private var windowVisible = true + private var applicationVisible = true + + /** Visibility affects rendering only; data continues accumulating on hidden pages. */ + @Synchronized + fun setRenderingEnabled(enabled: Boolean) { + windowVisible = enabled + updateRenderingEnabled() + } + + /** Background execution keeps samples/logging, but does not spend CPU drawing invisible charts. */ + @Synchronized + fun setApplicationVisible(visible: Boolean) { + applicationVisible = visible + updateRenderingEnabled() + } + + private fun updateRenderingEnabled() { + val enabled = windowVisible && applicationVisible + if (renderingEnabled == enabled) return + renderingEnabled = enabled + if (enabled) { + if (renderLoopRequested) renderScope?.let { start(it) } + requestSnapshot() + } else { + renderJob?.cancel() + pendingSnapshotJob?.cancel() + pendingSnapshotJob = null + } + } + + fun setScreenWidth(width: Int) { + val next = width.coerceAtLeast(10) + if (screenWidthPx != next) { screenWidthPx = next; requestSnapshot() } + } + + @Synchronized + private fun requestSnapshot() { + markDirty() + if (!renderingEnabled) return + val scope = renderScope + if (scope == null) { + // Standalone controllers/tests retain synchronous publication semantics. + val startedNanos = System.nanoTime() + _renderSnapshotFlow.value = buildSnapshot(screenWidthPx) + pipelineMetrics.recordRender(System.nanoTime() - startedNanos) + } else if (renderJob?.isActive != true && pendingSnapshotJob?.isActive != true) { + val job = scope.launch(dispatcherProvider.default, start = kotlinx.coroutines.CoroutineStart.LAZY) { + val startedNanos = System.nanoTime() + synchronized(this@ChartBatchRenderer) { + if (renderingEnabled && isDirty.compareAndSet(true, false)) { + _renderSnapshotFlow.value = buildSnapshot(screenWidthPx) + } + pendingSnapshotJob = null + if (renderingEnabled && isDirty.get()) requestSnapshot() + } + pipelineMetrics.recordRender(System.nanoTime() - startedNanos) + } + pendingSnapshotJob = job + job.start() + } + } + + @Synchronized + fun dispose() { + renderingEnabled = false + stop() + pendingSnapshotJob?.cancel() + renderScope = null + cursorSamples = emptyMap() + axisNavigationBaselines.clear() + _seriesMap.clear() + seriesOrder.clear() + _seriesListFlow.value = emptyList() + _renderSnapshotFlow.value = _renderSnapshotFlow.value.copy(seriesList = emptyList()) + } private val timeViewport = TimeAxisViewport() - private data class NavigationHome(val span: Double, val preset: TimeWindowPreset, val auto: Boolean) + private data class NavigationHome( + val span: Double, + val preset: TimeWindowPreset, + val auto: Boolean, + /** Non-null only when the pre-navigation state was an explicit Manual range. */ + val manualRange: TimeAxisViewport.Range? = null + ) private var navigationHome: NavigationHome? = null + /** + * Return the range currently visible for an axis. Auto axes are materialized + * in the last render snapshot, whereas manual axes are already authoritative + * in [_axesMap]. + */ + private fun effectiveAxisConfig(position: AxisPosition, configured: ChartAxisConfig): ChartAxisConfig { + if (!configured.isAuto) return configured + val rendered = _renderSnapshotFlow.value.axesConfig[position] + return if (rendered != null && isValidChartAxisRange(rendered.minVal, rendered.maxVal)) { + rendered + } else { + configured + } + } + + private fun rememberAxisNavigationBaseline( + position: AxisPosition, + configured: ChartAxisConfig, + effective: ChartAxisConfig + ) { + if (axisNavigationBaselines[position] == null) { + axisNavigationBaselines[position] = AxisNavigationBaseline( + minVal = effective.minVal, + maxVal = effective.maxVal, + isAuto = configured.isAuto + ) + } + } + private fun rememberNavigationHome() { - if (navigationHome == null) navigationHome = NavigationHome(timeWindowSec, xAxisPreset, isAutoTimeWindow) + if (navigationHome == null) { + val manualRange = if (xAxisPreset == TimeWindowPreset.MANUAL && !isXAxisAuto && timeViewport.isLockedToRange) { + timeViewport.currentRange?.let { TimeAxisViewport.Range(it.min + panOffsetSec, it.max + panOffsetSec) } + } else null + navigationHome = NavigationHome(timeWindowSec, xAxisPreset, isAutoTimeWindow, manualRange) + } } private fun allBoundAxesAuto(): Boolean { @@ -120,9 +258,18 @@ class ChartBatchRenderer( private fun latestTimestamp(): Double? = _seriesMap.values.mapNotNull { it.buffer.getLatestTimestamp() }.maxOrNull() + /** Navigation reads only range metadata, never rebuilds point lists on the input thread. */ + private fun navigationRange(): TimeAxisViewport.Range { + val oldest = _seriesMap.values.mapNotNull { it.buffer.getOldestTimestamp() }.minOrNull() + val range = timeViewport.resolve(oldest, latestTimestamp(), System.currentTimeMillis() / 1000.0, + timeWindowSec.takeIf { it > 0 } ?: 60.0, xAxisPreset, isAutoTimeWindow, + isXAxisAuto, screenWidthPx.toFloat()) + return TimeAxisViewport.Range(range.min + panOffsetSec, range.max + panOffsetSec) + } + init { for (pos in AxisPosition.entries) { - val defaultColor = if (pos.isLeft) 0xFF1976D2L else 0xFF388E3CL + val defaultColor = if (pos.isLeft) PcColorRef.DEFAULT_DASHBOARD else PcColorRef.fromRgb(0x38, 0x8E, 0x3C) _axesMap[pos] = ChartAxisConfig( position = pos, minVal = -50.0, @@ -149,14 +296,27 @@ class ChartBatchRenderer( } fun start(scope: CoroutineScope): Job { + renderScope = scope + renderLoopRequested = true renderJob?.cancel() val job = scope.launch(dispatcherProvider.default) { while (isActive) { val startTime = System.currentTimeMillis() - if (isDirty.compareAndSet(true, false) || !isPaused) { + // A frame is only needed after data or navigation state changed. + // Rebuilding every 16 ms while measuring used to resample every + // bound ring buffer even when the sender had stopped. With a + // large workspace this produced sustained allocations/GC and + // could starve the main thread (ANR) after playback stopped. + // addSample()/markDirty()/resume() already invalidate the + // snapshot for all state changes, so an idle measuring session + // must not force a full rebuild. + if (renderingEnabled && isDirty.compareAndSet(true, false)) { + val buildStartedNanos = System.nanoTime() synchronized(this@ChartBatchRenderer) { _renderSnapshotFlow.value = buildSnapshot(screenWidthPx) } + pipelineMetrics.recordRender(System.nanoTime() - buildStartedNanos) + pipelineMetrics.maybeLog("ChartBatchRenderer") } val elapsed = System.currentTimeMillis() - startTime @@ -165,10 +325,12 @@ class ChartBatchRenderer( } } renderJob = job + if (!renderingEnabled) job.cancel() return job } fun stop() { + renderLoopRequested = false pause() renderJob?.cancel() renderJob = null @@ -180,18 +342,28 @@ class ChartBatchRenderer( axes = _axesMap.values.sortedBy { it.position.ordinal }, timeWindowSec = timeWindowSec, timeWindowPreset = xAxisPreset, isAutoTimeWindow = isAutoTimeWindow, isXAxisAuto = isXAxisAuto, - isYAxisAuto = allBoundAxesAuto(), legendPosition = legendPosition + isYAxisAuto = allBoundAxesAuto(), legendPosition = legendPosition, + // Persist the actually visible manual interval so reopening a project + // restores the same X-axis bounds instead of freezing a new range. + xAxisMinSec = if (xAxisPreset == TimeWindowPreset.MANUAL && !isXAxisAuto && timeViewport.isLockedToRange) { + timeViewport.currentRange?.let { it.min + panOffsetSec } + } else null, + xAxisMaxSec = if (xAxisPreset == TimeWindowPreset.MANUAL && !isXAxisAuto && timeViewport.isLockedToRange) { + timeViewport.currentRange?.let { it.max + panOffsetSec } + } else null ) @Synchronized fun restoreWorkspaceState( signals: List, - state: com.dct.app.domain.model.project.DpcChartState? + state: com.dct.app.domain.model.project.DpcChartState?, + maxSignals: Int? = null ) { navigationHome = null + axisNavigationBaselines.clear() _seriesMap.clear() seriesOrder.clear() - signals.forEach { s -> + signals.asSequence().take(maxSignals ?: Int.MAX_VALUE).forEach { s -> // A fresh buffer is deliberate: project files contain layout, not a recording. bindSignal(s.signalId, s.signalName, s.axisPosition, s.color, s.unit, s.interpolationMode, s.lineWidthDp) _seriesMap[s.signalId] = _seriesMap.getValue(s.signalId).copy(dashStyle = s.dashStyle, isVisible = s.isVisible) @@ -205,6 +377,15 @@ class ChartBatchRenderer( xAxisPreset = it.timeWindowPreset isAutoTimeWindow = it.isAutoTimeWindow isXAxisAuto = it.isXAxisAuto + // New project files may contain an explicit Manual interval. Keep + // it authoritative even though the signal buffers are intentionally + // empty after restoring a workspace. + val savedMin = it.xAxisMinSec + val savedMax = it.xAxisMaxSec + if (xAxisPreset == TimeWindowPreset.MANUAL && !isXAxisAuto && + savedMin != null && savedMax != null && isValidChartAxisRange(savedMin, savedMax)) { + timeWindowSec = savedMax - savedMin + } if (it.axes.isEmpty()) _axesMap.replaceAll { _, axis -> axis.copy(isAuto = it.isYAxisAuto) } legendPosition = it.legendPosition } @@ -212,6 +393,14 @@ class ChartBatchRenderer( cursorSamples = emptyMap() panOffsetSec = 0.0 timeViewport.reset() + state?.let { + val savedMin = it.xAxisMinSec + val savedMax = it.xAxisMaxSec + if (it.timeWindowPreset == TimeWindowPreset.MANUAL && !it.isXAxisAuto && + savedMin != null && savedMax != null && isValidChartAxisRange(savedMin, savedMax)) { + timeViewport.pin(savedMin, savedMax, latestTimestamp(), lockToRange = true) + } + } updateSeriesList() markDirty() } @@ -226,7 +415,7 @@ class ChartBatchRenderer( signalId = signalId, signalName = signalId, axisPosition = position, - color = color.toLong() and 0xFFFFFFFFL + color = color.toLong().toColorRef() ) } @@ -246,7 +435,7 @@ class ChartBatchRenderer( signalId = signalId, signalName = signalName, axisPosition = axisPosition, - color = color, + color = color.toColorRef(), unit = unit, lineWidthDp = lineWidthDp, dashStyle = 0, @@ -256,13 +445,14 @@ class ChartBatchRenderer( ) _seriesMap[signalId] = series seriesOrder.addIfAbsent(signalId) + axisNavigationBaselines.remove(axisPosition) _axesMap[axisPosition]?.let { val finalMin = if (minVal != null && maxVal != null && maxVal > minVal) minVal else it.minVal val finalMax = if (minVal != null && maxVal != null && maxVal > minVal) maxVal else it.maxVal _axesMap[axisPosition] = it.copy( isVisible = true, - color = color, + color = color.toColorRef(), unit = unit, label = signalName, minVal = finalMin, @@ -274,10 +464,43 @@ class ChartBatchRenderer( markDirty() } + /** 소스 교체의 ID 이동을 한 번에 반영한다. 순서·축 옵션·미변경 신호의 버퍼는 유지한다. */ + @Synchronized + fun replaceSignalBindings(signals: List) { + val replacement = signals.distinctBy { it.signalId } + val unchangedIds = replacement.filter { _seriesMap[it.signalId]?.buffer === it.buffer } + .map { it.signalId }.toSet() + val affectedAxes = (_seriesMap.values.filter { it.signalId !in unchangedIds } + + replacement.filter { it.signalId !in unchangedIds }).map { it.axisPosition }.toSet() + _seriesMap.clear() + seriesOrder.clear() + replacement.forEach { series -> + _seriesMap[series.signalId] = series + seriesOrder.add(series.signalId) + } + affectedAxes.forEach { position -> + axisNavigationBaselines.remove(position) + _axesMap[position]?.let { axis -> + val series = replacement.lastOrNull { it.axisPosition == position } + _axesMap[position] = if (series != null) { + axis.copy(isVisible = true, color = series.color, unit = series.unit, label = series.signalName) + } else axis.copy(isVisible = position == AxisPosition.LEFT_0, + color = PcColorRef.DEFAULT_NEUTRAL) + } + } + // 같은 ID가 다른 정의로 재사용돼도 이전 파형/커서 값이 새 신호에 붙지 않는다. + _renderSnapshotFlow.value = _renderSnapshotFlow.value.copy( + seriesList = _renderSnapshotFlow.value.seriesList.filter { it.signalId in unchangedIds }) + cursorSamples = cursorSamples.filterKeys { it in unchangedIds } + updateSeriesList() + markDirty() + } + override fun unbindSignal(signalId: String) { val removed = _seriesMap.remove(signalId) seriesOrder.remove(signalId) if (removed != null) { + axisNavigationBaselines.remove(removed.axisPosition) val remaining = _seriesMap.values.find { it.axisPosition == removed.axisPosition } if (remaining != null) { // 해당 축에 다른 신호가 남아있다면 그 신호의 고유 색상으로 축 색상 동기화 @@ -291,12 +514,12 @@ class ChartBatchRenderer( } else { // LEFT_0 축에 더 이상 신호가 없으면 기본 회색으로 복원 _axesMap[AxisPosition.LEFT_0]?.let { - _axesMap[AxisPosition.LEFT_0] = it.copy(color = 0xFF424242L) + _axesMap[AxisPosition.LEFT_0] = it.copy(color = PcColorRef.DEFAULT_NEUTRAL) } } } updateSeriesList() - _renderSnapshotFlow.value = buildSnapshot(screenWidthPx) + requestSnapshot() markDirty() } @@ -305,19 +528,45 @@ class ChartBatchRenderer( } override fun addSampleSec(signalId: String, timestampSec: Double, rawValue: Double) { + addSampleSec(signalId, timestampSec, rawValue, ingressSequence = 0L) + } + + /** + * Adds a sample with a transport ingress sequence. Production network + * frames use this overload so multiple same-read frames are retained even + * when their timestamps are less than the legacy 1ms duplicate threshold. + */ + fun addSampleSec(signalId: String, timestampSec: Double, rawValue: Double, ingressSequence: Long) { if (!timestampSec.isFinite()) return val series = _seriesMap[signalId] ?: return - series.buffer.addSample(timestampSec, rawValue) + series.buffer.addSample(timestampSec, rawValue, forceAppend = ingressSequence > 0L) + pipelineMetrics.graphSamples.incrementAndGet() + markDirty() + } + + override fun markDataGap(timestampSec: Double) { + if (!timestampSec.isFinite()) return + for (series in _seriesMap.values) { + series.buffer.addGap(timestampSec) + } markDirty() } override fun setTimeWindow(durationMs: Long) { + if (durationMs == TimeWindowPreset.HOLD.durationMs) { + setTimeWindow(TimeWindowPreset.HOLD) + return + } setTimeWindow(durationMs.toDouble() / 1000.0) } @Synchronized fun setTimeWindow(durationSec: Double) { if (!durationSec.isFinite()) return + if (kotlin.math.abs(durationSec - TimeWindowPreset.HOLD.durationSec) < 0.1) { + setTimeWindow(TimeWindowPreset.HOLD) + return + } navigationHome = null if (durationSec <= 0.0) { isAutoTimeWindow = true @@ -330,11 +579,7 @@ class ChartBatchRenderer( isXAxisAuto = true panOffsetSec = 0.0 timeViewport.reset() - val latest = latestTimestamp() - if (!isAutoTimeWindow && latest != null) { - timeViewport.pin(latest - timeWindowSec, latest, latest) - } - _renderSnapshotFlow.value = buildSnapshot(screenWidthPx) + requestSnapshot() markDirty() } @@ -346,16 +591,14 @@ class ChartBatchRenderer( } navigationHome = null xAxisPreset = preset - isXAxisAuto = true + // Hold accumulates without rolling: keep the first sample at the left + // and grow the right edge by a tick only when the data reaches it. + isXAxisAuto = !preset.isHold isAutoTimeWindow = preset == TimeWindowPreset.AUTO timeWindowSec = preset.displaySpanSec panOffsetSec = 0.0 timeViewport.reset() - val latest = latestTimestamp() - if (!isAutoTimeWindow && latest != null) { - timeViewport.pin(latest - timeWindowSec, latest, latest) - } - _renderSnapshotFlow.value = buildSnapshot(screenWidthPx) + requestSnapshot() markDirty() } @@ -366,43 +609,70 @@ class ChartBatchRenderer( @Synchronized override fun setXAxisManualMode() { rememberNavigationHome() - val current = buildSnapshot(screenWidthPx) - timeWindowSec = current.timeMaxSec - current.timeMinSec - timeViewport.pin(current.timeMinSec, current.timeMaxSec) + val current = navigationRange() + timeWindowSec = current.max - current.min + timeViewport.pin(current.min, current.max, latestTimestamp(), lockToRange = true) panOffsetSec = 0.0 isXAxisAuto = false xAxisPreset = TimeWindowPreset.MANUAL isAutoTimeWindow = false - _renderSnapshotFlow.value = buildSnapshot(screenWidthPx) + requestSnapshot() markDirty() } + /** + * Apply an explicit X-axis interval entered by the user. The interval is + * independent of the newest sample, so live data continues to accumulate + * while the visible range remains fixed until the user selects Auto or + * changes the range again. + */ + @Synchronized + override fun setXAxisManualRange(minSec: Double, maxSec: Double): Boolean { + if (!isValidChartAxisRange(minSec, maxSec)) return false + val span = maxSec - minSec + if (!span.isFinite() || span < 0.001) return false + rememberNavigationHome() + timeWindowSec = span + timeViewport.pin(minSec, maxSec, latestTimestamp(), lockToRange = true) + panOffsetSec = 0.0 + isXAxisAuto = false + xAxisPreset = TimeWindowPreset.MANUAL + isAutoTimeWindow = false + requestSnapshot() + markDirty() + return true + } + + @Synchronized override fun setYAxisAuto(isAuto: Boolean) { + axisNavigationBaselines.clear() for ((pos, config) in _axesMap) { _axesMap[pos] = config.copy(isAuto = isAuto) } - _renderSnapshotFlow.value = buildSnapshot(screenWidthPx) + requestSnapshot() markDirty() } + @Synchronized override fun setYAxisRange(minVal: Double, maxVal: Double) { if (!isValidChartAxisRange(minVal, maxVal)) return + axisNavigationBaselines.clear() for ((pos, config) in _axesMap) { _axesMap[pos] = config.copy(minVal = minVal, maxVal = maxVal, isAuto = false) } - _renderSnapshotFlow.value = buildSnapshot(screenWidthPx) + requestSnapshot() markDirty() } override fun setLegendPosition(position: Int) { this.legendPosition = position.coerceIn(0, 3) - _renderSnapshotFlow.value = buildSnapshot(screenWidthPx) + requestSnapshot() markDirty() } override fun setPaused(paused: Boolean) { this.isPaused = paused - _renderSnapshotFlow.value = buildSnapshot(screenWidthPx) + requestSnapshot() markDirty() } @@ -412,42 +682,128 @@ class ChartBatchRenderer( cursorSamples = this.crosshairTimeSec?.let { time -> _seriesMap.mapValues { (_, series) -> CursorSamples(series.buffer, series.buffer.captureAt(time)) } } ?: emptyMap() - _renderSnapshotFlow.value = buildSnapshot(screenWidthPx) + requestSnapshot() markDirty() } + @Synchronized override fun setAxisRange(position: AxisPosition, minVal: Double, maxVal: Double, isAuto: Boolean) { if (!isValidChartAxisRange(minVal, maxVal)) return val axis = _axesMap[position] ?: return + axisNavigationBaselines.remove(position) _axesMap[position] = axis.copy( minVal = minVal, maxVal = maxVal, isAuto = isAuto ) - _renderSnapshotFlow.value = buildSnapshot(screenWidthPx) + requestSnapshot() markDirty() } + @Synchronized override fun setAxisMinMax(position: AxisPosition, minVal: Double, maxVal: Double) { if (!isValidChartAxisRange(minVal, maxVal)) return val axis = _axesMap[position] ?: return + axisNavigationBaselines.remove(position) _axesMap[position] = axis.copy(minVal = minVal, maxVal = maxVal) - _renderSnapshotFlow.value = buildSnapshot(screenWidthPx) + requestSnapshot() markDirty() } + @Synchronized override fun setAxisAuto(position: AxisPosition, isAuto: Boolean) { val axis = _axesMap[position] ?: return + axisNavigationBaselines.remove(position) _axesMap[position] = axis.copy(isAuto = isAuto) - _renderSnapshotFlow.value = buildSnapshot(screenWidthPx) + requestSnapshot() + markDirty() + } + + @Synchronized + override fun panYAxis(position: AxisPosition, deltaFraction: Double) { + if (!deltaFraction.isFinite() || kotlin.math.abs(deltaFraction) < 1e-12) return + val configuredAxis = _axesMap[position] ?: return + // Auto axes are materialized from visible samples in the last snapshot; + // using the configured [-50, 100] fallback here would cause a jump on + // the first drag for signals with a different physical range. + val axis = effectiveAxisConfig(position, configuredAxis) + rememberAxisNavigationBaseline(position, configuredAxis, axis) + + // The default vertical axis is non-inverted: a downward drag increases + // both numeric bounds, matching the PC CChartAxis::PanAxis behavior. + val shift = axis.span * deltaFraction + if (!shift.isFinite()) return + val nextMin = axis.minVal + shift + val nextMax = axis.maxVal + shift + if (!isValidChartAxisRange(nextMin, nextMax)) return + + _axesMap[position] = configuredAxis.copy( + minVal = nextMin, + maxVal = nextMax, + isAuto = false + ) + requestSnapshot() + markDirty() + } + + @Synchronized + override fun zoomYAxis(position: AxisPosition, factor: Double, anchorFraction: Double) { + if (!factor.isFinite() || factor <= 0.0 || kotlin.math.abs(factor - 1.0) < 1e-9) return + val configuredAxis = _axesMap[position] ?: return + val axis = effectiveAxisConfig(position, configuredAxis) + rememberAxisNavigationBaseline(position, configuredAxis, axis) + + // Compose's pinch scale is used as-is: factor > 1 is pinch-out/zoom-in + // and therefore narrows the visible data span. Keep the centroid value + // fixed so the gesture has the same anchor semantics as the PC wheel. + val anchor = anchorFraction.coerceIn(0.0, 1.0) + val anchorValue = axis.minVal + axis.span * anchor + val nextSpan = (axis.span / factor).coerceAtLeast(1e-12) + if (!anchorValue.isFinite() || !nextSpan.isFinite()) return + val nextMin = anchorValue - nextSpan * anchor + val nextMax = anchorValue + nextSpan * (1.0 - anchor) + if (!isValidChartAxisRange(nextMin, nextMax)) return + + _axesMap[position] = configuredAxis.copy( + minVal = nextMin, + maxVal = nextMax, + isAuto = false + ) + requestSnapshot() + markDirty() + } + + @Synchronized + override fun resetYAxis(position: AxisPosition) { + val configuredAxis = _axesMap[position] ?: return + val baseline = axisNavigationBaselines.remove(position) ?: return + if (!isValidChartAxisRange(baseline.minVal, baseline.maxVal)) return + + _axesMap[position] = configuredAxis.copy( + minVal = baseline.minVal, + maxVal = baseline.maxVal, + isAuto = baseline.isAuto + ) + requestSnapshot() markDirty() } + /** + * Preserve the pre-existing center-based API for older callers and tests. + * Internally it is expressed as an anchored zoom at the center, with the + * inverse factor because [zoomYAxis] receives a pinch scale. + */ + @Synchronized + override fun scaleYAxis(position: AxisPosition, factor: Double) { + if (!factor.isFinite() || factor <= 0.0) return + zoomYAxis(position, 1.0 / factor, 0.5) + } + override fun setSeriesVisible(signalId: String, isVisible: Boolean) { val series = _seriesMap[signalId] ?: return _seriesMap[signalId] = series.copy(isVisible = isVisible) updateSeriesList() - _renderSnapshotFlow.value = buildSnapshot(screenWidthPx) + requestSnapshot() markDirty() } @@ -473,21 +829,21 @@ class ChartBatchRenderer( } } else { _axesMap[AxisPosition.LEFT_0]?.let { - _axesMap[AxisPosition.LEFT_0] = it.copy(color = 0xFF424242L) + _axesMap[AxisPosition.LEFT_0] = it.copy(color = PcColorRef.DEFAULT_NEUTRAL) } } } updateSeriesList() - _renderSnapshotFlow.value = buildSnapshot(screenWidthPx) + requestSnapshot() markDirty() } override fun setSeriesColor(signalId: String, color: Long) { val series = _seriesMap[signalId] ?: return - _seriesMap[signalId] = series.copy(color = color) + _seriesMap[signalId] = series.copy(color = color.toColorRef()) _axesMap[series.axisPosition]?.let { axis -> - _axesMap[series.axisPosition] = axis.copy(color = color) + _axesMap[series.axisPosition] = axis.copy(color = color.toColorRef()) } updateSeriesList() markDirty() @@ -517,12 +873,16 @@ class ChartBatchRenderer( @Synchronized override fun panTimeWindow(deltaSec: Double) { if (!deltaSec.isFinite() || deltaSec == 0.0) return + // Hold always keeps the accumulated first-to-last range in one screen; + // panning would move an endpoint out of view. Switch to Manual first + // when historical browsing is required. + if (xAxisPreset.isHold) return // Already following: a forward drag must not freeze or replace the live preset. if (deltaSec > 0.0 && isXAxisAuto) return rememberNavigationHome() if (isXAxisAuto) setXAxisManualMode() val baseRight = timeViewport.currentRange?.max - ?: (buildSnapshot(screenWidthPx).timeMaxSec - panOffsetSec) + ?: (navigationRange().max - panOffsetSec) val latest = latestTimestamp() val maxOffset = ((latest ?: baseRight) - baseRight).coerceAtLeast(0.0) val nextOffset = panOffsetSec + deltaSec @@ -532,7 +892,7 @@ class ChartBatchRenderer( // Measurement stop/disconnect cancels the render loop. Navigation must still publish // frames from retained samples; markDirty alone has no consumer in that state. if (resumedFollowing || renderJob?.isActive != true) { - _renderSnapshotFlow.value = buildSnapshot(screenWidthPx) + requestSnapshot() } markDirty() } @@ -546,7 +906,7 @@ class ChartBatchRenderer( isXAxisAuto = true isAutoTimeWindow = false xAxisPreset = TimeWindowPreset.MANUAL // Custom span: adaptive ticks and continuous rolling. - timeViewport.pin(latest - timeWindowSec, latest, latest) + timeViewport.pin(latest - timeWindowSec, latest, latest, lockToRange = false) return true } @@ -554,14 +914,14 @@ class ChartBatchRenderer( fun zoomTimeWindow(factor: Float, anchorFraction: Double) { if (!factor.isFinite() || factor <= 0f || !anchorFraction.isFinite()) return if (kotlin.math.abs(factor - 1f) < 1e-6f) return - val snapshot = buildSnapshot(screenWidthPx) - val oldSpan = snapshot.timeMaxSec - snapshot.timeMinSec + val range = navigationRange() + val oldSpan = range.max - range.min if (!oldSpan.isFinite() || oldSpan <= 0) return rememberNavigationHome() val newSpan = (oldSpan / factor).coerceIn(0.05, 86400.0) - val anchor = snapshot.timeMinSec + oldSpan * anchorFraction.coerceIn(0.0, 1.0) + val anchor = range.min + oldSpan * anchorFraction.coerceIn(0.0, 1.0) val newMin = anchor - newSpan * anchorFraction.coerceIn(0.0, 1.0) - timeViewport.pin(newMin, newMin + newSpan) + timeViewport.pin(newMin, newMin + newSpan, latestTimestamp(), lockToRange = true) panOffsetSec = 0.0 timeWindowSec = newSpan isAutoTimeWindow = false @@ -569,7 +929,7 @@ class ChartBatchRenderer( xAxisPreset = TimeWindowPreset.MANUAL // Keep the pinch anchor in history; snap to live only when its right edge is reached. followLatestIfReached(newMin + newSpan, latestTimestamp()) - _renderSnapshotFlow.value = buildSnapshot(screenWidthPx) + requestSnapshot() markDirty() } @@ -583,29 +943,47 @@ class ChartBatchRenderer( @Synchronized override fun resetPan() { - // Double-tap restores the pre-navigation X scale and returns to live tracking. + // Double-tap restores the pre-navigation X scale and returns to live tracking + // (or accumulated Hold mode when that was the selected preset). // Y-axis configuration, signal styles and buffered samples remain untouched. - val home = navigationHome ?: NavigationHome(timeWindowSec, xAxisPreset, isAutoTimeWindow) + val home = navigationHome ?: NavigationHome( + timeWindowSec, + xAxisPreset, + isAutoTimeWindow, + if (xAxisPreset == TimeWindowPreset.MANUAL && !isXAxisAuto && timeViewport.isLockedToRange) { + timeViewport.currentRange?.let { TimeAxisViewport.Range(it.min + panOffsetSec, it.max + panOffsetSec) } + } else null + ) timeWindowSec = home.span xAxisPreset = home.preset isAutoTimeWindow = home.auto - isXAxisAuto = true + isXAxisAuto = if (home.manualRange != null && home.preset == TimeWindowPreset.MANUAL) { + false + } else { + home.preset != TimeWindowPreset.HOLD + } navigationHome = null panOffsetSec = 0.0 crosshairTimeSec = null cursorSamples = emptyMap() timeViewport.reset() - val latest = latestTimestamp() - if (!isAutoTimeWindow && latest != null) { - timeViewport.pin(latest - timeWindowSec, latest, latest) + home.manualRange?.let { + timeViewport.pin(it.min, it.max, latestTimestamp(), lockToRange = true) } - _renderSnapshotFlow.value = buildSnapshot(screenWidthPx) + // Resolve from the first retained sample: fill left-to-right before rolling. + requestSnapshot() markDirty() } @Synchronized override fun clearAllData() { navigationHome = null + // A data reset changes the auto-axis reference samples; an undo target + // captured from the previous recording is no longer meaningful. + axisNavigationBaselines.clear() + val preservedManualRange = if (xAxisPreset == TimeWindowPreset.MANUAL && !isXAxisAuto && timeViewport.isLockedToRange) { + timeViewport.currentRange?.let { TimeAxisViewport.Range(it.min + panOffsetSec, it.max + panOffsetSec) } + } else null for (series in _seriesMap.values) { series.buffer.clear() } @@ -614,12 +992,52 @@ class ChartBatchRenderer( panOffsetSec = 0.0 if (xAxisPreset.tickIntervalSec != null) timeWindowSec = xAxisPreset.resetSpanSec timeViewport.reset() + preservedManualRange?.let { + timeViewport.pin(it.min, it.max, latestTimestamp(), lockToRange = true) + } + requestSnapshot() markDirty() } + /** + * Removes only the samples currently drawn by this renderer. Signal + * bindings, colors, axes, interpolation, legend and X/Y-axis selections + * are deliberately left untouched for the top-level Graph reset action. + */ + @Synchronized + fun clearSamplesOnly() { + // Clearing samples must not silently change an explicitly entered + // Manual range; only the point data is reset by this action. + val preservedManualRange = if (xAxisPreset == TimeWindowPreset.MANUAL && !isXAxisAuto && timeViewport.isLockedToRange) { + timeViewport.currentRange?.let { TimeAxisViewport.Range(it.min + panOffsetSec, it.max + panOffsetSec) } + } else null + for (series in _seriesMap.values) { + series.buffer.clear() + } + crosshairTimeSec = null + cursorSamples = emptyMap() + // The next sample starts a fresh range using the existing axis options. + timeViewport.reset() + preservedManualRange?.let { + panOffsetSec = 0.0 + timeViewport.pin(it.min, it.max, latestTimestamp(), lockToRange = true) + } + requestSnapshot() + } + private fun updateSeriesList() { _seriesListFlow.value = seriesOrder.mapNotNull { _seriesMap[it] } - _renderSnapshotFlow.value = buildSnapshot(screenWidthPx) + // Style/visibility changes remain immediate even on hidden pages. Reuse geometry. + val previous = _renderSnapshotFlow.value + val oldSeries = previous.seriesList.associateBy { it.signalId } + _renderSnapshotFlow.value = previous.copy(seriesList = _seriesListFlow.value.map { s -> + val old = oldSeries[s.signalId] + SeriesRenderData(s.signalId, s.signalName, s.axisPosition, s.color, s.unit, + s.lineWidthDp, s.dashStyle, s.interpolationMode, s.isVisible, + old?.points ?: emptyList(), old?.latestValue, + old?.minVisibleValue, old?.maxVisibleValue, old?.cursorValue) + }) + requestSnapshot() } @Synchronized @@ -682,19 +1100,21 @@ class ChartBatchRenderer( var minVal: Double? = null var maxVal: Double? = null - val finitePoints = points.filter { it.second.isFinite() } - if (finitePoints.isNotEmpty()) { + if (points.isNotEmpty()) { var mn = Double.MAX_VALUE var mx = -Double.MAX_VALUE - for (p in finitePoints) { + for (p in points) { + if (!p.second.isFinite()) continue if (p.second < mn) mn = p.second if (p.second > mx) mx = p.second } - minVal = mn - maxVal = mx + minVal = mn.takeIf { it != Double.MAX_VALUE } + maxVal = mx.takeIf { it != -Double.MAX_VALUE } val current = axisMinMaxMap[s.axisPosition] - if (current == null) { + if (minVal == null || maxVal == null) { + // Gap-only ranges do not contribute to axis extrema. + } else if (current == null) { axisMinMaxMap[s.axisPosition] = Pair(mn, mx) } else { axisMinMaxMap[s.axisPosition] = Pair( diff --git a/Android/app/src/main/java/com/dct/app/domain/usecase/chart/ChartTelemetryResolver.kt b/Android/app/src/main/java/com/dct/app/domain/usecase/chart/ChartTelemetryResolver.kt new file mode 100644 index 0000000..cbf3bf9 --- /dev/null +++ b/Android/app/src/main/java/com/dct/app/domain/usecase/chart/ChartTelemetryResolver.kt @@ -0,0 +1,78 @@ +package com.dct.app.domain.usecase.chart + +import com.dct.app.domain.model.chart.ChartSignalSeries +import com.dct.app.domain.model.mdb.CanSignalKey +import com.dct.app.domain.model.mdb.MdbDatabase +import com.dct.app.domain.model.mdb.SignalDefinition + +/** Single packet-consumer cache; UI/source changes are supplied as immutable snapshots. */ +internal class ChartTelemetryResolver { + private var mdbSource: MdbDatabase? = null + private var canSource: List = emptyList() + private var a2lSource: List = emptyList() + private var mdbSignals: List = emptyList() + private val aliases = mutableMapOf, List>() + + /** A replacement file may reuse names/IDs, so cached matches cannot outlive its definitions. */ + fun updateSources( + mdb: MdbDatabase?, + can: List, + a2l: List + ) { + if (mdbSource === mdb && canSource === can && a2lSource === a2l) return + if (mdbSource !== mdb) mdbSignals = mdb?.allSignals.orEmpty() + mdbSource = mdb + canSource = can + a2lSource = a2l + aliases.clear() + } + + /** Only this packet's keys are eligible; cached telemetry must never become a fresh sample. */ + fun updatedValue( + series: ChartSignalSeries, + values: Map, + updatedSignals: Set + ): Double? { + if (series.signalId in updatedSignals) values[series.signalId]?.let { return it } + if (series.signalName in updatedSignals) values[series.signalName]?.let { return it } + val keys = aliases.getOrPut(series.signalId to series.signalName) { + resolveAliases(series.signalId, series.signalName) + } + for (key in keys) { + if (key in updatedSignals) values[key]?.let { return it } + } + return null + } + + /** Preserve the existing CAN → MDB → A2L alias precedence and first-definition matching. */ + private fun resolveAliases(id: String, name: String): List = buildList { + val can = CanSignalKey.find(id, canSource) ?: CanSignalKey.find(name, canSource) + if (can != null) { + add(CanSignalKey.stable(can)) + add(CanSignalKey.legacy(can)) + add("can_${can.idx}_${can.name}") + add(can.name) + add("can_${can.name}") + } + val mdb = mdbSignals.firstOrNull { signal -> + id.equals("sig_${signal.reclid}_${signal.idx}", ignoreCase = true) || + name.equals(signal.name, ignoreCase = true) || + id.equals(signal.name, ignoreCase = true) + } + if (mdb != null) { + add("sig_${mdb.reclid}_${mdb.idx}") + add(mdb.name) + } + val a2l = a2lSource.firstOrNull { signal -> + id.equals("a2l_${signal.adl}", ignoreCase = true) || + id.equals("sig_0_${signal.idx}", ignoreCase = true) || + id.equals(signal.name, ignoreCase = true) || + name.equals(signal.name, ignoreCase = true) + } + if (a2l != null) { + add("a2l_${a2l.adl}") + add("sig_0_${a2l.idx}") + add(a2l.name) + } + } +} diff --git a/Android/app/src/main/java/com/dct/app/domain/usecase/chart/MultiAxisChartController.kt b/Android/app/src/main/java/com/dct/app/domain/usecase/chart/MultiAxisChartController.kt index f401634..0408dd9 100644 --- a/Android/app/src/main/java/com/dct/app/domain/usecase/chart/MultiAxisChartController.kt +++ b/Android/app/src/main/java/com/dct/app/domain/usecase/chart/MultiAxisChartController.kt @@ -33,6 +33,9 @@ interface MultiAxisChartController { fun addSampleSec(signalId: String, timestampSec: Double, rawValue: Double) + /** Insert a NaN time marker so the next sample is not joined to the previous segment. */ + fun markDataGap(timestampSec: Double) + fun setTimeWindow(durationMs: Long) fun setTimeWindow(preset: TimeWindowPreset) @@ -47,6 +50,32 @@ interface MultiAxisChartController { fun setAxisAuto(position: AxisPosition, isAuto: Boolean) + /** + * Move one Y axis without changing its visible span. + * + * [deltaFraction] is the vertical drag expressed as a fraction of the + * current axis height. Positive values represent a downward drag, which + * increases the numeric range on the default (non-inverted) Y axis. + */ + fun panYAxis(position: AxisPosition, deltaFraction: Double) + + /** + * Zoom one Y axis around an anchor. The factor has the same semantics as + * Compose's pinch scale: greater than one narrows the visible range + * (pinch-out/zoom-in), while a value below one expands it. + */ + fun zoomYAxis(position: AxisPosition, factor: Double, anchorFraction: Double = 0.5) + + /** Restore only the selected Y axis to the state before its navigation gesture. */ + fun resetYAxis(position: AxisPosition) + + /** + * Legacy center-based scale API kept for callers that still use the old + * gesture adapter. A factor greater than one expands the visible range; + * a factor below one narrows it and switches the axis to manual mode. + */ + fun scaleYAxis(position: AxisPosition, factor: Double) + fun setSeriesVisible(signalId: String, isVisible: Boolean) fun setSeriesColor(signalId: String, color: Long) @@ -63,6 +92,9 @@ interface MultiAxisChartController { fun setXAxisManualMode() + /** Pin an explicit X-axis interval (seconds) and disable live following. */ + fun setXAxisManualRange(minSec: Double, maxSec: Double): Boolean + fun setYAxisAuto(isAuto: Boolean) fun setYAxisRange(minVal: Double, maxVal: Double) diff --git a/Android/app/src/main/java/com/dct/app/presentation/chart/GraphYAxisDialog.kt b/Android/app/src/main/java/com/dct/app/presentation/chart/GraphYAxisDialog.kt index fcaaa7d..4e28aea 100644 --- a/Android/app/src/main/java/com/dct/app/presentation/chart/GraphYAxisDialog.kt +++ b/Android/app/src/main/java/com/dct/app/presentation/chart/GraphYAxisDialog.kt @@ -1,23 +1,44 @@ package com.dct.app.presentation.chart -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.* +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.* -import androidx.compose.runtime.* +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.compose.ui.window.Dialog -import com.dct.app.domain.model.chart.chartAxisInputText import com.dct.app.domain.model.chart.isValidChartAxisRange /** - * PC C++ CGraphYAxisDlg 모달 대화상자 (영문 UI) + * Android counterpart of the PC CGraphYAxisDlg. + * + * The PC dialog edits signed 32-bit integer bounds and only applies them after + * OK. Auto/manual selection itself is kept in the graph menu, so this dialog + * intentionally has no Auto Scale checkbox. */ +@Suppress("UNUSED_PARAMETER") @Composable fun GraphYAxisDialog( signalName: String, @@ -27,9 +48,9 @@ fun GraphYAxisDialog( onDismiss: () -> Unit, onConfirm: (minVal: Double, maxVal: Double, isAuto: Boolean) -> Unit ) { - var minText by remember { mutableStateOf(chartAxisInputText(currentMin)) } - var maxText by remember { mutableStateOf(chartAxisInputText(currentMax)) } - var autoScale by remember { mutableStateOf(isAuto) } + // CGraphYAxisDlg::SetMinMax receives int32_t values before showing them. + var minText by remember { mutableStateOf(pcAxisInputText(currentMin)) } + var maxText by remember { mutableStateOf(pcAxisInputText(currentMax)) } var errorMessage by remember { mutableStateOf(null) } Dialog(onDismissRequest = onDismiss) { @@ -44,75 +65,53 @@ fun GraphYAxisDialog( .fillMaxWidth() .padding(20.dp) ) { - // Header Text( text = "Y-Axis Range ($signalName)", fontSize = 15.sp, fontWeight = FontWeight.Bold, color = Color(0xFF0F172A) ) + Spacer(modifier = Modifier.height(6.dp)) + Text( + text = "Enter integer minimum and maximum.", + fontSize = 11.sp, + color = Color(0xFF64748B) + ) + Spacer(modifier = Modifier.height(14.dp)) - Spacer(modifier = Modifier.height(16.dp)) - - // Auto Scale Checkbox - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.fillMaxWidth() - ) { - Checkbox( - checked = autoScale, - onCheckedChange = { autoScale = it } - ) - Spacer(modifier = Modifier.width(4.dp)) - Text( - text = "Auto Scale", - fontSize = 13.sp, - color = Color(0xFF334155) - ) - } - - Spacer(modifier = Modifier.height(12.dp)) - - // Max Field + // PC presents Min before Max and casts the initial values to int32_t. OutlinedTextField( - value = maxText, + value = minText, onValueChange = { - maxText = it + minText = it errorMessage = null }, - label = { Text("Max") }, - enabled = !autoScale, + label = { Text("Min") }, singleLine = true, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), modifier = Modifier.fillMaxWidth() ) Spacer(modifier = Modifier.height(10.dp)) - // Min Field OutlinedTextField( - value = minText, + value = maxText, onValueChange = { - minText = it + maxText = it errorMessage = null }, - label = { Text("Min") }, - enabled = !autoScale, + label = { Text("Max") }, singleLine = true, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), modifier = Modifier.fillMaxWidth() ) - if (errorMessage != null) { + errorMessage?.let { message -> Spacer(modifier = Modifier.height(6.dp)) - Text( - text = errorMessage ?: "", - fontSize = 11.sp, - color = Color(0xFFEF4444) - ) + Text(text = message, fontSize = 11.sp, color = Color(0xFFEF4444)) } Spacer(modifier = Modifier.height(20.dp)) - - // Bottom Buttons Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End, @@ -121,31 +120,28 @@ fun GraphYAxisDialog( TextButton(onClick = onDismiss) { Text("Cancel", color = Color(0xFF64748B)) } - Spacer(modifier = Modifier.width(8.dp)) - Button( onClick = { - if (autoScale) { - onConfirm(currentMin, currentMax, true) - onDismiss() - } else { - val parsedMin = minText.toDoubleOrNull() - val parsedMax = maxText.toDoubleOrNull() - - if (parsedMin == null || parsedMax == null || !parsedMin.isFinite() || !parsedMax.isFinite()) { - errorMessage = "Please enter valid numbers." - } else if (!isValidChartAxisRange(parsedMin, parsedMax)) { - errorMessage = "Min must be less than Max." - } else { - onConfirm(parsedMin, parsedMax, false) + val parsedMin = minText.trim().toIntOrNull() + val parsedMax = maxText.trim().toIntOrNull() + when { + parsedMin == null || parsedMax == null -> { + errorMessage = "Please input the correct min/max value." + } + !isValidChartAxisRange(parsedMin.toDouble(), parsedMax.toDouble()) -> { + errorMessage = "Please input the correct min/max value." + } + else -> { + // Selecting Manual in the PC menu is what + // changes the axis mode; an accepted dialog + // value is therefore always NotAutomatic. + onConfirm(parsedMin.toDouble(), parsedMax.toDouble(), false) onDismiss() } } }, - colors = ButtonDefaults.buttonColors( - containerColor = Color(0xFF2563EB) - ) + colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF2563EB)) ) { Text("OK", color = Color.White) } @@ -154,3 +150,10 @@ fun GraphYAxisDialog( } } } + +private fun pcAxisInputText(value: Double): String { + if (!value.isFinite()) return "" + return value.toLong() + .coerceIn(Int.MIN_VALUE.toLong(), Int.MAX_VALUE.toLong()) + .toString() +} diff --git a/Android/app/src/main/java/com/dct/app/presentation/chart/MultiXGraphCanvas.kt b/Android/app/src/main/java/com/dct/app/presentation/chart/MultiXGraphCanvas.kt index 9a06824..eed1e15 100644 --- a/Android/app/src/main/java/com/dct/app/presentation/chart/MultiXGraphCanvas.kt +++ b/Android/app/src/main/java/com/dct/app/presentation/chart/MultiXGraphCanvas.kt @@ -11,9 +11,14 @@ import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableLongStateOf +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberUpdatedState -import androidx.compose.runtime.getValue +import androidx.compose.runtime.setValue +import androidx.compose.runtime.withFrameNanos import androidx.compose.foundation.gestures.calculateZoom import androidx.compose.foundation.gestures.calculateCentroid import androidx.compose.ui.Modifier @@ -26,6 +31,7 @@ import androidx.compose.ui.graphics.Path import androidx.compose.ui.graphics.PathEffect import androidx.compose.ui.graphics.StrokeCap import androidx.compose.ui.graphics.StrokeJoin +import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.graphics.drawscope.DrawScope import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.graphics.drawscope.clipRect @@ -36,10 +42,12 @@ import androidx.compose.foundation.gestures.awaitFirstDown import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.layout.positionInRoot +import androidx.compose.ui.unit.IntSize import com.dct.app.presentation.monitor.components.LocalWorkspaceTouchNavigation -import com.dct.app.core.theme.toAndroidColorInt import com.dct.app.core.theme.toComposeColor +import com.dct.app.core.theme.PcColorRef import com.dct.app.domain.model.chart.ChartAxisTicks import com.dct.app.domain.model.chart.placeAxisLabels import com.dct.app.domain.model.chart.forEachChartVertex @@ -57,6 +65,9 @@ import kotlin.math.ceil import kotlin.math.floor import kotlin.math.log10 import kotlin.math.pow +import kotlin.math.exp +import kotlin.math.roundToLong +import kotlinx.coroutines.isActive /** * 실시간 시계열 차트 Canvas 컴포넌트 @@ -74,6 +85,11 @@ fun MultiXGraphCanvas( onRemoveSignal: ((String) -> Unit)? = null, onPanTime: ((Double) -> Unit)? = null, onZoomTime: ((Float, Double) -> Unit)? = null, + onYAxisPan: ((AxisPosition, Float) -> Unit)? = null, + onYAxisZoom: ((AxisPosition, Float, Float) -> Unit)? = null, + onResetYAxis: ((AxisPosition) -> Unit)? = null, + /** Kept for source compatibility with the pre-pan/pinch adapter. */ + onYAxisScale: ((AxisPosition, Float) -> Unit)? = null, onResetPan: (() -> Unit)? = null, onOpenContextMenu: ((Offset) -> Unit)? = null ) { @@ -82,6 +98,10 @@ fun MultiXGraphCanvas( val currentSnapshot by rememberUpdatedState(visibleSnapshot) val currentPan by rememberUpdatedState(onPanTime) val currentZoom by rememberUpdatedState(onZoomTime) + val currentYAxisPan by rememberUpdatedState(onYAxisPan) + val currentYAxisZoom by rememberUpdatedState(onYAxisZoom) + val currentResetYAxis by rememberUpdatedState(onResetYAxis) + val currentYAxisScale by rememberUpdatedState(onYAxisScale) val currentReset by rememberUpdatedState(onResetPan) val workspaceNavigation = LocalWorkspaceTouchNavigation.current val graphOrigin = remember { arrayOf(Offset.Zero) } @@ -116,7 +136,7 @@ fun MultiXGraphCanvas( minVal = -50.0, maxVal = 100.0, isAuto = true, - color = 0xFF424242L, + color = PcColorRef.DEFAULT_NEUTRAL, unit = "", label = "LEFT_0", tickCount = 5, @@ -125,18 +145,155 @@ fun MultiXGraphCanvas( ) } + // Keep the marquee clock independent from the high-frequency chart + // snapshot. A data packet may update the snapshot many times per + // second, but a name change/resize is the only event that should reset + // the legend animation. + var canvasSize by remember { mutableStateOf(IntSize.Zero) } + var legendMarqueeElapsedMs by remember { mutableLongStateOf(0L) } + val legendSignature = visibleSnapshot.seriesList.map { it.signalId to it.signalName } + val legendHasEntries = snapshot.legendPosition != 3 && legendSignature.isNotEmpty() && canvasSize.width > 0 + val legendLeftAxisCount = if (isMultiAxis) { + val usedPositions = visibleSnapshot.seriesList.map { it.axisPosition }.toSet() + visibleSnapshot.axesConfig.values.count { + it.isVisible && it.position.isLeft && (usedPositions.isEmpty() || it.position in usedPositions) + }.coerceAtLeast(1) + } else { + 1 + } + val legendRightAxisCount = if (isMultiAxis) { + val usedPositions = visibleSnapshot.seriesList.map { it.axisPosition }.toSet() + visibleSnapshot.axesConfig.values.count { + it.isVisible && !it.position.isLeft && it.position in usedPositions + } + } else { + 0 + } + val legendPlotBounds = if (legendHasEntries) { + val approxRightLegendWidth = if (snapshot.legendPosition == 0) { + rightLegendReservationWidth(visibleSnapshot.seriesList, canvasSize.width.toFloat(), legendPaint) + } else { + 0f + } + MultiAxisCoordinateTransformer.calculatePlotBounds( + canvasWidth = canvasSize.width.toFloat(), + canvasHeight = canvasSize.height.toFloat(), + leftAxisCount = legendLeftAxisCount, + rightAxisCount = legendRightAxisCount, + isMultiAxis = isMultiAxis, + hasLegend = true, + legendPosition = snapshot.legendPosition, + legendWidthPx = approxRightLegendWidth, + seriesCount = visibleSnapshot.seriesList.size + ) + } else { + null + } + val legendPlotWidth = legendPlotBounds?.width ?: 0f + // Multi-axis rendering places the right legend after the outer right Y + // axes, so overflow detection must use the same effective right edge as + // drawRightLegendBox rather than the raw plot bound. + val legendPlotRight = legendPlotBounds?.right?.plus( + if (isMultiAxis) legendRightAxisCount * MultiAxisCoordinateTransformer.DEFAULT_AXIS_WIDTH_PX else 0f + ) ?: Float.NaN + val legendMayOverflow = legendHasEntries && hasLegendTextOverflow( + seriesList = visibleSnapshot.seriesList, + legendPosition = snapshot.legendPosition, + plotWidth = legendPlotWidth, + canvasWidth = canvasSize.width.toFloat(), + plotRight = legendPlotRight, + paint = legendPaint + ) + + LaunchedEffect(legendSignature, snapshot.legendPosition, canvasSize) { + legendMarqueeElapsedMs = 0L + if (!legendMayOverflow) return@LaunchedEffect + + var previousFrameNanos = 0L + while (isActive) { + withFrameNanos { frameNanos -> + if (previousFrameNanos != 0L) { + // Clamp a long background/resume frame so the first frame + // does not jump through the entire marquee cycle. + val deltaMs = ((frameNanos - previousFrameNanos) / 1_000_000L) + .coerceIn(1L, 100L) + legendMarqueeElapsedMs += deltaMs + } + previousFrameNanos = frameNanos + } + } + } + + /** Resolve the same visible axis lists used by drawing and hit testing. */ + fun gestureAxes(chart: ChartRenderSnapshot): Pair, List> { + val usedPositions = chart.seriesList.filter { it.isVisible }.map { it.axisPosition }.toSet() + val left = if (isMultiAxis) { + chart.axesConfig.values + .filter { it.isVisible && it.position.isLeft && + (usedPositions.isEmpty() || it.position in usedPositions) } + .sortedBy { it.position.index } + .ifEmpty { listOf(defaultAxis) } + } else { + listOf(chart.axesConfig[AxisPosition.LEFT_0] ?: defaultAxis) + } + val right = visibleRightAxes(chart, isMultiAxis) + return left to right + } + + fun gestureBounds(chart: ChartRenderSnapshot, width: Float, height: Float): PlotBounds { + val (left, right) = gestureAxes(chart) + val hasLegend = chart.seriesList.isNotEmpty() && chart.legendPosition != 3 + val legendWidth = if (hasLegend && chart.legendPosition == 0) { + rightLegendReservationWidth(chart.seriesList, width, legendPaint) + } else { + 0f + } + return MultiAxisCoordinateTransformer.calculatePlotBounds( + canvasWidth = width, + canvasHeight = height, + leftAxisCount = left.size.coerceAtLeast(1), + rightAxisCount = right.size, + isMultiAxis = isMultiAxis, + hasLegend = hasLegend, + legendPosition = chart.legendPosition, + legendWidthPx = legendWidth, + seriesCount = chart.seriesList.size + ) + } + + fun yAxisAt(offset: Offset): AxisPosition? { + if (!isMultiAxis || canvasSize.width <= 0 || canvasSize.height <= 0) return null + val chart = currentSnapshot + val (left, right) = gestureAxes(chart) + return hitTestYAxis( + offset = offset, + leftAxes = left, + rightAxes = right, + bounds = gestureBounds(chart, canvasSize.width.toFloat(), canvasSize.height.toFloat()) + ) + } + Box( modifier = modifier .fillMaxSize() .background(Color(0xFFECEFF1)) + .onSizeChanged { canvasSize = it } .onGloballyPositioned { graphOrigin[0] = it.positionInRoot() } .pointerInput(isMultiAxis) { detectTapGestures( onLongPress = { offset -> onOpenContextMenu?.invoke(offset) }, - onDoubleTap = { - currentReset?.invoke() + onDoubleTap = { offset -> + // A double tap on a Y-axis strip resets only that axis. + // The plot area keeps the existing X/time navigation + // reset behavior. + val axis = yAxisAt(offset) + if (axis != null && currentResetYAxis != null) { + currentResetYAxis?.invoke(axis) + } else { + currentReset?.invoke() + } }, onTap = { offset -> val snapshot = currentSnapshot @@ -148,8 +305,7 @@ fun MultiXGraphCanvas( listOf(snapshot.axesConfig[AxisPosition.LEFT_0] ?: snapshot.axesConfig.values.firstOrNull() ?: defaultAxis) } val approxLegendWidth = if (snapshot.seriesList.isNotEmpty() && snapshot.legendPosition == 0) { - val maxW = snapshot.seriesList.maxOfOrNull { legendPaint.measureText(it.signalName) } ?: 60f - maxW + 36f + rightLegendReservationWidth(snapshot.seriesList, size.width.toFloat(), legendPaint) } else 0f val bounds = MultiAxisCoordinateTransformer.calculatePlotBounds( @@ -192,8 +348,7 @@ fun MultiXGraphCanvas( } val hasLegend = snapshot.seriesList.isNotEmpty() && snapshot.legendPosition != 3 val approxLegendWidth = if (hasLegend && snapshot.legendPosition == 0) { - val maxW = snapshot.seriesList.maxOfOrNull { legendPaint.measureText(it.signalName) } ?: 60f - maxW + 36f + rightLegendReservationWidth(snapshot.seriesList, size.width.toFloat(), legendPaint) } else 0f val bounds = MultiAxisCoordinateTransformer.calculatePlotBounds( @@ -208,8 +363,144 @@ fun MultiXGraphCanvas( seriesCount = snapshot.seriesList.size ) + // Y-axis gestures are resolved before the plot-area X + // pan. Each visible axis owns a narrow hit strip, so + // dragging one axis never changes another axis. + val yAxisGestureEnabled = isMultiAxis && + (currentYAxisPan != null || currentYAxisZoom != null || currentYAxisScale != null) + val rightAxes = visibleRightAxes(snapshot, isMultiAxis) + val yAxis = if (yAxisGestureEnabled) { + hitTestYAxis( + offset = down.position, + leftAxes = visibleLeftAxes, + rightAxes = rightAxes, + bounds = bounds + ) + } else null + + if (yAxis != null) { + // The workspace navigator receives the Initial pointer + // pass. Reserve only pairs that remain on this same + // axis so a two-finger pinch cannot scroll the page or + // activate another MDI window. + workspaceNavigation?.axisGestureReservation = { rootPoints -> + rootPoints.size >= 2 && rootPoints.all { rootPoint -> + val localPoint = rootPoint - graphOrigin[0] + hitTestYAxis(localPoint, visibleLeftAxes, rightAxes, bounds) == yAxis + } + } + workspaceNavigation?.singlePointerGestureReservation = { rootPoint -> + hitTestYAxis(rootPoint - graphOrigin[0], visibleLeftAxes, rightAxes, bounds) == yAxis + } + + var pendingDragY = 0f + var isAxisDragging = false + var isAxisPinching = false + var axisCanceled = false + var lastPinchDistance: Float? = null + val axisPan = currentYAxisPan + val axisZoom = currentYAxisZoom + val axisScale = currentYAxisScale + while (true) { + val event = awaitPointerEvent() + val pressed = event.changes.filter { it.pressed } + val pressedCount = pressed.size + if (workspaceNavigation?.ownsTouches == true) { + // A race with the parent navigator must never + // apply a partial axis gesture. + axisCanceled = true + break + } + + if (pressedCount > 2) { + // The axis gesture is defined for exactly two + // pointers. Do not reinterpret a third finger + // as a page/window gesture. + axisCanceled = true + event.changes.forEach { it.consume() } + break + } + + if (pressedCount == 2) { + if (!pressed.all { pointer -> + hitTestYAxis(pointer.position, visibleLeftAxes, rightAxes, bounds) == yAxis + }) { + axisCanceled = true + event.changes.forEach { it.consume() } + break + } + + isAxisPinching = true + val distance = (pressed[0].position - pressed[1].position).getDistance() + val previousDistance = lastPinchDistance + if (previousDistance != null && previousDistance > 0.5f && distance > 0.5f) { + val zoomFactor = (distance / previousDistance) + .coerceIn(0.5f, 2.0f) + if (kotlin.math.abs(zoomFactor - 1f) > 1e-4f) { + val centroidY = (pressed[0].position.y + pressed[1].position.y) / 2f + val anchorFraction = ((bounds.bottom - centroidY) / bounds.height) + .coerceIn(0f, 1f) + if (axisZoom != null) { + axisZoom.invoke(yAxis, zoomFactor, anchorFraction) + } else { + // Legacy callback uses a visible + // span multiplier, so invert the + // pinch scale before forwarding it. + axisScale?.invoke(yAxis, 1f / zoomFactor) + } + } + } + lastPinchDistance = distance + event.changes.forEach { it.consume() } + continue + } + + // Once a pinch has started, lifting one finger must + // not hand the remaining pointer to one-finger pan. + if (isAxisPinching) { + lastPinchDistance = null + event.changes.forEach { it.consume() } + if (pressedCount == 0) break + continue + } + + val change = event.changes.firstOrNull { it.id == down.id } ?: break + if (!change.pressed) break + + var dragAmountY = change.position.y - change.previousPosition.y + if (!isAxisDragging) { + pendingDragY += dragAmountY + if (kotlin.math.abs(pendingDragY) <= viewConfiguration.touchSlop) continue + isAxisDragging = true + dragAmountY = pendingDragY - kotlin.math.sign(pendingDragY) * viewConfiguration.touchSlop + } + + if (dragAmountY != 0f) { + if (axisPan != null && bounds.height > 1f) { + // Positive delta means a downward drag. + // The renderer converts this fraction to a + // PC-compatible numeric range translation. + axisPan.invoke(yAxis, dragAmountY / bounds.height) + } else { + // Keep old callers functional until they + // migrate to the explicit pan callback. + val factor = exp((-dragAmountY / Y_AXIS_SCALE_PIXELS).toDouble()) + .coerceIn(0.5, 2.0) + .toFloat() + axisScale?.invoke(yAxis, factor) + } + change.consume() + } + } + if (!axisCanceled) return@awaitEachGesture + } + // 터치 다운이 차트 플롯 영역 내부일 때 부모 스크롤을 막고 X축 패닝을 우선 수행 if (bounds.isDrawable && bounds.width >= 120f && bounds.height >= 60f && down.position.x in bounds.left..bounds.right && down.position.y in bounds.top..bounds.bottom) { + workspaceNavigation?.singlePointerGestureReservation = { rootPoint -> + val local = rootPoint - graphOrigin[0] + local.x in bounds.left..bounds.right && local.y in bounds.top..bounds.bottom + } if (currentZoom != null && workspaceNavigation?.ownsTouches == false) { workspaceNavigation.canZoomAtRoot = { rootPoint -> val local = rootPoint - graphOrigin[0] @@ -270,10 +561,9 @@ fun MultiXGraphCanvas( Canvas(modifier = Modifier.fillMaxSize().clipToBounds()) { val snapshot = visibleSnapshot val primaryAxis = if (!isMultiAxis && snapshot.isYAxisAuto && snapshot.seriesList.isNotEmpty()) { - val allPoints = snapshot.seriesList.filter { it.isVisible }.flatMap { it.points }.filter { it.second.isFinite() } - if (allPoints.isNotEmpty()) { - val mn = allPoints.minOf { it.second } - val mx = allPoints.maxOf { it.second } + val mn = snapshot.seriesList.minOfOrNull { it.minVisibleValue ?: Double.POSITIVE_INFINITY } + val mx = snapshot.seriesList.maxOfOrNull { it.maxVisibleValue ?: Double.NEGATIVE_INFINITY } + if (mn != null && mx != null && mn.isFinite() && mx.isFinite()) { val margin = if (mx - mn > 1e-6) (mx - mn) * 0.1 else 1.0 (snapshot.axesConfig[AxisPosition.LEFT_0] ?: defaultAxis).copy( minVal = mn - margin, @@ -296,8 +586,7 @@ fun MultiXGraphCanvas( val hasLegend = snapshot.seriesList.isNotEmpty() && snapshot.legendPosition != 3 val approxLegendWidth = if (hasLegend && snapshot.legendPosition == 0) { - val maxW = snapshot.seriesList.maxOfOrNull { legendPaint.measureText(it.signalName) } ?: 60f - maxW + 36f + rightLegendReservationWidth(snapshot.seriesList, size.width, legendPaint) } else 0f val bounds = MultiAxisCoordinateTransformer.calculatePlotBounds( @@ -378,10 +667,15 @@ fun MultiXGraphCanvas( 0 -> { // Right drawRightLegendBox( seriesList = snapshot.seriesList, - bounds = bounds.copy(right = bounds.right + visibleRightAxes(snapshot, isMultiAxis).size * 54f), + bounds = bounds.copy( + right = bounds.right + + visibleRightAxes(snapshot, isMultiAxis).size * + MultiAxisCoordinateTransformer.DEFAULT_AXIS_WIDTH_PX + ), canvasWidth = size.width, canvasHeight = size.height, - legendPaint = legendPaint + legendPaint = legendPaint, + marqueeElapsedMs = legendMarqueeElapsedMs ) } 1 -> { // Top @@ -389,7 +683,8 @@ fun MultiXGraphCanvas( seriesList = snapshot.seriesList, bounds = bounds, canvasWidth = size.width, - legendPaint = legendPaint + legendPaint = legendPaint, + marqueeElapsedMs = legendMarqueeElapsedMs ) } 2 -> { // Bottom @@ -398,7 +693,8 @@ fun MultiXGraphCanvas( bounds = bounds, canvasWidth = size.width, canvasHeight = size.height, - legendPaint = legendPaint + legendPaint = legendPaint, + marqueeElapsedMs = legendMarqueeElapsedMs ) } 3 -> { // None (Hide Legend) @@ -495,8 +791,9 @@ private fun DrawScope.drawYAxesAndGrids( val axisColor = effectiveColorLong.toComposeColor() val leftCount = visibleAxes.count { it.position.isLeft } val isLeft = axis.position.isLeft - val axisX = if (isLeft) bounds.left - (leftCount - 1 - i) * 54f - else bounds.right + (i - leftCount) * 54f + val axisLaneWidth = MultiAxisCoordinateTransformer.DEFAULT_AXIS_WIDTH_PX + val axisX = if (isLeft) bounds.left - (leftCount - 1 - i) * axisLaneWidth + else bounds.right + (i - leftCount) * axisLaneWidth drawLine( color = axisColor, @@ -535,7 +832,10 @@ private fun DrawScope.drawYAxesAndGrids( val labelStr = chartAxisTickText(v, ticks) val fontMetrics = textPaint.fontMetrics val baseline = y - (fontMetrics.ascent + fontMetrics.descent) / 2f - textPaint.color = effectiveColorLong.toAndroidColorInt() + // Use the exact same Compose color instance as the axis line. + // This avoids a second COLORREF -> Android color conversion + // path producing a subtly different rendered color. + textPaint.color = axisColor.toArgb() textPaint.textAlign = if (isLeft) android.graphics.Paint.Align.RIGHT else android.graphics.Paint.Align.LEFT drawIntoCanvas { canvas -> @@ -555,6 +855,39 @@ private fun visibleRightAxes(snapshot: ChartRenderSnapshot, multiAxis: Boolean): return snapshot.axesConfig.values.filter { it.isVisible && !it.position.isLeft && it.position in used }.sortedBy { it.position.index } } +private const val Y_AXIS_HIT_HALF_WIDTH_PX = 32f +private const val Y_AXIS_SCALE_PIXELS = 180f + +/** Return the axis whose line/labels are under the initial pointer. */ +internal fun hitTestYAxis( + offset: Offset, + leftAxes: List, + rightAxes: List, + bounds: PlotBounds +): AxisPosition? { + if (!bounds.isDrawable || offset.y !in bounds.top..bounds.bottom) return null + + val candidates = leftAxes.mapIndexed { index, axis -> + axis to (bounds.left - (leftAxes.lastIndex - index) * MultiAxisCoordinateTransformer.DEFAULT_AXIS_WIDTH_PX) + } + rightAxes.mapIndexed { index, axis -> + axis to (bounds.right + index * MultiAxisCoordinateTransformer.DEFAULT_AXIS_WIDTH_PX) + } + // Labels extend LEFT of left axes and RIGHT of right axes. A symmetric + // nearest-line test assigns long labels to the neighbouring axis instead. + // Give each axis its whole label lane, with the axis line owning the boundary. + candidates.firstOrNull { (_, x) -> offset.x == x }?.let { return it.first.position } + val laneWidth = MultiAxisCoordinateTransformer.DEFAULT_AXIS_WIDTH_PX + candidates.firstOrNull { (axis, x) -> + if (axis.position.isLeft) offset.x > x - laneWidth && offset.x < x + else offset.x > x && offset.x < x + laneWidth + }?.let { return it.first.position } + // Retain the small plot-side tolerance only at the innermost axes; + // it must not steal the next axis's number area. + if (offset.x in bounds.left..(bounds.left + Y_AXIS_HIT_HALF_WIDTH_PX)) return leftAxes.lastOrNull()?.position + if (offset.x in (bounds.right - Y_AXIS_HIT_HALF_WIDTH_PX)..bounds.right) return rightAxes.firstOrNull()?.position + return null +} + private fun DrawScope.drawXAxisAndLabels( snapshot: ChartRenderSnapshot, bounds: PlotBounds, @@ -674,10 +1007,13 @@ private fun DrawScope.drawSeriesPath( val vMin = axis.minVal val vMax = axis.maxVal + val strokeWidth = chartLineWidthPx(series.lineWidthDp) forEachChartVertex(series.points, series.interpolationMode) { t, v, move -> val screenX = MultiAxisCoordinateTransformer.timeToScreenX(t, tMinSec, tMaxSec, bounds.left, bounds.width) - val screenY = MultiAxisCoordinateTransformer.valueToScreenY(v, vMin, vMax, bounds.top, bounds.height) + val screenY = MultiAxisCoordinateTransformer.valueToVisibleStrokeY( + v, vMin, vMax, bounds.top, bounds.height, strokeWidth + ) if (!screenX.isFinite() || !screenY.isFinite()) { isFirst = true } else { @@ -690,7 +1026,7 @@ private fun DrawScope.drawSeriesPath( path = path, color = series.color.toComposeColor(), style = Stroke( - width = chartLineWidthPx(series.lineWidthDp), + width = strokeWidth, cap = StrokeCap.Round, join = StrokeJoin.Round, pathEffect = getDashPathEffect(series.dashStyle) @@ -698,6 +1034,193 @@ private fun DrawScope.drawSeriesPath( ) } +private const val LEGEND_MARQUEE_SPEED_PX_PER_SEC = 36f +private const val LEGEND_MARQUEE_START_PAUSE_MS = 1_000L +private const val LEGEND_MARQUEE_GAP_PX = 18f +private const val RIGHT_LEGEND_GAP_PX = 8f +private const val RIGHT_LEGEND_EDGE_PADDING_PX = 6f + +/** + * The legend is painted on the same Canvas as the graph. Keep its viewport + * bounded so a long name can never paint over the graph/window edge. + */ +private fun maxLegendBoxWidth(canvasWidth: Float): Float { + val canvasLimit = (canvasWidth - 12f).coerceAtLeast(80f) + val proportionalLimit = (canvasWidth * 0.45f).coerceAtLeast(120f) + return minOf(canvasLimit, proportionalLimit).coerceAtLeast(80f) +} + +private fun rightLegendReservationWidth( + seriesList: List, + canvasWidth: Float, + paint: Paint +): Float { + if (seriesList.isEmpty()) return 0f + val maxTextWidth = seriesList.maxOfOrNull { paint.measureText(it.signalName) } ?: 50f + return rightLegendBoxWidth(maxTextWidth, canvasWidth) +} + +/** + * Computes the actual right-legend box width. When [plotRight] is supplied, + * the width is also limited by the strip that remains after the plot. This + * keeps the marquee trigger and the draw path on exactly the same viewport. + */ +private fun rightLegendBoxWidth( + maxTextWidth: Float, + canvasWidth: Float, + plotRight: Float? = null +): Float { + val naturalWidth = (maxTextWidth + 38f).coerceAtLeast(80f) + val availableWidth = plotRight + ?.takeIf { it.isFinite() } + ?.let { (canvasWidth - it - RIGHT_LEGEND_GAP_PX - RIGHT_LEGEND_EDGE_PADDING_PX).coerceAtLeast(1f) } + ?: maxLegendBoxWidth(canvasWidth) + return naturalWidth.coerceAtMost(minOf(maxLegendBoxWidth(canvasWidth), availableWidth)) +} + +/** Fit legend slots to the available row/column width without changing the + * natural layout when all names fit. */ +private fun fitLegendItemWidths(naturalWidths: List, availableWidth: Float): List { + if (naturalWidths.isEmpty()) return emptyList() + val safeAvailable = availableWidth.coerceAtLeast(1f) + if (naturalWidths.sum() <= safeAvailable) return naturalWidths + + // Equal slots make every item reachable. The text viewport inside each + // slot is clipped and marquee-scrolled independently. + val equalWidth = safeAvailable / naturalWidths.size + return List(naturalWidths.size) { equalWidth } +} + +/** Uses the same slot calculations as the draw functions to decide whether + * the marquee clock is actually needed. */ +private fun hasLegendTextOverflow( + seriesList: List, + legendPosition: Int, + plotWidth: Float, + canvasWidth: Float, + plotRight: Float = Float.NaN, + paint: Paint +): Boolean { + if (seriesList.isEmpty() || plotWidth <= 0f) return false + + return when (legendPosition) { + 0 -> { + val maxTextWidth = seriesList.maxOfOrNull { paint.measureText(it.signalName) } ?: 0f + val boxWidth = rightLegendBoxWidth(maxTextWidth, canvasWidth, plotRight.takeIf { it.isFinite() }) + maxTextWidth > (boxWidth - 38f).coerceAtLeast(0f) + 0.5f + } + 1, 2 -> { + if (legendPosition == 2 && seriesList.size >= 3) { + val colCount = (seriesList.size + 1) / 2 + val colSpacing = 16f + val naturalColWidths = FloatArray(colCount) + for (i in seriesList.indices) { + val col = i % colCount + naturalColWidths[col] = maxOf( + naturalColWidths[col], + 24f + paint.measureText(seriesList[i].signalName) + ) + } + val naturalTotal = naturalColWidths.sum() + (colCount - 1) * colSpacing + val boxWidth = (naturalTotal + 20f).coerceIn(120f, plotWidth) + val available = (boxWidth - 20f - (colCount - 1) * colSpacing).coerceAtLeast(1f) + val colWidths = fitLegendItemWidths(naturalColWidths.toList(), available) + seriesList.indices.any { i -> + paint.measureText(seriesList[i].signalName) > (colWidths[i % colCount] - 24f).coerceAtLeast(0f) + 0.5f + } + } else { + val itemGap = 16f + val naturalWidths = seriesList.map { 24f + paint.measureText(it.signalName) } + val naturalTotal = naturalWidths.sum() + itemGap * (seriesList.size - 1).coerceAtLeast(0) + val boxWidth = (naturalTotal + 20f).coerceIn(80f, plotWidth) + val available = (boxWidth - 20f - itemGap * (seriesList.size - 1).coerceAtLeast(0)).coerceAtLeast(1f) + val itemWidths = fitLegendItemWidths(naturalWidths, available) + seriesList.indices.any { i -> + paint.measureText(seriesList[i].signalName) > (itemWidths[i] - 24f).coerceAtLeast(0f) + 0.5f + } + } + } + else -> false + } +} + +/** + * Match AlphanumericalWindow's marquee feel: pause at the start, then move in + * one direction continuously. The distance includes the text plus a small + * gap; drawLegendMarqueeText paints a second copy behind it so the cycle wraps + * without a visible jump or a reverse-direction animation. + */ +private fun legendMarqueeOffsetPx( + textWidth: Float, + viewportWidth: Float, + elapsedMs: Long, + itemIndex: Int +): Float { + val overflow = textWidth - viewportWidth + if (!overflow.isFinite() || overflow <= 0f || viewportWidth <= 0f) return 0f + + val cycleDistance = textWidth + LEGEND_MARQUEE_GAP_PX + val travelMs = ((cycleDistance / LEGEND_MARQUEE_SPEED_PX_PER_SEC) * 1_000f) + .roundToLong() + .coerceIn(650L, 8_000L) + val cycleMs = LEGEND_MARQUEE_START_PAUSE_MS + travelMs + val phaseMs = (elapsedMs.coerceAtLeast(0L) + itemIndex * 350L) % cycleMs + + return when { + phaseMs < LEGEND_MARQUEE_START_PAUSE_MS -> 0f + phaseMs < LEGEND_MARQUEE_START_PAUSE_MS + travelMs -> { + val progress = (phaseMs - LEGEND_MARQUEE_START_PAUSE_MS).toFloat() / travelMs + -cycleDistance * progress + } + else -> -cycleDistance + } +} + +/** Draw one clipped legend label, applying marquee only when it overflows. */ +private fun DrawScope.drawLegendMarqueeText( + text: String, + textLeft: Float, + textRight: Float, + baseline: Float, + rowTop: Float, + rowBottom: Float, + paint: Paint, + elapsedMs: Long, + itemIndex: Int +) { + val viewportWidth = textRight - textLeft + if (text.isEmpty() || !viewportWidth.isFinite() || viewportWidth <= 0f) return + + paint.color = android.graphics.Color.BLACK + paint.textAlign = Paint.Align.LEFT + val textWidth = paint.measureText(text) + val offset = legendMarqueeOffsetPx( + textWidth = textWidth, + viewportWidth = viewportWidth, + elapsedMs = elapsedMs, + itemIndex = itemIndex + ) + + // Clip to the item viewport, not merely the outer Canvas. This prevents + // a long name from covering an adjacent legend item or the graph itself. + clipRect(textLeft, rowTop, textRight, rowBottom) { + drawIntoCanvas { canvas -> + canvas.nativeCanvas.drawText(text, textLeft + offset, baseline, paint) + // A second copy follows the first one. At the cycle boundary it + // is already at textLeft, making the end-to-start transition + // continuous instead of jumping back to the beginning. + if (textWidth > viewportWidth) { + canvas.nativeCanvas.drawText( + text, + textLeft + offset + textWidth + LEGEND_MARQUEE_GAP_PX, + baseline, + paint + ) + } + } + } +} + /** * 우측 범례 (Right) */ @@ -706,40 +1229,58 @@ private fun DrawScope.drawRightLegendBox( bounds: PlotBounds, canvasWidth: Float, canvasHeight: Float, - legendPaint: Paint + legendPaint: Paint, + marqueeElapsedMs: Long ) { val itemHeight = 24f val paddingH = 8f val paddingV = 6f val maxTextWidth = seriesList.maxOfOrNull { legendPaint.measureText(it.signalName) } ?: 50f - val boxWidth = maxTextWidth + 36f + // Keep a long signal name inside a bounded viewport. The name itself is + // marquee-scrolled below when it exceeds this viewport. + // The plot bounds already reserve space for the legend, but a capped + // right margin or a very narrow window can leave less room than the + // natural name width. Fit the box to the actual remaining strip instead + // of moving it left over the plot. The marquee then handles the reduced + // text viewport without ever painting over graph data. + val boxWidth = rightLegendBoxWidth(maxTextWidth, canvasWidth, bounds.right) val boxHeight = seriesList.size * itemHeight + paddingV * 2 - val boxX = (bounds.right + 8f).coerceAtMost(canvasWidth - boxWidth - 6f).coerceAtLeast(bounds.left) + val rightmostX = (canvasWidth - boxWidth - RIGHT_LEGEND_EDGE_PADDING_PX).coerceAtLeast(4f) + val boxX = (bounds.right + RIGHT_LEGEND_GAP_PX).coerceIn(4f, rightmostX) val boxY = (bounds.top + (bounds.height - boxHeight) / 2f).coerceIn(bounds.top, (canvasHeight - boxHeight - 6f).coerceAtLeast(bounds.top)) drawRect(color = Color.Black, topLeft = Offset(boxX + 3f, boxY + 3f), size = Size(boxWidth, boxHeight)) drawRect(color = Color.White, topLeft = Offset(boxX, boxY), size = Size(boxWidth, boxHeight)) drawRect(color = Color.Black, topLeft = Offset(boxX, boxY), size = Size(boxWidth, boxHeight), style = Stroke(width = 1f)) - drawIntoCanvas { canvas -> - var currentY = boxY + paddingV + itemHeight / 2f - for (series in seriesList) { - val seriesColor = series.color.toComposeColor() + var currentY = boxY + paddingV + itemHeight / 2f + for ((index, series) in seriesList.withIndex()) { + val seriesColor = series.color.toComposeColor() - drawLine( - color = seriesColor, - start = Offset(boxX + paddingH, currentY - 5f), - end = Offset(boxX + paddingH + 18f, currentY - 5f), - strokeWidth = 2.5f, - pathEffect = getDashPathEffect(series.dashStyle) - ) + drawLine( + color = seriesColor, + start = Offset(boxX + paddingH, currentY - 5f), + end = Offset(boxX + paddingH + 18f, currentY - 5f), + strokeWidth = 2.5f, + pathEffect = getDashPathEffect(series.dashStyle) + ) - legendPaint.color = android.graphics.Color.BLACK - canvas.nativeCanvas.drawText(series.signalName, boxX + paddingH + 22f, currentY + 2f, legendPaint) + val textLeft = boxX + paddingH + 22f + val textRight = boxX + boxWidth - paddingH + drawLegendMarqueeText( + text = series.signalName, + textLeft = textLeft, + textRight = textRight, + baseline = currentY + 2f, + rowTop = boxY + paddingV + index * itemHeight, + rowBottom = boxY + paddingV + (index + 1) * itemHeight, + paint = legendPaint, + elapsedMs = marqueeElapsedMs, + itemIndex = index + ) - currentY += itemHeight - } + currentY += itemHeight } } @@ -750,48 +1291,56 @@ private fun DrawScope.drawTopLegendBox( seriesList: List, bounds: PlotBounds, canvasWidth: Float, - legendPaint: Paint + legendPaint: Paint, + marqueeElapsedMs: Long ) { val paddingH = 10f val boxHeight = 26f val boxY = (bounds.top - 32f).coerceAtLeast(4f) + val itemGap = 16f - var totalItemsWidth = 0f - val itemWidths = seriesList.map { series -> + val naturalItemWidths = seriesList.map { series -> val textW = legendPaint.measureText(series.signalName) - val w = 18f + 6f + textW + 16f - totalItemsWidth += w - w + 18f + 6f + textW } - val boxWidth = (totalItemsWidth + paddingH * 2 - 16f).coerceIn(80f, bounds.width) + val naturalContentWidth = naturalItemWidths.sum() + itemGap * (seriesList.size - 1).coerceAtLeast(0) + val boxWidth = (naturalContentWidth + paddingH * 2).coerceIn(80f, bounds.width) val boxX = bounds.left + (bounds.width - boxWidth) / 2f + val contentWidth = (boxWidth - paddingH * 2 - itemGap * (seriesList.size - 1).coerceAtLeast(0)).coerceAtLeast(1f) + val itemWidths = fitLegendItemWidths(naturalItemWidths, contentWidth) drawRect(color = Color.Black, topLeft = Offset(boxX + 3f, boxY + 3f), size = Size(boxWidth, boxHeight)) drawRect(color = Color.White, topLeft = Offset(boxX, boxY), size = Size(boxWidth, boxHeight)) drawRect(color = Color.Black, topLeft = Offset(boxX, boxY), size = Size(boxWidth, boxHeight), style = Stroke(width = 1f)) - drawIntoCanvas { canvas -> - var currentX = boxX + paddingH - val itemCenterY = boxY + boxHeight / 2f + 4f + var currentX = boxX + paddingH + val itemCenterY = boxY + boxHeight / 2f + for (i in seriesList.indices) { + val series = seriesList[i] + val seriesColor = series.color.toComposeColor() + val itemWidth = itemWidths.getOrElse(i) { 0f } - for (i in seriesList.indices) { - val series = seriesList[i] - val seriesColor = series.color.toComposeColor() - - drawLine( - color = seriesColor, - start = Offset(currentX, itemCenterY - 9f), - end = Offset(currentX + 18f, itemCenterY - 9f), - strokeWidth = 2.5f, - pathEffect = getDashPathEffect(series.dashStyle) - ) + drawLine( + color = seriesColor, + start = Offset(currentX, itemCenterY - 9f), + end = Offset(currentX + 18f, itemCenterY - 9f), + strokeWidth = 2.5f, + pathEffect = getDashPathEffect(series.dashStyle) + ) - legendPaint.color = android.graphics.Color.BLACK - canvas.nativeCanvas.drawText(series.signalName, currentX + 24f, itemCenterY - 3f, legendPaint) + drawLegendMarqueeText( + text = series.signalName, + textLeft = currentX + 24f, + textRight = (currentX + itemWidth).coerceAtMost(boxX + boxWidth - paddingH), + baseline = itemCenterY - 3f, + rowTop = boxY, + rowBottom = boxY + boxHeight, + paint = legendPaint, + elapsedMs = marqueeElapsedMs, + itemIndex = i + ) - currentX += itemWidths[i] - if (currentX > boxX + boxWidth - 10f) break - } + currentX += itemWidth + itemGap } } @@ -803,7 +1352,8 @@ private fun DrawScope.drawBottomLegendBox( bounds: PlotBounds, canvasWidth: Float, canvasHeight: Float, - legendPaint: Paint + legendPaint: Paint, + marqueeElapsedMs: Long ) { if (seriesList.isEmpty()) return @@ -816,41 +1366,49 @@ private fun DrawScope.drawBottomLegendBox( val boxHeight = 26f val boxY = (bounds.bottom + 26f).coerceAtMost(canvasHeight - boxHeight - 4f) - var totalItemsWidth = 0f - val itemWidths = seriesList.map { series -> + val naturalItemWidths = seriesList.map { series -> val textW = legendPaint.measureText(series.signalName) - val w = 18f + 6f + textW + 16f - totalItemsWidth += w - w + 18f + 6f + textW } - val boxWidth = (totalItemsWidth + paddingH * 2 - 16f).coerceIn(80f, bounds.width) + val itemGap = 16f + val naturalContentWidth = naturalItemWidths.sum() + itemGap * (seriesList.size - 1).coerceAtLeast(0) + val boxWidth = (naturalContentWidth + paddingH * 2).coerceIn(80f, bounds.width) val boxX = bounds.left + (bounds.width - boxWidth) / 2f + val contentWidth = (boxWidth - paddingH * 2 - itemGap * (seriesList.size - 1).coerceAtLeast(0)).coerceAtLeast(1f) + val itemWidths = fitLegendItemWidths(naturalItemWidths, contentWidth) drawRect(color = Color.Black, topLeft = Offset(boxX + 3f, boxY + 3f), size = Size(boxWidth, boxHeight)) drawRect(color = Color.White, topLeft = Offset(boxX, boxY), size = Size(boxWidth, boxHeight)) drawRect(color = Color.Black, topLeft = Offset(boxX, boxY), size = Size(boxWidth, boxHeight), style = Stroke(width = 1f)) - drawIntoCanvas { canvas -> - var currentX = boxX + paddingH - val itemCenterY = boxY + boxHeight / 2f - - for (i in seriesList.indices) { - val series = seriesList[i] - val seriesColor = series.color.toComposeColor() + var currentX = boxX + paddingH + val itemCenterY = boxY + boxHeight / 2f + for (i in seriesList.indices) { + val series = seriesList[i] + val seriesColor = series.color.toComposeColor() + val itemWidth = itemWidths.getOrElse(i) { 0f } - drawLine( - color = seriesColor, - start = Offset(currentX, itemCenterY), - end = Offset(currentX + 18f, itemCenterY), - strokeWidth = 2.5f, - pathEffect = getDashPathEffect(series.dashStyle) - ) + drawLine( + color = seriesColor, + start = Offset(currentX, itemCenterY), + end = Offset(currentX + 18f, itemCenterY), + strokeWidth = 2.5f, + pathEffect = getDashPathEffect(series.dashStyle) + ) - legendPaint.color = android.graphics.Color.BLACK - canvas.nativeCanvas.drawText(series.signalName, currentX + 24f, itemCenterY + 5f, legendPaint) + drawLegendMarqueeText( + text = series.signalName, + textLeft = currentX + 24f, + textRight = (currentX + itemWidth).coerceAtMost(boxX + boxWidth - paddingH), + baseline = itemCenterY + 5f, + rowTop = boxY, + rowBottom = boxY + boxHeight, + paint = legendPaint, + elapsedMs = marqueeElapsedMs, + itemIndex = i + ) - currentX += itemWidths[i] - } + currentX += itemWidth + itemGap } } else { // [2] 신호 3개 이상: 2x2 형태 (2행 N열 그리드 정렬) @@ -865,54 +1423,63 @@ private fun DrawScope.drawBottomLegendBox( val boxY = (bounds.bottom + 26f).coerceAtMost(canvasHeight - boxHeight - 4f) // 각 열(col)별 최대 너비 계산 - val colWidths = FloatArray(colCount) + val naturalColWidths = FloatArray(colCount) for (i in seriesList.indices) { val col = i % colCount val textW = legendPaint.measureText(seriesList[i].signalName) val itemW = 18f + 6f + textW - if (itemW > colWidths[col]) { - colWidths[col] = itemW + if (itemW > naturalColWidths[col]) { + naturalColWidths[col] = itemW } } - val totalColsWidth = colWidths.sum() + (colCount - 1) * colSpacing + val totalColsWidth = naturalColWidths.sum() + (colCount - 1) * colSpacing val boxWidth = (totalColsWidth + paddingH * 2).coerceIn(120f, bounds.width) val boxX = bounds.left + (bounds.width - boxWidth) / 2f + val availableColsWidth = (boxWidth - paddingH * 2 - (colCount - 1) * colSpacing).coerceAtLeast(1f) + val colWidths = fitLegendItemWidths(naturalColWidths.toList(), availableColsWidth).toFloatArray() // 3px 직각 섀도우 + 순백색 배경 + 1px 검정 테두리 drawRect(color = Color.Black, topLeft = Offset(boxX + 3f, boxY + 3f), size = Size(boxWidth, boxHeight)) drawRect(color = Color.White, topLeft = Offset(boxX, boxY), size = Size(boxWidth, boxHeight)) drawRect(color = Color.Black, topLeft = Offset(boxX, boxY), size = Size(boxWidth, boxHeight), style = Stroke(width = 1f)) - drawIntoCanvas { canvas -> - val colStartX = FloatArray(colCount) - var currentColX = boxX + paddingH - for (c in 0 until colCount) { - colStartX[c] = currentColX - currentColX += colWidths[c] + colSpacing - } + val colStartX = FloatArray(colCount) + var currentColX = boxX + paddingH + for (c in 0 until colCount) { + colStartX[c] = currentColX + currentColX += colWidths[c] + colSpacing + } - for (i in seriesList.indices) { - val series = seriesList[i] - val seriesColor = series.color.toComposeColor() + for (i in seriesList.indices) { + val series = seriesList[i] + val seriesColor = series.color.toComposeColor() - val col = i % colCount - val row = i / colCount + val col = i % colCount + val row = i / colCount - val itemX = colStartX[col] - val itemCenterY = boxY + paddingV + row * itemHeight + itemHeight / 2f + val itemX = colStartX[col] + val itemCenterY = boxY + paddingV + row * itemHeight + itemHeight / 2f - drawLine( - color = seriesColor, - start = Offset(itemX, itemCenterY), - end = Offset(itemX + 18f, itemCenterY), - strokeWidth = 2.5f, - pathEffect = getDashPathEffect(series.dashStyle) - ) + drawLine( + color = seriesColor, + start = Offset(itemX, itemCenterY), + end = Offset(itemX + 18f, itemCenterY), + strokeWidth = 2.5f, + pathEffect = getDashPathEffect(series.dashStyle) + ) - legendPaint.color = android.graphics.Color.BLACK - canvas.nativeCanvas.drawText(series.signalName, itemX + 24f, itemCenterY + 5f, legendPaint) - } + drawLegendMarqueeText( + text = series.signalName, + textLeft = itemX + 24f, + textRight = (itemX + colWidths[col]).coerceAtMost(boxX + boxWidth - paddingH), + baseline = itemCenterY + 5f, + rowTop = boxY + paddingV + row * itemHeight, + rowBottom = boxY + paddingV + (row + 1) * itemHeight, + paint = legendPaint, + elapsedMs = marqueeElapsedMs, + itemIndex = i + ) } } } diff --git a/Android/app/src/main/java/com/dct/app/presentation/chart/MultiXGraphView.kt b/Android/app/src/main/java/com/dct/app/presentation/chart/MultiXGraphView.kt index 3b24842..ab4c20e 100644 --- a/Android/app/src/main/java/com/dct/app/presentation/chart/MultiXGraphView.kt +++ b/Android/app/src/main/java/com/dct/app/presentation/chart/MultiXGraphView.kt @@ -109,6 +109,7 @@ fun MultiXGraphView( TimeWindowPreset.SEC_30, TimeWindowPreset.MIN_1, TimeWindowPreset.MIN_5, + TimeWindowPreset.HOLD, TimeWindowPreset.AUTO ) @@ -163,6 +164,11 @@ fun MultiXGraphView( }, onPanTime = { deltaSec -> renderer.panTimeWindow(deltaSec) }, onZoomTime = { factor, anchor -> renderer.zoomTimeWindow(factor, anchor) }, + onYAxisPan = { axis, fraction -> renderer.panYAxis(axis, fraction.toDouble()) }, + onYAxisZoom = { axis, factor, anchor -> + renderer.zoomYAxis(axis, factor.toDouble(), anchor.toDouble()) + }, + onResetYAxis = { axis -> renderer.resetYAxis(axis) }, onResetPan = { renderer.resetPan() } ) } diff --git a/Android/app/src/main/java/com/dct/app/presentation/dialog/CommunicationDeviceListDialogs.kt b/Android/app/src/main/java/com/dct/app/presentation/dialog/CommunicationDeviceListDialogs.kt index fdd38aa..08573f7 100644 --- a/Android/app/src/main/java/com/dct/app/presentation/dialog/CommunicationDeviceListDialogs.kt +++ b/Android/app/src/main/java/com/dct/app/presentation/dialog/CommunicationDeviceListDialogs.kt @@ -15,6 +15,7 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Bluetooth import androidx.compose.material.icons.filled.BluetoothDisabled import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Refresh import androidx.compose.material.icons.filled.Settings import androidx.compose.material.icons.filled.Wifi @@ -343,520 +344,6 @@ fun BluetoothDeviceListDialog( } } -/** - * 실제 Android Wi-Fi Direct 실시간 탐색 및 SoftAP(직접 IP/Port) 듀얼 모드 연결 다이얼로그 - */ -@Composable -fun WifiDirectDeviceListDialog( - manager: WifiDirectManager? = null, - currentConnectedDevice: String?, - onSelectDevice: (deviceName: String, hostIp: String, port: Int) -> Unit, - onDismiss: () -> Unit -) { - val context = LocalContext.current - val actualManager = manager ?: remember { WifiDirectManager(context) } - - var selectedTab by remember { mutableStateOf(0) } // 0: Wi-Fi Direct, 1: SoftAP (직접 IP) - val inputPreferences = remember(context) { - com.dct.app.data.communication.WifiConnectionPreferences( - context.applicationContext.getSharedPreferences( - com.dct.app.data.communication.WifiConnectionPreferences.FILE_NAME, - android.content.Context.MODE_PRIVATE - ) - ) - } - var manualInput by remember(inputPreferences) { mutableStateOf(inputPreferences.load()) } - - val requiredPermissions = remember { - val list = mutableListOf() - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - list.add(android.Manifest.permission.NEARBY_WIFI_DEVICES) - } - list.add(android.Manifest.permission.ACCESS_FINE_LOCATION) - list.add(android.Manifest.permission.ACCESS_COARSE_LOCATION) - list - } - - var hasPermissions by remember { - mutableStateOf( - requiredPermissions.all { - androidx.core.content.ContextCompat.checkSelfPermission(context, it) == android.content.pm.PackageManager.PERMISSION_GRANTED - } - ) - } - - val permissionLauncher = androidx.activity.compose.rememberLauncherForActivityResult( - contract = androidx.activity.result.contract.ActivityResultContracts.RequestMultiplePermissions() - ) { results -> - val granted = results.values.all { it } - hasPermissions = granted - if (granted) { - actualManager.discoverPeers() - } - } - - val isP2pEnabled by actualManager.isP2pEnabled.collectAsState() - val isSearching by actualManager.isSearching.collectAsState() - val peers by actualManager.peers.collectAsState() - val lanServers by actualManager.lanServers.collectAsState() - val connectionInfo by actualManager.connectionInfo.collectAsState() - val groupInfo by actualManager.groupInfo.collectAsState() - val lastErrorReason by actualManager.lastErrorReason.collectAsState() - - val isLocationEnabled = remember(context, isP2pEnabled, isSearching) { - val lm = context.getSystemService(Context.LOCATION_SERVICE) as? android.location.LocationManager - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { - lm?.isLocationEnabled == true - } else { - lm?.isProviderEnabled(android.location.LocationManager.GPS_PROVIDER) == true || - lm?.isProviderEnabled(android.location.LocationManager.NETWORK_PROVIDER) == true - } - } - - val isGroupConnected = connectionInfo?.groupFormed == true - - DisposableEffect(actualManager, hasPermissions) { - actualManager.registerReceiver() - if (hasPermissions) { - actualManager.discoverPeers() - actualManager.refreshConnectionAndPeers() - } else { - permissionLauncher.launch(requiredPermissions.toTypedArray()) - } - onDispose { - actualManager.stopPeerDiscovery() - actualManager.unregisterReceiver() - } - } - - Dialog(onDismissRequest = onDismiss) { - Surface( - shape = RoundedCornerShape(8.dp), - color = Color.White, - shadowElevation = 8.dp, - modifier = Modifier - .width(560.dp) - .height(580.dp) - ) { - Column(modifier = Modifier.fillMaxSize().padding(20.dp)) { - // Header - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween, - modifier = Modifier.fillMaxWidth() - ) { - Row(verticalAlignment = Alignment.CenterVertically) { - Box( - modifier = Modifier - .size(32.dp) - .clip(CircleShape) - .background(Color(0xFFEBF3FF)), - contentAlignment = Alignment.Center - ) { - Icon( - imageVector = Icons.Default.Wifi, - contentDescription = null, - tint = Color(0xFF2563EB), - modifier = Modifier.size(18.dp) - ) - } - Spacer(modifier = Modifier.width(10.dp)) - Column { - Text( - text = "Wi-Fi 통신 연결", - fontSize = 15.sp, - fontWeight = FontWeight.Bold, - color = Color(0xFF1E293B) - ) - Text( - text = "DCT 하드웨어와 TCP 소켓 통신을 연결합니다.", - fontSize = 11.sp, - color = Color(0xFF64748B) - ) - } - } - - if (selectedTab == 0) { - IconButton( - onClick = { - actualManager.refreshConnectionAndPeers() - if (hasPermissions) { - actualManager.discoverPeers() - } else { - permissionLauncher.launch(requiredPermissions.toTypedArray()) - } - }, - modifier = Modifier.size(28.dp) - ) { - Icon( - Icons.Default.Refresh, - contentDescription = "새로고침", - tint = Color(0xFF64748B), - modifier = Modifier.size(18.dp) - ) - } - } - } - - Spacer(modifier = Modifier.height(12.dp)) - - // 탭 선택 (Wi-Fi Direct vs SoftAP) - Row( - modifier = Modifier - .fillMaxWidth() - .background(Color(0xFFF1F5F9), RoundedCornerShape(6.dp)) - .padding(3.dp) - ) { - Box( - modifier = Modifier - .weight(1f) - .clip(RoundedCornerShape(4.dp)) - .background(if (selectedTab == 0) Color.White else Color.Transparent) - .clickable { selectedTab = 0 } - .padding(vertical = 6.dp), - contentAlignment = Alignment.Center - ) { - Text( - text = "Wi-Fi Direct", - fontSize = 12.sp, - fontWeight = if (selectedTab == 0) FontWeight.Bold else FontWeight.Normal, - color = if (selectedTab == 0) Color(0xFF2563EB) else Color(0xFF64748B) - ) - } - - Box( - modifier = Modifier - .weight(1f) - .clip(RoundedCornerShape(4.dp)) - .background(if (selectedTab == 1) Color.White else Color.Transparent) - .clickable { selectedTab = 1 } - .padding(vertical = 6.dp), - contentAlignment = Alignment.Center - ) { - Text( - text = "IP 직접 입력", - fontSize = 12.sp, - fontWeight = if (selectedTab == 1) FontWeight.Bold else FontWeight.Normal, - color = if (selectedTab == 1) Color(0xFF2563EB) else Color(0xFF64748B) - ) - } - } - - Spacer(modifier = Modifier.height(12.dp)) - HorizontalDivider(color = Color(0xFFE2E8F0)) - Spacer(modifier = Modifier.height(10.dp)) - - // Content (고정된 높이 영역) - Box(modifier = Modifier.weight(1f).fillMaxWidth()) { - if (selectedTab == 0) { - // 1. Wi-Fi Direct 모드 - if (!hasPermissions) { - // 권한 미허용 안내 배너 - Surface( - color = Color(0xFFFEF2F2), - shape = RoundedCornerShape(6.dp), - border = androidx.compose.foundation.BorderStroke(1.dp, Color(0xFFFECACA)), - modifier = Modifier.fillMaxWidth().align(Alignment.Center).padding(vertical = 12.dp) - ) { - Column( - modifier = Modifier.padding(14.dp), - horizontalAlignment = Alignment.CenterHorizontally - ) { - Text( - text = "Wi-Fi Direct 탐색을 위해 권한이 필요합니다.", - fontSize = 12.sp, - fontWeight = FontWeight.Bold, - color = Color(0xFFDC2626) - ) - Spacer(modifier = Modifier.height(4.dp)) - Text( - text = "Android 근처 기기(Wi-Fi) 및 위치 권한을 허용해 주세요.", - fontSize = 11.sp, - color = Color(0xFF7F1D1D) - ) - Spacer(modifier = Modifier.height(8.dp)) - Button( - onClick = { permissionLauncher.launch(requiredPermissions.toTypedArray()) }, - colors = ButtonDefaults.buttonColors(containerColor = Color(0xFFDC2626)) - ) { - Text("권한 허용하기", fontSize = 11.sp, color = Color.White) - } - } - } - } else if (!isLocationEnabled) { - // 위치(GPS) 서비스 꺼짐 안내 배너 - Surface( - color = Color(0xFFFFFBEB), - shape = RoundedCornerShape(6.dp), - border = androidx.compose.foundation.BorderStroke(1.dp, Color(0xFFFDE68A)), - modifier = Modifier.fillMaxWidth().align(Alignment.Center).padding(vertical = 12.dp) - ) { - Column( - modifier = Modifier.padding(14.dp), - horizontalAlignment = Alignment.CenterHorizontally - ) { - Text( - text = "태블릿의 위치(GPS) 서비스가 꺼져 있습니다.", - fontSize = 12.sp, - fontWeight = FontWeight.Bold, - color = Color(0xFFB45309) - ) - Spacer(modifier = Modifier.height(4.dp)) - Text( - text = "Android 보안 정책상 위치 서비스가 켜져 있어야 주변 Wi-Fi Direct 기기를 검색할 수 있습니다.", - fontSize = 11.sp, - color = Color(0xFF92400E), - textAlign = androidx.compose.ui.text.style.TextAlign.Center - ) - Spacer(modifier = Modifier.height(8.dp)) - Button( - onClick = { - try { - context.startActivity( - android.content.Intent(android.provider.Settings.ACTION_LOCATION_SOURCE_SETTINGS).apply { - flags = android.content.Intent.FLAG_ACTIVITY_NEW_TASK - } - ) - } catch (_: Exception) {} - }, - colors = ButtonDefaults.buttonColors(containerColor = Color(0xFFD97706)) - ) { - Text("위치(GPS) 설정 열기", fontSize = 11.sp, color = Color.White) - } - } - } - } else if (peers.isEmpty() && lanServers.isEmpty() && !isGroupConnected) { - Column( - modifier = Modifier - .fillMaxWidth() - .align(Alignment.Center) - .padding(vertical = 20.dp), - horizontalAlignment = Alignment.CenterHorizontally - ) { - if (isSearching) { - CircularProgressIndicator(modifier = Modifier.size(24.dp), strokeWidth = 2.dp, color = Color(0xFF2563EB)) - Spacer(modifier = Modifier.height(10.dp)) - Text( - text = "주변 Wi-Fi Direct 기기를 탐색 중입니다...", - fontSize = 12.sp, - color = Color(0xFF475569) - ) - } else { - Icon( - imageVector = Icons.Default.WifiOff, - contentDescription = null, - tint = Color(0xFF94A3B8), - modifier = Modifier.size(32.dp) - ) - Spacer(modifier = Modifier.height(8.dp)) - Text( - text = "탐색된 Wi-Fi Direct 장치가 없습니다.", - fontSize = 13.sp, - fontWeight = FontWeight.SemiBold, - color = Color(0xFF475569) - ) - Spacer(modifier = Modifier.height(4.dp)) - Text( - text = "1. 태블릿의 Wi-Fi 및 위치(GPS)가 켜져 있는지 확인해 주세요.\n2. 상대 기기의 Wi-Fi Direct가 켜져 있는지 확인 후 상단 [새로고침]을 눌러주세요.\n3. 자체 핫스팟 모드인 경우 [IP 직접 입력] 탭을 이용해 주세요.", - fontSize = 11.sp, - color = Color(0xFF94A3B8), - textAlign = androidx.compose.ui.text.style.TextAlign.Center - ) - } - } - } else { - val listState = rememberLazyListState() - - val groupClients = groupInfo?.clientList?.toList() ?: emptyList() - val groupOwner = groupInfo?.owner - - // 연결된 기기 분리 및 최상단 정렬 (그룹 형성 또는 상태 기준) - val connectedPeer = peers.find { peer -> - val isMatchedByGroup = isGroupConnected && ( - (groupOwner != null && (peer.deviceAddress == groupOwner.deviceAddress || peer.deviceName == groupOwner.deviceName)) || - groupClients.any { it.deviceAddress == peer.deviceAddress || it.deviceName == peer.deviceName } - ) - val isMatchedByName = currentConnectedDevice != null && (peer.deviceName == currentConnectedDevice || peer.deviceAddress == currentConnectedDevice) - val isMatchedByStatus = peer.status == WifiP2pDevice.CONNECTED - isMatchedByGroup || isMatchedByName || isMatchedByStatus - } - val remainingPeers = peers.filter { it.deviceAddress != connectedPeer?.deviceAddress } - - Column(modifier = Modifier.fillMaxSize()) { - Box(modifier = Modifier.weight(1f).fillMaxWidth()) { - LazyColumn( - state = listState, - modifier = Modifier - .fillMaxSize() - .padding(end = 8.dp), - verticalArrangement = Arrangement.spacedBy(6.dp) - ) { - if (isSearching) { - item { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 4.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.Center - ) { - CircularProgressIndicator(modifier = Modifier.size(14.dp), strokeWidth = 2.dp, color = Color(0xFF2563EB)) - Spacer(modifier = Modifier.width(8.dp)) - Text("Wi-Fi Direct 탐색 중...", fontSize = 11.sp, color = Color(0xFF64748B)) - } - } - } - - // 1. 최상단: 현재 연결된 기기 - if (connectedPeer != null || isGroupConnected) { - val devName = connectedPeer?.deviceName?.ifBlank { null } - ?: (if (connectionInfo?.isGroupOwner == true) groupClients.firstOrNull()?.deviceName else groupOwner?.deviceName)?.ifBlank { null } - ?: "Wi-Fi Direct Device" - val devAddress = connectedPeer?.deviceAddress ?: groupOwner?.deviceAddress ?: "" - val hostIp = connectionInfo?.groupOwnerAddress?.hostAddress ?: "192.168.49.1" - - item { - Text( - text = "현재 연결된 기기", - fontSize = 11.sp, - fontWeight = FontWeight.Bold, - color = Color(0xFF16A34A), - modifier = Modifier.padding(top = 2.dp, bottom = 2.dp) - ) - } - item { - DeviceListItem( - name = devName, - subText = "IP: $hostIp | MAC: $devAddress (연결됨)", - isSelected = true, - isConnected = true, - onClick = { - onSelectDevice(devName, hostIp, 6000) - onDismiss() - } - ) - } - } - - // 2. 탐색된 PC 테스트 서버 (LAN/Wi-Fi 자동 탐색) - if (lanServers.isNotEmpty()) { - item { - Text( - text = "탐색된 PC 테스트 서버 (${lanServers.size})", - fontSize = 11.sp, - fontWeight = FontWeight.Bold, - color = Color(0xFF16A34A), - modifier = Modifier.padding(top = 4.dp, bottom = 2.dp) - ) - } - items(lanServers) { lanDev -> - DeviceListItem( - name = lanDev.deviceName, - subText = "IP: ${lanDev.hostIp}:${lanDev.port} | 상태: 연결 가능 (클릭 시 즉시 연결)", - isSelected = false, - isConnected = false, - onClick = { - onSelectDevice(lanDev.deviceName, lanDev.hostIp, lanDev.port) - onDismiss() - } - ) - } - } - - // 3. 주변 탐색된 Wi-Fi Direct 기기 - if (remainingPeers.isNotEmpty()) { - item { - Text( - text = "주변 탐색된 기기 (${remainingPeers.size})", - fontSize = 11.sp, - fontWeight = FontWeight.Bold, - color = Color(0xFF2563EB), - modifier = Modifier.padding(top = 6.dp, bottom = 2.dp) - ) - } - items(remainingPeers) { device -> - val devName = device.deviceName.ifBlank { "Wi-Fi Direct Device" } - val devAddress = device.deviceAddress ?: "" - val isThisDeviceConnected = device.status == WifiP2pDevice.CONNECTED || (isGroupConnected && (device.deviceAddress == groupOwner?.deviceAddress || groupClients.any { it.deviceAddress == device.deviceAddress })) - - val statusLabel = when { - isThisDeviceConnected -> "연결됨" - device.status == WifiP2pDevice.INVITED -> "초대 중" - device.status == WifiP2pDevice.FAILED -> "실패" - device.status == WifiP2pDevice.AVAILABLE -> "연결 가능" - device.status == WifiP2pDevice.UNAVAILABLE -> "사용 불가" - else -> "대기" - } - - DeviceListItem( - name = devName, - subText = "MAC: $devAddress | 상태: $statusLabel", - isSelected = isThisDeviceConnected, - isConnected = isThisDeviceConnected, - onClick = { - actualManager.connect( - device = device, - onSuccessWithIp = { hostIp -> - onSelectDevice(devName, hostIp, 6000) - onDismiss() - }, - onFailure = { - val fallbackIp = connectionInfo?.groupOwnerAddress?.hostAddress ?: "192.168.4.1" - onSelectDevice(devName, fallbackIp, 6000) - onDismiss() - } - ) - } - ) - } - } - } - - VerticalListScrollbar( - listState = listState, - modifier = Modifier - .align(Alignment.CenterEnd) - .padding(vertical = 4.dp) - ) - } - } - } - } else { - WifiManualConnectionForm( - input = manualInput, - onInputChange = { - manualInput = it - inputPreferences.save(it) - }, - servers = lanServers, - onOpenWifiSettings = { - try { - context.startActivity(android.content.Intent(android.provider.Settings.ACTION_WIFI_SETTINGS)) - } catch (_: Exception) {} - }, - onConnect = { ip, port -> - onSelectDevice("Wi-Fi ($ip:$port)", ip, port) - onDismiss() - } - ) - } - } - - Spacer(modifier = Modifier.height(14.dp)) - - // Actions - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.End - ) { - TextButton(onClick = onDismiss) { - Text("닫기", color = Color(0xFF64748B), fontSize = 12.sp) - } - } - } - } - } -} @Composable private fun DeviceListItem( @@ -864,14 +351,18 @@ private fun DeviceListItem( subText: String, isSelected: Boolean, isConnected: Boolean = false, + isLoading: Boolean = false, + enabled: Boolean = true, onClick: () -> Unit ) { val borderColor = when { + isLoading -> Color(0xFF2563EB) isConnected -> Color(0xFF86EFAC) // Soft Green isSelected -> Color(0xFF93C5FD) // Blue else -> Color(0xFFE2E8F0) } val bgColor = when { + isLoading -> Color(0xFFEFF6FF) isConnected -> Color(0xFFF0FDF4) isSelected -> Color(0xFFEFF6FF) else -> Color(0xFFF8FAFC) @@ -883,22 +374,24 @@ private fun DeviceListItem( border = androidx.compose.foundation.BorderStroke(1.dp, borderColor), modifier = Modifier .fillMaxWidth() - .clickable { onClick() } + .clickable(enabled = enabled) { onClick() } ) { Row( modifier = Modifier.padding(horizontal = 12.dp, vertical = 10.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween ) { - Column { + Column(modifier = Modifier.weight(1f)) { Row(verticalAlignment = Alignment.CenterVertically) { Text( text = name, fontSize = 13.sp, fontWeight = FontWeight.SemiBold, color = when { + isLoading -> Color(0xFF1D4ED8) isConnected -> Color(0xFF15803D) isSelected -> Color(0xFF1D4ED8) + !enabled -> Color(0xFF94A3B8) else -> Color(0xFF334155) } ) @@ -916,16 +409,36 @@ private fun DeviceListItem( modifier = Modifier.padding(horizontal = 5.dp, vertical = 2.dp) ) } + } else if (isLoading) { + Spacer(modifier = Modifier.width(6.dp)) + Surface( + color = Color(0xFFDBEAFE), + shape = RoundedCornerShape(4.dp) + ) { + Text( + text = "연결 중", + fontSize = 10.sp, + fontWeight = FontWeight.Bold, + color = Color(0xFF1D4ED8), + modifier = Modifier.padding(horizontal = 5.dp, vertical = 2.dp) + ) + } } } Text( text = subText, fontSize = 10.sp, - color = Color(0xFF94A3B8) + color = if (isLoading) Color(0xFF2563EB) else Color(0xFF94A3B8) ) } - if (isConnected || isSelected) { + if (isLoading) { + CircularProgressIndicator( + modifier = Modifier.size(16.dp), + strokeWidth = 2.dp, + color = Color(0xFF2563EB) + ) + } else if (isConnected || isSelected) { Icon( imageVector = Icons.Default.Check, contentDescription = "Connected", diff --git a/Android/app/src/main/java/com/dct/app/presentation/dialog/ProjectFileDialogs.kt b/Android/app/src/main/java/com/dct/app/presentation/dialog/ProjectFileDialogs.kt index 5c106e2..a3e8ff8 100644 --- a/Android/app/src/main/java/com/dct/app/presentation/dialog/ProjectFileDialogs.kt +++ b/Android/app/src/main/java/com/dct/app/presentation/dialog/ProjectFileDialogs.kt @@ -12,7 +12,10 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.* import androidx.compose.material3.* import androidx.compose.runtime.Composable +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.getValue +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color @@ -39,6 +42,7 @@ fun OpenProjectDialog( onDismissRequest = onDismiss, properties = androidx.compose.ui.window.DialogProperties(usePlatformDefaultWidth = false) ) { + var actionLocked by remember { mutableStateOf(false) } Box( modifier = Modifier .fillMaxSize() @@ -177,7 +181,12 @@ fun OpenProjectDialog( border = androidx.compose.foundation.BorderStroke(1.dp, Color(0xFFE2E8F0)), modifier = Modifier .fillMaxWidth() - .clickable { onSelectFile(file) } + .clickable(enabled = !actionLocked) { + if (!actionLocked) { + actionLocked = true + onSelectFile(file) + } + } ) { Row( verticalAlignment = Alignment.CenterVertically, @@ -232,7 +241,13 @@ fun OpenProjectDialog( verticalAlignment = Alignment.CenterVertically ) { OutlinedButton( - onClick = onBrowseOther, + enabled = !actionLocked, + onClick = { + if (!actionLocked) { + actionLocked = true + onBrowseOther() + } + }, shape = RoundedCornerShape(4.dp), colors = ButtonDefaults.outlinedButtonColors(contentColor = Color(0xFF2563EB)), border = androidx.compose.foundation.BorderStroke(1.dp, Color(0xFF3B82F6)) diff --git a/Android/app/src/main/java/com/dct/app/presentation/dialog/WifiDirectDeviceListDialog.kt b/Android/app/src/main/java/com/dct/app/presentation/dialog/WifiDirectDeviceListDialog.kt new file mode 100644 index 0000000..27e3b7b --- /dev/null +++ b/Android/app/src/main/java/com/dct/app/presentation/dialog/WifiDirectDeviceListDialog.kt @@ -0,0 +1,152 @@ +package com.dct.app.presentation.dialog + +import android.content.Intent +import android.net.Uri +import android.net.wifi.p2p.WifiP2pDevice +import android.os.Build +import android.provider.Settings +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +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.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 + +/** App-owned Material 3 UI; OS commands go exclusively through the ViewModel. */ +@Composable +fun WifiDirectDeviceListDialog(viewModel: MainViewModel, onDismiss: () -> Unit) { + val context = LocalContext.current + val state by viewModel.wifiDirectState.collectAsStateWithLifecycle() + val mainState by viewModel.uiState.collectAsStateWithLifecycle() + val tcpConnected by viewModel.wifiSocketManager.isConnected.collectAsStateWithLifecycle() + var searchOnReady by remember { mutableStateOf(true) } + var permissionRequested by remember { mutableStateOf(false) } + var settingsError by remember { mutableStateOf(null) } + val permissions = remember { WifiDirectRequirements.runtimePermissions(Build.VERSION.SDK_INT) } + val permissionLauncher = rememberLauncherForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { + viewModel.refreshWifiDirect() + searchOnReady = it.values.all { granted -> granted } + } + fun settings(action: String, app: Boolean = false) { + try { + context.startActivity(Intent(action).apply { + if (app) data = Uri.parse("package:${context.packageName}") + }) + } catch (e: android.content.ActivityNotFoundException) { + settingsError = "설정 화면을 열 수 없습니다: ${e.message}" + } + } + LaunchedEffect(Unit) { viewModel.refreshWifiDirect() } + LaunchedEffect(state.permissionGranted, state.supported, state.phase) { + if (state.supported && state.phase == WifiDirectPhase.PERMISSION_REQUIRED && !permissionRequested) { + permissionRequested = true + permissionLauncher.launch(permissions) + } + } + LaunchedEffect(searchOnReady, state.canDiscover) { + if (searchOnReady && state.canDiscover) { + searchOnReady = false + viewModel.discoverWifiDirect() + } + } + DisposableEffect(Unit) { + // Closing the sheet does not cancel an invitation or remove a formed group. + onDispose { viewModel.stopWifiDiscovery() } + } + Dialog(onDismissRequest = onDismiss) { + Surface(shape = MaterialTheme.shapes.extraLarge, modifier = Modifier.widthIn(max = 560.dp).fillMaxWidth()) { + Column(Modifier.padding(24.dp).heightIn(max = 660.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { + Text("Wi-Fi Direct", style = MaterialTheme.typography.headlineSmall) + state.thisDevice?.let { Text("내 기기: ${it.deviceName}", style = MaterialTheme.typography.bodySmall) } + if (state.busy || state.discovering) LinearProgressIndicator(Modifier.fillMaxWidth()) + Text(when (state.phase) { + WifiDirectPhase.CHECKING -> "Wi-Fi Direct 상태 확인 중..." + WifiDirectPhase.UNSUPPORTED -> "이 기기는 Wi-Fi Direct를 지원하지 않습니다." + WifiDirectPhase.DISABLED -> "Wi-Fi가 꺼져 있습니다." + WifiDirectPhase.PERMISSION_REQUIRED -> "주변 기기 권한이 필요합니다." + WifiDirectPhase.CONNECTING -> "연결 중... 상대 기기의 연결 요청을 승인해 주세요." + WifiDirectPhase.CANCELLING -> "연결 취소 확인 중..." + WifiDirectPhase.DISCONNECTING -> "연결 해제 확인 중..." + WifiDirectPhase.CONNECTED -> "연결됨" + WifiDirectPhase.DISCOVERING -> "검색 중..." + WifiDirectPhase.ERROR -> "작업을 완료하지 못했습니다." + WifiDirectPhase.IDLE -> "연결할 기기를 선택하세요." + }, Modifier.testTag("wifi:state")) + (settingsError ?: state.error)?.let { Text(it, color = MaterialTheme.colorScheme.error) } + if (state.supported && !state.permissionGranted) { + Row { + TextButton(onClick = { permissionLauncher.launch(permissions) }) { Text("권한 허용") } + TextButton(onClick = { settings(Settings.ACTION_APPLICATION_DETAILS_SETTINGS, true) }) { Text("앱 설정") } + } + } else if (state.supported && !state.enabled) { + TextButton(onClick = { settings(Settings.ACTION_WIFI_SETTINGS) }) { Text("Wi-Fi 설정") } + } + if (state.permissionGranted && !state.locationEnabled) { + Text("기기 검색을 위해 위치 서비스를 켜 주세요.") + TextButton(onClick = { settings(Settings.ACTION_LOCATION_SOURCE_SETTINGS) }) { Text("위치 설정") } + } + state.link?.let { link -> + Text(link.displayName, style = MaterialTheme.typography.titleMedium) + Text("Group Owner IP: ${link.info.groupOwnerAddress?.hostAddress ?: "할당 대기 중"}") + Text("역할: ${if (link.info.isGroupOwner) "Group Owner" else "Client"}") + Text("인터페이스: ${link.group.`interface` ?: "확인 중"}", style = MaterialTheme.typography.bodySmall) + Text(when { + mainState.isConnected -> "DCT 통신 준비 완료 · 포트 ${WifiSocketManager.DEFAULT_PORT}" + tcpConnected -> "DCT 장비 초기화 중..." + else -> "DCT 통신 연결 대기 · 포트 ${WifiSocketManager.DEFAULT_PORT}" + }) + Row { + TextButton(onClick = viewModel::removeWifiDirectGroup, + enabled = state.phase != WifiDirectPhase.DISCONNECTING) { Text("연결 해제") } + if (!mainState.isConnected) TextButton(onClick = viewModel::retryWifiDirectTcp, + enabled = state.phase == WifiDirectPhase.CONNECTED) { Text("통신 재연결") } + } + } + if (state.phase in setOf(WifiDirectPhase.CONNECTING, WifiDirectPhase.CANCELLING)) { + Text(state.target?.deviceName?.ifBlank { state.target?.deviceAddress.orEmpty() } ?: "상대 기기") + TextButton(onClick = viewModel::cancelWifiDirectConnection, + enabled = state.phase == WifiDirectPhase.CONNECTING) { Text("취소") } + } + HorizontalDivider() + LazyColumn(Modifier.weight(1f, fill = false).heightIn(min = 80.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + if (state.peers.isEmpty()) item { Text("주변 Wi-Fi Direct 기기를 찾을 수 없습니다.") } + items(state.peers, key = { it.deviceAddress }) { device -> + val connected = state.link?.peers?.any { it.deviceAddress == device.deviceAddress } == true + val pending = state.target?.deviceAddress == device.deviceAddress && state.busy + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { + Column(Modifier.weight(1f)) { + Text(device.deviceName.ifBlank { device.deviceAddress }, style = MaterialTheme.typography.titleSmall) + Text(when { + connected -> "연결됨" + pending || device.status == WifiP2pDevice.INVITED -> "연결 중..." + device.status == WifiP2pDevice.FAILED -> "연결 실패 · 재시도 가능" + device.status == WifiP2pDevice.UNAVAILABLE -> "사용 불가" + device.status == WifiP2pDevice.CONNECTED -> "그룹 정보 확인 중..." + else -> "사용 가능" + }, style = MaterialTheme.typography.bodySmall) + } + if (!connected && !pending) TextButton( + onClick = { viewModel.connectWifiDirect(device) }, + enabled = state.canConnect && device.status in setOf(WifiP2pDevice.AVAILABLE, WifiP2pDevice.FAILED) + ) { Text(if (device.status == WifiP2pDevice.FAILED) "재시도" else "연결") } + } + } + } + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) { + TextButton(onClick = { searchOnReady = true; viewModel.refreshWifiDirect() }, + enabled = state.supported && !state.busy && !state.discovering) { Text("새로고침") } + TextButton(onClick = onDismiss) { Text("닫기") } + } + } + } + } +} diff --git a/Android/app/src/main/java/com/dct/app/presentation/dialog/WifiManualConnectionForm.kt b/Android/app/src/main/java/com/dct/app/presentation/dialog/WifiManualConnectionForm.kt index 6be33d8..b4fe324 100644 --- a/Android/app/src/main/java/com/dct/app/presentation/dialog/WifiManualConnectionForm.kt +++ b/Android/app/src/main/java/com/dct/app/presentation/dialog/WifiManualConnectionForm.kt @@ -50,7 +50,7 @@ internal fun WifiManualConnectionForm( onInputChange(WifiConnectionInput(server.hostIp, server.port.toString())) }) { Column(Modifier.padding(10.dp)) { - Text("탐색된 PC: ${server.deviceName} (${server.hostIp})", fontSize = 11.sp, color = Color(0xFF15803D)) + Text("탐색된 장비: ${server.deviceName} (${server.hostIp})", fontSize = 11.sp, color = Color(0xFF15803D)) Text("클릭하여 IP와 포트 적용", fontSize = 10.sp) } } diff --git a/Android/app/src/main/java/com/dct/app/presentation/main/BackgroundExecutionPermissionDialogs.kt b/Android/app/src/main/java/com/dct/app/presentation/main/BackgroundExecutionPermissionDialogs.kt new file mode 100644 index 0000000..85651f2 --- /dev/null +++ b/Android/app/src/main/java/com/dct/app/presentation/main/BackgroundExecutionPermissionDialogs.kt @@ -0,0 +1,95 @@ +package com.dct.app.presentation.main + +import android.Manifest +import android.content.Intent +import android.content.pm.PackageManager +import android.net.Uri +import android.os.Build +import android.os.PowerManager +import android.provider.Settings +import android.util.Log +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.ui.platform.LocalContext +import androidx.core.app.NotificationManagerCompat +import androidx.core.content.ContextCompat + +/** Permissions are explained once, only when there is an actual equipment session. */ +@Composable +internal fun BackgroundExecutionPermissionDialogs(running: Boolean, otherExplanationVisible: Boolean) { + val context = LocalContext.current + val preferences = remember { context.getSharedPreferences("dct_background_explanation", 0) } + var notificationExplained by remember { mutableStateOf(preferences.getBoolean("notification", false)) } + var batteryExplained by remember { mutableStateOf(preferences.getBoolean("battery", false)) } + var requestPending by rememberSaveable { mutableStateOf(false) } + val permissionLauncher = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted -> + requestPending = false + if (!granted) Log.w("DctBackgroundPermission", "Notification denied: session continues, drawer notification may be hidden") + } + val settingsLauncher = rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { + requestPending = false + } + fun openSettings(intent: Intent): Boolean = try { + requestPending = true + settingsLauncher.launch(intent) + true + } catch (error: RuntimeException) { + requestPending = false + Log.w("DctBackgroundPermission", "System settings unavailable: ${intent.action}", error) + false + } + fun explainNotificationDone() { + notificationExplained = true + preferences.edit().putBoolean("notification", true).apply() + } + fun explainBatteryDone() { + batteryExplained = true + preferences.edit().putBoolean("battery", true).apply() + } + if (!running || otherExplanationVisible || requestPending) return + val notificationsEnabled = NotificationManagerCompat.from(context).areNotificationsEnabled() + val ignoreBattery = context.getSystemService(PowerManager::class.java).isIgnoringBatteryOptimizations(context.packageName) + if (!notificationsEnabled && !notificationExplained) { + AlertDialog( + onDismissRequest = ::explainNotificationDone, + title = { Text("백그라운드 실행 알림") }, + text = { Text("앱을 벗어나도 장비 연결·계측·파일 기록이 계속됩니다. 알림에서 진행 상태를 확인하고 계측 정지 또는 연결 해제를 할 수 있도록 알림을 허용해 주세요.\n\n허용하지 않아도 계측은 계속되지만 알림 창에서 상태가 보이지 않을 수 있습니다.") }, + confirmButton = { Button(onClick = { + explainNotificationDone() + if (Build.VERSION.SDK_INT >= 33 && ContextCompat.checkSelfPermission(context, + Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) { + permissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS) + requestPending = true + } else { + openSettings(Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS) + .putExtra(Settings.EXTRA_APP_PACKAGE, context.packageName)) + } + }) { Text("알림 허용") } }, + dismissButton = { TextButton(onClick = ::explainNotificationDone) { Text("나중에") } } + ) + } else if (!ignoreBattery && !batteryExplained) { + AlertDialog( + onDismissRequest = ::explainBatteryDone, + title = { Text("화면이 꺼져도 계측 유지") }, + text = { Text("실시간 장비 데이터는 지연 없이 계속 수신해야 합니다. Android 절전 모드가 통신을 제한하지 않도록 DCT의 배터리 최적화 제한을 해제하는 것을 권장합니다.\n\n연결 중에는 CPU를 유지하므로 배터리 사용량이 늘어납니다. 해제하지 않으면 장시간 화면 꺼짐 상태에서 통신이 지연될 수 있습니다.") }, + confirmButton = { Button(onClick = { + explainBatteryDone() + if (!openSettings(Intent(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS) + .setData(Uri.parse("package:${context.packageName}")))) { + openSettings(Intent(Settings.ACTION_IGNORE_BATTERY_OPTIMIZATION_SETTINGS)) + } + }) { Text("배터리 제한 해제") } }, + dismissButton = { TextButton(onClick = ::explainBatteryDone) { Text("나중에") } } + ) + } +} diff --git a/Android/app/src/main/java/com/dct/app/presentation/main/DctTabletApp.kt b/Android/app/src/main/java/com/dct/app/presentation/main/DctTabletApp.kt index e753409..96c4e66 100644 --- a/Android/app/src/main/java/com/dct/app/presentation/main/DctTabletApp.kt +++ b/Android/app/src/main/java/com/dct/app/presentation/main/DctTabletApp.kt @@ -5,16 +5,9 @@ import androidx.compose.material3.Surface import androidx.compose.runtime.* import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color -import com.dct.app.core.common.DefaultDispatcherProvider -import com.dct.app.core.common.EventBus -import com.dct.app.data.communication.WifiSocketManager -import com.dct.app.data.repository.MdbRepositoryImpl -import com.dct.app.data.repository.PacketRepositoryImpl -import com.dct.app.domain.usecase.chart.ChartBatchRenderer -import com.dct.app.presentation.calibration.CalibrationViewModel -import com.dct.app.presentation.mdi.MdiViewModel +import androidx.compose.ui.platform.LocalContext +import com.dct.app.DctApplication import com.dct.app.presentation.monitor.DiagnosticDataMonitorScreen -import com.dct.app.presentation.signaltree.SignalTreeViewModel /** * DCT 태블릿 앱 엔트리포인트 @@ -25,32 +18,8 @@ fun DctTabletApp( viewModel: MainViewModel? = null, modifier: Modifier = Modifier ) { - val mainViewModel = viewModel ?: remember { - val dispatchers = DefaultDispatcherProvider() - val eventBus = EventBus() - val mdbRepo = MdbRepositoryImpl(dispatchers) - val chartRenderer = ChartBatchRenderer(dispatchers) - val treeVm = SignalTreeViewModel(dispatchers) - val mdiVm = MdiViewModel(dispatchers) - val calibVm = CalibrationViewModel(mdbRepo, dispatchers) - val wifiManager = WifiSocketManager() - val packetRepo = PacketRepositoryImpl( - wifiSocketManager = wifiManager, - ioDispatcher = dispatchers.io - ) - - MainViewModel( - mdbRepository = mdbRepo, - chartRenderer = chartRenderer, - treeViewModel = treeVm, - mdiViewModel = mdiVm, - calibrationViewModel = calibVm, - eventBus = eventBus, - dispatchers = dispatchers, - wifiSocketManager = wifiManager, - packetRepository = packetRepo - ) - } + // The service and UI use exactly the same process-wide engine and sample buffers. + val mainViewModel = viewModel ?: (LocalContext.current.applicationContext as DctApplication).session.viewModel Surface( modifier = modifier.fillMaxSize(), diff --git a/Android/app/src/main/java/com/dct/app/presentation/main/MainActivity.kt b/Android/app/src/main/java/com/dct/app/presentation/main/MainActivity.kt index 2f0894e..4822bf1 100644 --- a/Android/app/src/main/java/com/dct/app/presentation/main/MainActivity.kt +++ b/Android/app/src/main/java/com/dct/app/presentation/main/MainActivity.kt @@ -8,12 +8,37 @@ import android.os.Environment import android.provider.Settings import androidx.activity.ComponentActivity import androidx.activity.compose.setContent +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.FolderSpecial +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.OutlinedButton import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.graphics.Color import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp import com.dct.app.core.theme.DctTheme import com.dct.app.core.theme.DctThemeTokens +private const val PERMISSION_EXPLANATION_PREFS = "dct_permission_explanation" +private const val STORAGE_EXPLANATION_SHOWN = "storage_explanation_shown" + /** * DCT 안드로이드 태블릿 메인 액티비티 */ @@ -21,9 +46,18 @@ class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - // 모든 파일 관리 권한 및 통신(Wi-Fi Direct, Bluetooth, 위치) 권한 확인 및 요청 - checkAndRequestStoragePermission() - checkAndRequestCommunicationPermissions() + // 처음 실행할 때 시스템 권한 화면으로 바로 이동하지 않고, 먼저 + // 권한의 용도와 제한 사항을 설명한다. 실제 요청은 사용자가 + // "권한 설정"을 누른 뒤에만 수행한다. + val shouldExplainStoragePermission = storagePermissionRequired() && + !getSharedPreferences(PERMISSION_EXPLANATION_PREFS, MODE_PRIVATE) + .getBoolean(STORAGE_EXPLANATION_SHOWN, false) + if (shouldExplainStoragePermission) { + getSharedPreferences(PERMISSION_EXPLANATION_PREFS, MODE_PRIVATE) + .edit() + .putBoolean(STORAGE_EXPLANATION_SHOWN, true) + .apply() + } // 앱 시작 시 Documents/DCT Work 및 8대 서브폴더 자동 생성 try { @@ -31,6 +65,10 @@ class MainActivity : ComponentActivity() { } catch (_: Exception) {} setContent { + var showStoragePermissionExplanation by rememberSaveable { + mutableStateOf(shouldExplainStoragePermission) + } + DctTheme { Surface( modifier = Modifier.fillMaxSize(), @@ -38,6 +76,18 @@ class MainActivity : ComponentActivity() { ) { DctTabletApp() } + + if (showStoragePermissionExplanation) { + StoragePermissionExplanationDialog( + onRequestPermission = { + showStoragePermissionExplanation = false + checkAndRequestStoragePermission() + }, + onDismiss = { showStoragePermissionExplanation = false } + ) + } + val backgroundRunning by (application as com.dct.app.DctApplication).session.serviceRunning.collectAsState() + BackgroundExecutionPermissionDialogs(backgroundRunning, showStoragePermissionExplanation) } } } @@ -50,24 +100,23 @@ class MainActivity : ComponentActivity() { } catch (_: Exception) {} } - private fun checkAndRequestCommunicationPermissions() { - val permissions = mutableListOf() + override fun onStart() { + super.onStart() + (application as com.dct.app.DctApplication).session.onUiStarted() + } - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - permissions.add(android.Manifest.permission.NEARBY_WIFI_DEVICES) - } - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { - permissions.add(android.Manifest.permission.BLUETOOTH_SCAN) - permissions.add(android.Manifest.permission.BLUETOOTH_CONNECT) - } - permissions.add(android.Manifest.permission.ACCESS_FINE_LOCATION) - permissions.add(android.Manifest.permission.ACCESS_COARSE_LOCATION) + override fun onStop() { + (application as com.dct.app.DctApplication).session.onUiStopped() + super.onStop() + } - val needed = permissions.filter { - checkSelfPermission(it) != android.content.pm.PackageManager.PERMISSION_GRANTED - } - if (needed.isNotEmpty()) { - requestPermissions(needed.toTypedArray(), 1002) + /** 저장소 권한이 없어 최초 안내가 필요한지 확인한다. */ + private fun storagePermissionRequired(): Boolean { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + !Environment.isExternalStorageManager() + } else { + checkSelfPermission(android.Manifest.permission.WRITE_EXTERNAL_STORAGE) != + android.content.pm.PackageManager.PERMISSION_GRANTED } } @@ -101,3 +150,72 @@ class MainActivity : ComponentActivity() { } } +/** + * 권한 요청 전에 표시하는 최초 실행 안내 화면. + * 시스템 설정 화면은 사용자가 확인한 뒤에만 연다. + */ +@Composable +private fun StoragePermissionExplanationDialog( + onRequestPermission: () -> Unit, + onDismiss: () -> Unit +) { + val isAllFilesAccess = Build.VERSION.SDK_INT >= Build.VERSION_CODES.R + + AlertDialog( + onDismissRequest = onDismiss, + icon = { + Icon( + imageVector = Icons.Default.FolderSpecial, + contentDescription = null, + tint = Color(0xFF2563EB), + modifier = Modifier.size(34.dp) + ) + }, + title = { + Text( + text = "권한 안내", + fontWeight = FontWeight.Bold, + fontSize = 18.sp + ) + }, + text = { + Column( + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.padding(top = 2.dp) + ) { + Text( + text = "DCT는 프로젝트와 계측 데이터를 파일로 저장하고 다시 열기 위해 파일 접근 권한이 필요합니다.", + fontSize = 14.sp, + color = Color(0xFF334155) + ) + Text( + text = "• 프로젝트(.dpc), MDB/A2L/DBC 파일 열기\n• Documents/DCT Work에 계측 기록 저장\n• 측정 로그와 유지보수 파일 관리", + fontSize = 13.sp, + color = Color(0xFF475569) + ) + Text( + text = if (isAllFilesAccess) { + "다음 화면에서 '모든 파일에 대한 접근'을 허용해 주세요. 허용하지 않아도 앱은 실행되지만 파일 저장·불러오기 기능이 제한됩니다." + } else { + "권한을 허용하지 않아도 앱은 실행되지만 파일 저장·불러오기 기능이 제한됩니다." + }, + fontSize = 13.sp, + color = Color(0xFF64748B) + ) + } + }, + confirmButton = { + Button( + onClick = onRequestPermission, + colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF2563EB)) + ) { + Text("권한 설정", fontSize = 13.sp) + } + }, + dismissButton = { + OutlinedButton(onClick = onDismiss) { + Text("나중에", fontSize = 13.sp, color = Color(0xFF475569)) + } + } + ) +} 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 f091f63..5e4ee2d 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 @@ -2,11 +2,14 @@ package com.dct.app.presentation.main import android.content.Context import android.net.Uri +import android.util.Log import androidx.lifecycle.ViewModel import com.dct.app.core.common.AppEvent import com.dct.app.core.common.DctResult import com.dct.app.core.common.DispatcherProvider import com.dct.app.core.common.EventBus +import com.dct.app.core.common.PacketPipelineMetrics +import com.dct.app.core.theme.toColorRef import com.dct.app.data.parser.A2lParser.A2lCharacteristic import com.dct.app.data.parser.A2lParser.A2lCharacteristicArray import com.dct.app.domain.model.WorkspacePageState @@ -21,7 +24,9 @@ import com.dct.app.domain.model.mdb.SignalDefinition import com.dct.app.domain.repository.MdbRepository import com.dct.app.domain.repository.DeviceConnectionState import com.dct.app.domain.usecase.chart.ChartBatchRenderer +import com.dct.app.domain.usecase.chart.ChartTelemetryResolver import com.dct.app.presentation.calibration.CalibrationViewModel +import com.dct.app.presentation.mdi.ALPHANUMERICAL_DEFAULT_WIDTH_DP import com.dct.app.presentation.mdi.MdiViewModel import com.dct.app.presentation.mdi.MdiWindowType import com.dct.app.presentation.signaltree.AddToChartRequest @@ -51,6 +56,25 @@ enum class CommunicationType { WIFI } +/** + * START 요청 시점과 소켓 수신 시점은 서로 다른 시계 기준을 사용할 수 있다. + * 모노토닉 기반 수신 시각을 벽시계 요청 시각과 비교할 때 허용할 지터 범위다. + */ +private const val MEASUREMENT_TIMESTAMP_GRACE_MILLIS = 200L + +/** PC의 IsRecordingData와 동일하게 계측 상태와 독립적으로 관리되는 파일 기록 상태. */ +enum class MeasurementRecordingState { + IDLE, STARTING, RECORDING, STOPPING, ERROR +} + +/** Guards project open/restore as one UI transaction. */ +enum class ProjectLoadState { + IDLE, + LOADING, + COMPLETED, + FAILED +} + /** * 메인 애플리케이션 UI 상태 */ @@ -62,6 +86,9 @@ data class MainUiState( val projectFileName: String? = null, val isMdbLoading: Boolean = false, val isMeasuring: Boolean = false, + val recordingState: MeasurementRecordingState = MeasurementRecordingState.IDLE, + val recordingStartedAtMillis: Long? = null, + val recordingSampleCount: Long = 0L, val measurementFrequencyHz: Int = 30, val packetCount: Long = 0L, val dashboardTiles: List = emptyList(), @@ -88,15 +115,25 @@ class MainViewModel( val wifiSocketManager: com.dct.app.data.communication.WifiSocketManager = com.dct.app.data.communication.WifiSocketManager(), val packetRepository: com.dct.app.domain.repository.PacketRepository = com.dct.app.data.repository.PacketRepositoryImpl( wifiSocketManager = wifiSocketManager - ) + ), + val pipelineMetrics: PacketPipelineMetrics = PacketPipelineMetrics(), + private val ensureBackgroundExecution: () -> Boolean = { true }, + private val operationalLogger: com.dct.app.data.logger.PcOperationalLogger? = null ) : ViewModel() { val scope: CoroutineScope = CoroutineScope(dispatchers.mainImmediate + SupervisorJob()) private val calibrationMutex = kotlinx.coroutines.sync.Mutex() private var calibrationJob: Job? = null private val sessionMutex = kotlinx.coroutines.sync.Mutex() + private val recordingMutex = kotlinx.coroutines.sync.Mutex() private val connectionGeneration = java.util.concurrent.atomic.AtomicLong() + @Volatile private var gapMarkedGeneration = Long.MIN_VALUE + @Volatile private var measurementRequestMillis = 0L @Volatile private var configurationRequired = false + private val chartTelemetryResolver = ChartTelemetryResolver() + private val formulaTelemetryLock = Any() + private val formulaTelemetryEngine = com.dct.app.core.formula.FormulaTelemetryEngine() + private var configuredMdbSelection: List>? = null private val _isCalibrationBusy = MutableStateFlow(false) val isCalibrationBusy: StateFlow = _isCalibrationBusy.asStateFlow() @@ -149,16 +186,27 @@ class MainViewModel( init { // 실제 DCT 하드웨어 패킷 스트림 수집 - scope.launch(dispatchers.default) { + scope.launch(dispatchers.io) { packetRepository.packetStream.collect { packet -> + val processingStartedNanos = System.nanoTime() + if (packet.receivedAtNanos > 0L) { + pipelineMetrics.recordReceiveToProcess(processingStartedNanos - packet.receivedAtNanos) + } try { - val nowSec = System.currentTimeMillis() / 1000.0 + val nowSec = packet.receivedAtSec.takeIf { it.isFinite() } + ?: (packet.receivedAtMillis.takeIf { it > 0 } ?: System.currentTimeMillis()) / 1000.0 processIncomingPacket(packet, nowSec) } catch (e: kotlinx.coroutines.CancellationException) { throw e } catch (e: Exception) { // A bad packet/logging error must not terminate the long-lived subscription. - _uiState.update { it.copy(statusMessage = "수신 패킷 처리 실패 (다음 패킷 수신 계속): ${e.message}") } + // Packet errors are diagnostic events, not user actions: keeping them out + // of statusMessage prevents the global Toast effect from firing repeatedly + // while a damaged stream is arriving. + Log.e(LOG_TAG, "수신 패킷 처리 실패 (다음 패킷 수신 계속): ${e.message}", e) + } finally { + pipelineMetrics.recordProcessing(System.nanoTime() - processingStartedNanos) + pipelineMetrics.maybeLog("MainViewModel") } } } @@ -168,6 +216,7 @@ class MainViewModel( val isDisconnectedOrError = state == DeviceConnectionState.ERROR || state == DeviceConnectionState.DISCONNECTED if (isDisconnectedOrError) { if (_uiState.value.isMeasuring) { + markChartDataGap() val generation = connectionGeneration.get() scope.launch(dispatchers.io) { sessionMutex.withLock { @@ -256,6 +305,14 @@ class MainViewModel( val pages: StateFlow> = _pages.asStateFlow() private val _activePageId = MutableStateFlow("page_1") + private var applicationVisible = true + + /** Rendering alone follows Activity visibility; transport, samples and recorders keep running. */ + fun setApplicationVisible(visible: Boolean) { + applicationVisible = visible + chartRenderer.setApplicationVisible(visible) + _windowChartRenderers.values.forEach { it.setApplicationVisible(visible) } + } val activePageId: StateFlow = _activePageId.asStateFlow() private var chartRenderLoopJob: Job? = null @@ -301,8 +358,21 @@ class MainViewModel( } private val _windowChartRenderers = java.util.concurrent.ConcurrentHashMap() - private var projectLoadJob: Job? = null + + /** Actual deletion only. Page switches and drag-to-page deliberately do not call this. */ + fun closeWorkspaceWindow(windowId: String) { + val window = mdiViewModel.uiState.value.windows.find { it.id == windowId } ?: return + if (!window.isClosable) return + mdiViewModel.closeWindow(windowId) + _windowChartRenderers.remove(windowId)?.dispose() + _pages.update { pages -> pages.map { page -> + if (page.id == _activePageId.value) page.copy(windows = mdiViewModel.uiState.value.windows) else page + } } + } + private val projectLoadGuard = Any() @Volatile private var isProjectLoading = false + private val _projectLoadState = MutableStateFlow(ProjectLoadState.IDLE) + val projectLoadState: StateFlow = _projectLoadState.asStateFlow() private val _workspaceRevision = MutableStateFlow(0L) val workspaceRevision: StateFlow = _workspaceRevision.asStateFlow() private val viewportReaders = mutableMapOf Pair>() @@ -375,7 +445,9 @@ class MainViewModel( */ fun getChartRenderer(windowId: String): ChartBatchRenderer { return _windowChartRenderers.computeIfAbsent(windowId) { wId -> - val renderer = ChartBatchRenderer(dispatchers) + val renderer = ChartBatchRenderer(dispatchers, pipelineMetrics = pipelineMetrics) + renderer.setApplicationVisible(applicationVisible) + renderer.setRenderingEnabled(false) // Activated by the graph's composable lifecycle. renderer.setGraphInterpolationMode(_uiState.value.graphInterpolationMode) renderer.start(scope) if (!_uiState.value.isMeasuring) { @@ -391,7 +463,13 @@ class MainViewModel( } else { renderer.setLegendPosition(0) // PC 사양: 단일축 기본 우측(Right) } - renderer.restoreWorkspaceState(win.config.boundSignals, win.config.chartState) + // PC CChartCtrl exposes MAX_LEFT_AXIS=4 in MultiXGraphWnd. A + // project saved by a newer Android build may contain more + // entries; restore only the same four slots as the PC client. + val maxSignals = if (win.type == MdiWindowType.MULTI_AXIS_GRAPH) { + AxisPosition.LEFT_AXES.size + } else null + renderer.restoreWorkspaceState(win.config.boundSignals, win.config.chartState, maxSignals) if (win.config.chartState == null) renderer.setTimeWindow(win.config.timeWindowPreset) } renderer @@ -402,12 +480,19 @@ class MainViewModel( * 차트 축 바인딩 처리 (AddToChartRequest 지원) */ fun handleAddToChart(req: AddToChartRequest) { - chartRenderer.bindSignal( + // SignalTreeViewModel에서 발생한 요청도 드롭과 동일하게 현재 + // 그래프 창의 독립 렌더러에 등록해야 한다. 이전에는 전역 + // chartRenderer에만 등록되어 GraphWindow/MultiAxisGraph의 화면에는 + // 시리즈가 없고, 수신 샘플도 삽입되지 않는 경로가 있었다. + handleAddToChart( signalId = req.signalId, signalName = req.signalName, - axisPosition = req.axisPosition, - color = req.color, - unit = req.unit + colorLong = req.color, + unit = req.unit, + isCan = req.signalId.startsWith("can_", ignoreCase = true), + minVal = req.rawSignal?.min, + maxVal = req.rawSignal?.max, + preferredAxis = req.axisPosition ) } @@ -422,7 +507,8 @@ class MainViewModel( isCan: Boolean = false, minVal: Double? = null, maxVal: Double? = null, - targetWindowId: String? = null + targetWindowId: String? = null, + preferredAxis: AxisPosition? = null ) { val currentWinList = mdiViewModel.uiState.value.windows val graphWindows = currentWinList.filter { it.type in setOf(MdiWindowType.GRAPH, MdiWindowType.CHART, MdiWindowType.MULTI_AXIS_GRAPH) } @@ -431,44 +517,64 @@ class MainViewModel( } else { graphWindows.find { it.id == mdiViewModel.uiState.value.activeWindowId } ?: graphWindows.firstOrNull() } + var targetRejectedByLimit = false // 1. 해당 윈도우 전용 독립 렌더러에 등록 (창별 완벽 격리) if (targetWin != null) { val renderer = getChartRenderer(targetWin.id) val activeSeries = renderer.seriesListFlow.value if (!activeSeries.any { it.signalId == signalId }) { - val targetAxis = if (targetWin.type == MdiWindowType.GRAPH) { - AxisPosition.LEFT_0 + if (targetWin.type == MdiWindowType.MULTI_AXIS_GRAPH && + activeSeries.size >= AxisPosition.LEFT_AXES.size + ) { + // CMultiXGraphWnd::GetEmptyLeftAxisIndex() returns -1 + // after MAX_LEFT_AXIS (4); it does not recycle an axis. + targetRejectedByLimit = true + Log.i(LOG_TAG, "MultiAxisGraph item limit reached: ${AxisPosition.LEFT_AXES.size}") + // DiagnosticDataMonitorScreen observes statusMessage and presents it as a + // short Toast. Keep the rejection visible without changing existing items. + _uiState.update { it.copy(statusMessage = "다중축 그래프는 최대 4개까지 지원합니다.") } } else { - val usedAxes = activeSeries.map { it.axisPosition }.toSet() - AxisPosition.LEFT_AXES.firstOrNull { it !in usedAxes } - ?: AxisPosition.LEFT_AXES[activeSeries.size % AxisPosition.LEFT_AXES.size] - } - - renderer.bindSignal( - signalId = signalId, - signalName = signalName, - axisPosition = targetAxis, - color = colorLong, - unit = unit, - minVal = minVal, - maxVal = maxVal - ) + val targetAxis = if (targetWin.type == MdiWindowType.GRAPH || + targetWin.type == MdiWindowType.CHART + ) { + AxisPosition.LEFT_0 + } else { + // PC allocates one free left axis per item and stops + // when all four slots are occupied. The Android + // request's preferred axis is retained only by the + // global compatibility renderer below; window-local + // MultiAxisGraph follows the PC allocation order. + val usedAxes = activeSeries.map { it.axisPosition }.toSet() + AxisPosition.LEFT_AXES.firstOrNull { it !in usedAxes } + ?: AxisPosition.LEFT_0 + } - val updatedSeries = renderer.seriesListFlow.value - mdiViewModel.updateWindowConfig( - targetWin.id, - targetWin.config.copy(boundSignals = updatedSeries) - ) + renderer.bindSignal( + signalId = signalId, + signalName = signalName, + axisPosition = targetAxis, + color = colorLong, + unit = unit, + minVal = minVal, + maxVal = maxVal + ) + + val updatedSeries = renderer.seriesListFlow.value + mdiViewModel.updateWindowConfig( + targetWin.id, + targetWin.config.copy(boundSignals = updatedSeries) + ) + } } } // 2. 기본 chartRenderer (단일 차트 및 테스트 호환성 유지) val globalActive = chartRenderer.seriesListFlow.value - if (!globalActive.any { it.signalId == signalId }) { + if (!targetRejectedByLimit && !globalActive.any { it.signalId == signalId }) { val usedAxes = globalActive.map { it.axisPosition }.toSet() - val targetAxis = AxisPosition.LEFT_AXES.firstOrNull { it !in usedAxes } - ?: AxisPosition.LEFT_AXES[globalActive.size % AxisPosition.LEFT_AXES.size] + val targetAxis = preferredAxis ?: (AxisPosition.LEFT_AXES.firstOrNull { it !in usedAxes } + ?: AxisPosition.LEFT_AXES[globalActive.size % AxisPosition.LEFT_AXES.size]) chartRenderer.bindSignal( signalId = signalId, @@ -614,6 +720,17 @@ class MainViewModel( } } + /** + * 햄버거 메뉴의 그래프 초기화: 모든 그래프의 현재 샘플만 삭제한다. + * 신호 바인딩, 창/페이지, 색상, 축 및 그래프 옵션은 보존한다. + */ + fun resetGraphData() { + val renderers = LinkedHashSet() + renderers += chartRenderer + renderers += _windowChartRenderers.values + renderers.forEach { it.clearSamplesOnly() } + } + /** * 기본 Signal list MDI 서브윈도우 인스턴스 생성 */ @@ -651,9 +768,10 @@ class MainViewModel( // CAN 신호 대조 val canList = _canSignals.value - val can1 = canList.find { "can_${it.idx}" == key1 || it.name.equals(key1, ignoreCase = true) } - val can2 = canList.find { "can_${it.idx}" == key2 || it.name.equals(key2, ignoreCase = true) } - if (can1 != null && can2 != null && can1.idx == can2.idx && can1.name == can2.name) return true + val can1 = com.dct.app.domain.model.mdb.CanSignalKey.find(key1, canList) + val can2 = com.dct.app.domain.model.mdb.CanSignalKey.find(key2, canList) + if (can1 != null && can2 != null && + can1.messageId == can2.messageId && can1.name == can2.name) return true // A2L 신호 대조 val a2lList = _a2lSignals.value @@ -789,7 +907,7 @@ class MainViewModel( } /** - * X축 시간 윈도우 프리셋 변경 (10s, 30s, 1m, 5m, 10m, Auto) + * X축 시간 윈도우 프리셋 변경 (10s, 30s, 1m, 5m, 10m, Auto, Hold) */ fun setChartTimeWindowPreset(preset: com.dct.app.domain.model.chart.TimeWindowPreset, targetWindowId: String? = null) { val targetId = targetWindowId ?: mdiViewModel.uiState.value.activeWindowId ?: return @@ -821,7 +939,7 @@ class MainViewModel( * 프로젝트 전체 탭을 고려하여 순차 번호가 매겨진 새 Alphanumerical 윈도우 생성 ("Alphanumerical Window 01", "Alphanumerical Window 02" 등 전역 번호 부여) */ fun addAlphanumericalWindow( - defaultWidth: Float = 380f, + defaultWidth: Float = ALPHANUMERICAL_DEFAULT_WIDTH_DP, defaultHeight: Float = 320f, viewportScrollX: Float = 0f, viewportScrollY: Float = 0f @@ -913,6 +1031,7 @@ class MainViewModel( companion object { const val MAX_WORKSPACE_PAGES = 5 + private const val LOG_TAG = "DctMainViewModel" } /** @@ -970,6 +1089,8 @@ class MainViewModel( if (pageIndex == -1) return val newPages = currentPages.filter { it.id != pageId } + val removedWindows = if (_activePageId.value == pageId) mdiViewModel.uiState.value.windows else targetPage.windows + removedWindows.forEach { _windowChartRenderers.remove(it.id)?.dispose() } _pages.value = newPages if (_activePageId.value == pageId) { @@ -1383,7 +1504,9 @@ class MainViewModel( * 프로젝트 전환 또는 신규 프로젝트 생성 시 화면 및 상태를 완전히 초기화 * (기존 서브윈도우, 차트 바인딩, 페이지, 텔레메트리 데이터 완전 리셋) */ - fun resetWorkspaceForNewProject() { + fun resetWorkspaceForNewProject(logNewProject: Boolean = true) { + if (_currentProject.value != null) operationalLogger?.action("Close project file") + if (logNewProject) operationalLogger?.action("New project") calibrationJob?.cancel() _workspaceRevision.value++ viewportReaders.clear() @@ -1406,7 +1529,7 @@ class MainViewModel( _activePageId.value = "page_1" // 5. 텔레메트리 수신 데이터 초기화 - _liveTelemetryValues.value = emptyMap() + clearFormulaTelemetry() // 6. 신호 트리 및 캘리브레이션 뷰모델 초기화 treeViewModel.clear() @@ -1447,12 +1570,21 @@ class MainViewModel( } } + private val pendingWifiTarget = java.util.concurrent.atomic.AtomicReference(null) + + @Synchronized fun connectWifi( deviceName: String, - hostIp: String = "192.168.4.1", - port: Int = 6000, + hostIp: String, + port: Int = com.dct.app.data.communication.WifiSocketManager.DEFAULT_PORT, isGroupOwner: Boolean = false ) { + require(hostIp.isNotBlank()) { "Wi-Fi Direct 그룹 주소가 없습니다." } + val target = "$hostIp:$port:$isGroupOwner" + if (pendingWifiTarget.get() == target) return + if (_uiState.value.isConnected && wifiSocketManager.connectedHost.value == hostIp) return + if (!ensureBackgroundExecution()) return + pendingWifiTarget.set(target) val generation = connectionGeneration.incrementAndGet() configurationRequired = true val roleLabel = if (isGroupOwner) "서버(GO)" else "클라이언트(GC)" @@ -1466,7 +1598,9 @@ class MainViewModel( _uiState.update { it.copy(isConnected = false, communicationType = CommunicationType.WIFI, connectedDeviceName = deviceName, - statusMessage = "Wi-Fi [$deviceName] ($hostIp:$port, $roleLabel) 통신 개시 중...") + // Connection progress is rendered by the Wi-Fi Direct dialog. Do not + // enqueue a Toast for every intermediate handshake phase. + statusMessage = null) } wifiSocketManager.startDualMode( isGroupOwner = isGroupOwner, hostIp = hostIp, port = port, @@ -1474,26 +1608,43 @@ class MainViewModel( scope.launch(dispatchers.io) { sessionMutex.withLock { if (generation != connectionGeneration.get()) return@withLock - configurationRequired = true - when (val result = packetRepository.connect(remoteAddress)) { - is DctResult.Success -> { - if (generation == connectionGeneration.get()) { - syncDeviceConfigurationLocked(generation) + Log.i(LOG_TAG, "Wi-Fi Direct TCP 연결 완료: $remoteAddress ($roleLabel)") + try { + configurationRequired = true + // The PC handshake exchanges CAN/SM settings + // before the version ACK. Supply the same + // project snapshot without changing the legacy + // one-argument connect API used by test doubles. + packetRepository.setInitialMeasurementConfiguration( + createDeviceMeasurementConfiguration() + ) + when (val result = packetRepository.connect(remoteAddress)) { + is DctResult.Success -> { + if (generation == connectionGeneration.get()) { + syncDeviceConfigurationLocked(generation, notifyProgress = false) + } } + is DctResult.Failure -> if (generation == connectionGeneration.get()) { + setStatusMessage(result.error.message) + } + DctResult.Loading -> Unit } - is DctResult.Failure -> if (generation == connectionGeneration.get()) { - setStatusMessage(result.error.message) - } - DctResult.Loading -> Unit + } finally { + if (generation == connectionGeneration.get()) pendingWifiTarget.compareAndSet(target, null) } } } }, onWaiting = { message -> - if (generation == connectionGeneration.get()) setStatusMessage(message) + 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") + } }, onFailure = { error -> if (generation == connectionGeneration.get()) { + pendingWifiTarget.compareAndSet(target, null) _uiState.update { it.copy(isConnected = false, communicationType = CommunicationType.NONE, connectedDeviceName = null, statusMessage = "Wi-Fi 연결 실패: $error") } } @@ -1504,16 +1655,94 @@ class MainViewModel( } private var registeredWifiDirectManager: com.dct.app.data.communication.WifiDirectManager? = null + private val _wifiDirectState = MutableStateFlow(com.dct.app.data.communication.WifiDirectUiState()) + val wifiDirectState: StateFlow = _wifiDirectState.asStateFlow() + private var p2pObserver: Job? = null + private var lastP2pKey: String? = null + + /** Application context only; one manager and one receiver for this retained ViewModel. */ + fun initializeWifiDirect(context: Context) { + storageContext = context.applicationContext + scope.launch(dispatchers.io) { + runCatching { com.dct.app.data.logger.MeasurementSafExports.exportReady(context.applicationContext) } + .onFailure { Log.w(LOG_TAG, "이전 측정 파일 SAF 내보내기 재시도 실패", it) } + } + if (registeredWifiDirectManager != null) return + registerWifiDirectManager(com.dct.app.data.communication.WifiDirectManager(context.applicationContext)) + registeredWifiDirectManager?.start() + } + + fun refreshWifiDirect() { registeredWifiDirectManager?.refresh() } + fun discoverWifiDirect() { registeredWifiDirectManager?.discoverPeers() } + fun stopWifiDiscovery() { registeredWifiDirectManager?.stopPeerDiscovery() } + fun connectWifiDirect(device: android.net.wifi.p2p.WifiP2pDevice) { + val manager = registeredWifiDirectManager ?: return + if (manager.uiState.value.canConnect && ensureBackgroundExecution()) manager.connect(device) + } + fun cancelWifiDirectConnection() { registeredWifiDirectManager?.cancelConnect() } + fun removeWifiDirectGroup() { registeredWifiDirectManager?.disconnect() } + fun retryWifiDirectTcp() { + val state = wifiDirectState.value + val link = state.link ?: return + if (state.synchronizedWithOs && state.phase == com.dct.app.data.communication.WifiDirectPhase.CONNECTED) + link.info.groupOwnerAddress?.hostAddress?.let { connectWifi(link.displayName, it, isGroupOwner = link.info.isGroupOwner) } + } fun registerWifiDirectManager(manager: com.dct.app.data.communication.WifiDirectManager) { + if (registeredWifiDirectManager === manager) return + p2pObserver?.cancel() registeredWifiDirectManager = manager + wifiSocketManager.setWifiDirectRouteProvider { manager.currentP2pRoute() } + p2pObserver = scope.launch { + manager.uiState.collect { state -> + _wifiDirectState.value = state + if (!state.synchronizedWithOs) return@collect + val link = state.link + if (link != null && state.phase == com.dct.app.data.communication.WifiDirectPhase.CONNECTED && + link.info.groupOwnerAddress != null && link.key != lastP2pKey) { + lastP2pKey = link.key + connectWifi(link.displayName, link.info.groupOwnerAddress.hostAddress!!, isGroupOwner = link.info.isGroupOwner) + } else if (link == null && lastP2pKey != null) { + lastP2pKey = null + closeWifiTransportAfterGroupLoss() + } + } + } + } + + private fun closeWifiTransportAfterGroupLoss() { + val generation = connectionGeneration.incrementAndGet() + pendingWifiTarget.set(null) + configurationRequired = true + // Invalidate immediately: queued cleanup must never close a newly formed group's socket. + wifiSocketManager.invalidateConnection() + scope.launch(dispatchers.io) { + sessionMutex.withLock { + if (generation != connectionGeneration.get()) return@withLock + packetRepository.disconnect() + finishMeasurementSession() + _uiState.update { it.copy(isConnected = false, isMeasuring = false, + communicationType = CommunicationType.NONE, connectedDeviceName = null, + statusMessage = "Wi-Fi Direct 연결이 해제되었습니다.") } + } + } } /** * Wi-Fi Direct / TCP 통신 연결 해제. */ + @Synchronized fun disconnectCommunication(context: Context? = null) { + registeredWifiDirectManager?.let { manager -> + when { + manager.uiState.value.link != null -> manager.disconnect() + manager.uiState.value.phase == com.dct.app.data.communication.WifiDirectPhase.CONNECTING -> manager.cancelConnect() + else -> closeWifiTransportAfterGroupLoss() + } + return + } connectionGeneration.incrementAndGet() // Invalidate pending START/handshake callbacks immediately. + pendingWifiTarget.set(null) configurationRequired = true _uiState.update { it.copy(isConnected = false, communicationType = CommunicationType.NONE, connectedDeviceName = null, statusMessage = "통신 연결 해제 중...") } @@ -1524,12 +1753,6 @@ class MainViewModel( } finally { finishMeasurementSession() wifiSocketManager.disconnectSuspend() - registeredWifiDirectManager?.disconnect() - if (context != null && registeredWifiDirectManager == null) { - try { - com.dct.app.data.communication.WifiDirectManager(context).disconnect() - } catch (_: Exception) {} - } _uiState.update { it.copy(isConnected = false, isMeasuring = false, communicationType = CommunicationType.NONE, connectedDeviceName = null, statusMessage = "통신 연결이 해제되었습니다.") @@ -1542,36 +1765,48 @@ class MainViewModel( /** * 외부 SAF Uri로부터 .dpc 프로젝트 파일 열기 및 로드 */ - fun openProjectFromUri(context: Context, uri: Uri) { - loadProjectAndWorkspace(context) { + fun openProjectFromUri(context: Context, uri: Uri): Boolean { + return loadProjectAndWorkspace(context, uri.toString()) { com.dct.app.data.repository.DpcProjectManager.loadProject(context, uri) } } - fun openProjectFromFile(context: Context, file: File) { - loadProjectAndWorkspace(context) { + fun openProjectFromFile(context: Context, file: File): Boolean { + return loadProjectAndWorkspace(context, file.absolutePath) { com.dct.app.data.repository.DpcProjectManager.loadProjectFromFile(file) } } private fun loadProjectAndWorkspace( context: Context, + source: String, load: () -> com.dct.app.domain.model.project.DpcProject? - ) { - val previous = projectLoadJob - projectLoadJob = scope.launch(dispatchers.io) { - previous?.cancel() - previous?.join() + ): Boolean { + // Reserve the load before launching the IO coroutine. This closes the + // small race where two taps arrive before the first coroutine starts. + synchronized(projectLoadGuard) { + if (isProjectLoading || propertyUpdateInProgress) { + setStatusMessage("프로젝트 처리 중입니다. 완료 후 다시 시도해 주세요.") + return false + } isProjectLoading = true + _projectLoadState.value = ProjectLoadState.LOADING + } + + scope.launch(dispatchers.io) { + var completionState = ProjectLoadState.FAILED try { _uiState.update { it.copy(isMdbLoading = true, statusMessage = "프로젝트 로딩 중...") } - val project = load() + val project = load()?.let { + com.dct.app.data.repository.DpcProjectManager.publishAvailableProjectSources(context, it) + } if (project == null) { + operationalLogger?.action("Open project failed: $source") _uiState.update { it.copy(statusMessage = "프로젝트 로드 실패: 올바른 .dpc 파일이 아닙니다.") } return@launch } withContext(dispatchers.mainImmediate) { - resetWorkspaceForNewProject() + resetWorkspaceForNewProject(logNewProject = false) setCurrentProject(project) } fun sourceFile(localPath: String?, name: String, folder: String): File? { @@ -1610,15 +1845,23 @@ class MainViewModel( statusMessage = "프로젝트 로드 완료: ${project.projectFileName}$warning" ) } } + completionState = ProjectLoadState.COMPLETED + operationalLogger?.action("Open project file $source") } catch (e: kotlinx.coroutines.CancellationException) { + completionState = ProjectLoadState.IDLE throw e } catch (e: Exception) { + operationalLogger?.action("Open project failed: ${e.message}") _uiState.update { it.copy(statusMessage = "프로젝트 로드 실패: ${e.message}") } } finally { - isProjectLoading = false + synchronized(projectLoadGuard) { + isProjectLoading = false + _projectLoadState.value = completionState + } _uiState.update { it.copy(isMdbLoading = false) } } } + return true } private suspend fun awaitProjectSource(job: Job) { @@ -1632,6 +1875,25 @@ class MainViewModel( } } + private fun logCanDefinitions(source: String, signals: List) { + val messageIds = signals.asSequence().map { it.messageId }.distinct().toList() + val standard = messageIds.count { it in 0L..0xFFFFL } + val extended = messageIds.size - standard + Log.i( + LOG_TAG, + "CAN DBC 로드: source=$source, signals=${signals.size}, " + + "messages=${messageIds.size}, standard16=$standard, unsupportedExtended=$extended" + ) + if (extended > 0) { + Log.w( + LOG_TAG, + "DCT A3 wire가 16-bit CAN ID만 제공하여 확장 메시지는 실시간 수신에서 제외됩니다: " + + messageIds.filter { it !in 0L..0xFFFFL }.take(24) + .joinToString { "0x%08X".format(it) } + ) + } + } + /** * 외부 SAF Uri로부터 CAN DBC 파일 열기 및 파싱 */ @@ -1667,6 +1929,7 @@ class MainViewModel( stream.use { s -> val signals = com.dct.app.data.parser.CanDbcParser.parseDbc(s) _canSignals.value = applyProjectOverridesToCan(signals) + logCanDefinitions(dbcName, _canSignals.value) _uiState.update { it.copy( canDbcFileName = dbcName, @@ -1695,6 +1958,7 @@ class MainViewModel( file.inputStream().use { stream -> val signals = com.dct.app.data.parser.CanDbcParser.parseDbc(stream) _canSignals.value = applyProjectOverridesToCan(signals) + logCanDefinitions(file.name, _canSignals.value) _uiState.update { it.copy( canDbcFileName = file.name, @@ -1877,6 +2141,7 @@ class MainViewModel( val target = _a2lCharacteristics.value.find { it.name == name } ?: return val revision = _workspaceRevision.value runCalibration("A2L [$name] 쓰기") { + require(target.calibrationLayoutError == null) { target.calibrationLayoutError ?: "지원하지 않는 Record Layout입니다." } require(newValue.isFinite() && newValue in target.lowerLimit..target.upperLimit) { "값이 허용 범위를 벗어났습니다." } packetRepository.writeXcpCharacteristic(target.ecuAddress, target.a2lDataType, newValue).calibrationValue() require(_workspaceRevision.value == revision) { "프로젝트가 변경되었습니다." } @@ -1893,7 +2158,7 @@ class MainViewModel( runCalibration("A2L [$name][$cellIndex] 쓰기") { require(cellIndex in target.values.indices) { "배열 인덱스가 범위를 벗어났습니다." } require(newValue.isFinite() && newValue in target.lowerLimit..target.upperLimit) { "값이 허용 범위를 벗어났습니다." } - val address = target.ecuAddress + cellIndex.toLong() * target.a2lDataType.byteSize + val address = target.cellAddress(cellIndex) packetRepository.writeXcpCharacteristic(address, target.a2lDataType, newValue).calibrationValue() require(_workspaceRevision.value == revision) { "프로젝트가 변경되었습니다." } _a2lCharacteristicsArray.update { list -> list.map { arr -> @@ -1909,7 +2174,7 @@ class MainViewModel( val descCaseInsensitiveMap = proj.a2lSignalDescriptions.mapKeys { it.key.trim().lowercase(Locale.ROOT) } return signals.map { sig -> val key = sig.name.trim().lowercase(Locale.ROOT) - val customColor = colorCaseInsensitiveMap[key] ?: sig.color + val customColor = (colorCaseInsensitiveMap[key] ?: sig.color).toColorRef() val customDesc = descCaseInsensitiveMap[key] ?: sig.description sig.copy(color = customColor, description = customDesc) } @@ -1947,7 +2212,7 @@ class MainViewModel( val descCaseInsensitiveMap = proj.canSignalDescriptions.mapKeys { it.key.trim().lowercase(Locale.ROOT) } return signals.map { sig -> val key = sig.name.trim().lowercase(Locale.ROOT) - val customColor = colorCaseInsensitiveMap[key] ?: sig.color + val customColor = (colorCaseInsensitiveMap[key] ?: sig.color).toColorRef() val customDesc = descCaseInsensitiveMap[key] ?: sig.description sig.copy(color = customColor, description = customDesc) } @@ -1961,7 +2226,7 @@ class MainViewModel( val updatedGroups = database.groups.map { group -> val updatedSignals = group.signals.map { sig -> val key = sig.name.trim().lowercase(Locale.ROOT) - val customColor = colorCaseInsensitiveMap[key] ?: sig.color + val customColor = (colorCaseInsensitiveMap[key] ?: sig.color).toColorRef() val customDesc = descCaseInsensitiveMap[key] ?: sig.description sig.copy(color = customColor, description = customDesc) } @@ -2034,7 +2299,7 @@ class MainViewModel( * 색상 값은 MDB/DBC/A2L과 동일한 Win32 COLORREF(0x00BBGGRR) 형식으로 유지한다. */ fun updateMdbSignalColor(context: Context? = null, signal: SignalDefinition, color: Long) { - val normalizedColor = color and 0xFFFFFFL + val normalizedColor = color.toColorRef() val currentDb = _uiState.value.mdbDatabase ?: return val updatedDb = currentDb.copy( groups = currentDb.groups.map { group -> @@ -2059,7 +2324,7 @@ class MainViewModel( } fun updateCanSignalColor(context: Context? = null, signal: SignalDefinition, color: Long) { - val normalizedColor = color and 0xFFFFFFL + val normalizedColor = color.toColorRef() _canSignals.update { signals -> signals.map { current -> if (current.idx == signal.idx && current.name.equals(signal.name, ignoreCase = true)) { @@ -2071,12 +2336,12 @@ class MainViewModel( _currentProject.value = currentProject.copy( canSignalColors = currentProject.canSignalColors + (signal.name to normalizedColor) ) - synchronizeSignalColor("can_${signal.idx}", signal.name, normalizedColor) + synchronizeSignalColor(com.dct.app.domain.model.mdb.CanSignalKey.stable(signal), signal.name, normalizedColor) if (context != null) saveActiveProject(context, showFeedback = false) } fun updateA2lSignalColor(context: Context? = null, signal: SignalDefinition, color: Long) { - val normalizedColor = color and 0xFFFFFFL + val normalizedColor = color.toColorRef() _a2lSignals.update { signals -> signals.map { current -> if (current.adl == signal.adl && current.name.equals(signal.name, ignoreCase = true)) { @@ -2093,6 +2358,7 @@ class MainViewModel( } private fun synchronizeSignalColor(signalId: String, signalName: String, color: Long) { + val canonicalColor = color.toColorRef() fun matches(series: ChartSignalSeries): Boolean { return series.signalId == signalId || (series.signalId.equals(signalName, ignoreCase = true) && @@ -2100,7 +2366,7 @@ class MainViewModel( } fun recolor(series: ChartSignalSeries): ChartSignalSeries = - if (matches(series)) series.copy(color = color) else series + if (matches(series)) series.copy(color = canonicalColor) else series val renderers = buildList { add(chartRenderer) @@ -2109,7 +2375,7 @@ class MainViewModel( renderers.forEach { renderer -> renderer.seriesListFlow.value .filter(::matches) - .forEach { renderer.setSeriesColor(it.signalId, color) } + .forEach { renderer.setSeriesColor(it.signalId, canonicalColor) } } fun recolorWindow(window: com.dct.app.presentation.mdi.MdiWindow): com.dct.app.presentation.mdi.MdiWindow { @@ -2139,7 +2405,7 @@ class MainViewModel( if (tile.signalId == signalId || (tile.signalId.equals(signalName, ignoreCase = true) && tile.signalName.equals(signalName, ignoreCase = true))) { - tile.copy(color = color) + tile.copy(color = canonicalColor) } else tile } ) @@ -2150,22 +2416,26 @@ class MainViewModel( * Formula(수식) 추가 */ fun addFormula(formula: com.dct.app.domain.model.formula.FormulaDefinition) { - _formulas.update { it + formula } + val canonicalFormula = formula.copy(color = formula.color.toColorRef()) + _formulas.update { it + canonicalFormula } + refreshFormulaTelemetry() _currentProject.update { it?.copy(formulas = _formulas.value) } - _uiState.update { it.copy(statusMessage = "수식 '${formula.name}' 추가 완료") } + _uiState.update { it.copy(statusMessage = "수식 '${canonicalFormula.name}' 추가 완료") } } /** * Formula(수식) 수정 */ fun updateFormula(formula: com.dct.app.domain.model.formula.FormulaDefinition) { - val previous = _formulas.value.firstOrNull { it.id == formula.id } - _formulas.update { list -> list.map { if (it.id == formula.id) formula else it } } + val canonicalFormula = formula.copy(color = formula.color.toColorRef()) + val previous = _formulas.value.firstOrNull { it.id == canonicalFormula.id } + _formulas.update { list -> list.map { if (it.id == canonicalFormula.id) canonicalFormula else it } } + refreshFormulaTelemetry() _currentProject.update { it?.copy(formulas = _formulas.value) } - if (previous != null && previous.color != formula.color) { - synchronizeSignalColor("formula_${previous.name}", previous.name, formula.color) + if (previous != null && previous.color != canonicalFormula.color) { + synchronizeSignalColor("formula_${previous.name}", previous.name, canonicalFormula.color) } - _uiState.update { it.copy(statusMessage = "수식 '${formula.name}' 수정 완료") } + _uiState.update { it.copy(statusMessage = "수식 '${canonicalFormula.name}' 수정 완료") } } /** @@ -2173,6 +2443,7 @@ class MainViewModel( */ fun deleteFormula(formula: com.dct.app.domain.model.formula.FormulaDefinition) { _formulas.update { list -> list.filter { it.id != formula.id } } + refreshFormulaTelemetry() _currentProject.update { it?.copy(formulas = _formulas.value) } // 차트 렌더러에서 해당 수식 신호 제거 @@ -2261,7 +2532,10 @@ class MainViewModel( val msbFirst = currentXcpIfData?.isMsbFirst ?: false runCalibration("A2L [$name] 읽기") { if (cellIndex != null) require(cellIndex in array!!.values.indices) { "배열 인덱스 범위 오류" } - val address = if (cellIndex == null) scalar!!.ecuAddress else array!!.ecuAddress + cellIndex.toLong() * type.byteSize + val address = if (cellIndex == null) { + require(scalar!!.calibrationLayoutError == null) { scalar.calibrationLayoutError ?: "지원하지 않는 Record Layout입니다." } + scalar.ecuAddress + } else array!!.cellAddress(cellIndex) val bytes = packetRepository.readXcpCharacteristic(address, type).calibrationValue() require(bytes.size == type.byteSize) { "A2L 응답 길이 오류" } val value = type.unpackRawValue(bytes, 0, msbFirst) @@ -2352,7 +2626,10 @@ class MainViewModel( } } } - fun setCurrentProject(project: com.dct.app.domain.model.project.DpcProject) { + fun setCurrentProject( + project: com.dct.app.domain.model.project.DpcProject, + preserveChartSignals: Boolean = false + ) { _currentProject.value = project applyGraphInterpolationMode(if (project.graphLinear) InterpolationMode.LINEAR else InterpolationMode.PWM_STEP) @@ -2409,11 +2686,12 @@ class MainViewModel( // 4. Formula 처리 _formulas.value = project.formulas + refreshFormulaTelemetry() // 5. Sensor Module 처리 _sensorModules.value = if (project.sensorModules.isNotEmpty()) project.sensorModules else com.dct.app.domain.model.project.createDefaultSensorModules() - clearChartSignals() + if (!preserveChartSignals) clearChartSignals() _uiState.update { it.copy( @@ -2426,6 +2704,257 @@ class MainViewModel( applyParameterDescriptions() } + @Volatile private var propertyUpdateInProgress = false + + /** PC Property 명령처럼 연결 중 편집을 막고, 교체 파일의 검증이 끝난 뒤에만 프로젝트를 바꾼다. */ + fun applyProjectProperties( + context: Context, + draft: com.dct.app.domain.model.project.DpcProject, + onComplete: (Boolean) -> Unit + ): Job? { + if (propertyUpdateInProgress || isProjectLoading || + _uiState.value.communicationType != CommunicationType.NONE || _uiState.value.isConnected + ) { + setStatusMessage("장비 연결 또는 프로젝트 처리 중에는 속성을 변경할 수 없습니다.") + onComplete(false) + return null + } + val previous = _currentProject.value ?: run { + onComplete(false) + return null + } + propertyUpdateInProgress = true + return scope.launch { + try { + val mdbChanged = previous.mdbFileUri != draft.mdbFileUri || + previous.localMdbPath != draft.localMdbPath || previous.mdbFileName != draft.mdbFileName + val a2lChanged = previous.a2lFileUri != draft.a2lFileUri || + previous.localA2lPath != draft.localA2lPath || previous.a2lFileName != draft.a2lFileName + val dbcChanged = previous.canDbcFileUri != draft.canDbcFileUri || + previous.localCanDbcPath != draft.localCanDbcPath || previous.canDbcFileName != draft.canDbcFileName + val oldMdb = _uiState.value.mdbDatabase?.allSignals.orEmpty() + val oldA2l = _a2lSignals.value + snapshotWorkspaceForSave() + + // 새 파일은 별도 캐시에 보관한다. 같은 파일명 재선택도 기존 프로젝트의 캐시를 덮지 않는다. + val prepared = withContext(dispatchers.io) { + var project = draft + fun sourceFile(changed: Boolean, uri: Uri?, path: String?, name: String): File? { + if (!changed || name.isBlank()) return null + return if (uri != null && path.isNullOrBlank()) { + com.dct.app.data.repository.DpcProjectManager.copyUriToVersionedStorage(context, uri, name) + ?: error("$name 파일을 읽을 수 없습니다.") + } else File(path ?: error("$name 파일 경로가 없습니다.")).also { + require(it.isFile) { "$name 파일을 찾을 수 없습니다." } + } + } + val mdb = sourceFile(mdbChanged, draft.mdbFileUri, draft.localMdbPath, draft.mdbFileName) + val a2l = sourceFile(a2lChanged, draft.a2lFileUri, draft.localA2lPath, draft.a2lFileName) + val dbc = sourceFile(dbcChanged, draft.canDbcFileUri, draft.localCanDbcPath, draft.canDbcFileName) + if (mdb != null) { + val validator = com.dct.app.data.repository.MdbRepositoryImpl(dispatchers) + try { + when (val result = validator.openDatabase(mdb)) { + is DctResult.Success -> require(validator.getCurrentDatabase() != null) { "MDB 신호 정의가 없습니다." } + is DctResult.Failure -> error(result.error.message) + is DctResult.Loading -> error("MDB 검증이 완료되지 않았습니다.") + } + } finally { validator.close() } + val published = com.dct.app.data.repository.DpcProjectManager.publishSourceFile( + context, mdb, draft.mdbFileName, "MDB") + ?: error("MDB를 DCT Work/MDB에 저장할 수 없습니다.") + project = project.copy(mdbFileName = published.name, localMdbPath = published.absolutePath) + } + if (a2l != null) { + val parsed = a2l.inputStream().use { com.dct.app.data.parser.A2lParser.parseA2lFull(it) } + require(parsed.measurements.isNotEmpty() || parsed.characteristics.isNotEmpty() || + parsed.characteristicsArray.isNotEmpty()) { "A2L 정의를 찾을 수 없습니다." } + val published = com.dct.app.data.repository.DpcProjectManager.publishSourceFile( + context, a2l, draft.a2lFileName, "A2L") + ?: error("A2L을 DCT Work/A2L에 저장할 수 없습니다.") + project = project.copy(a2lFileName = published.name, localA2lPath = published.absolutePath) + } + if (dbc != null) { + val parsed = dbc.inputStream().use { com.dct.app.data.parser.CanDbcParser.parseDbc(it) } + require(parsed.isNotEmpty()) { "DBC 신호 정의를 찾을 수 없습니다." } + val published = com.dct.app.data.repository.DpcProjectManager.publishSourceFile( + context, dbc, draft.canDbcFileName, "DBC") + ?: error("DBC를 DCT Work/DBC에 저장할 수 없습니다.") + project = project.copy(canDbcFileName = published.name, localCanDbcPath = published.absolutePath) + } + // PC는 교체 소스의 사용자 색상을 새 파일 기본값으로 초기화한다. + if (mdbChanged) project = project.copy(mdbSignalColors = emptyMap(), mdbSignalDescriptions = emptyMap()) + if (a2lChanged) project = project.copy(a2lSignalColors = emptyMap(), a2lSignalDescriptions = emptyMap()) + if (dbcChanged) project = project.copy(canSignalColors = emptyMap(), canSignalDescriptions = emptyMap()) + project + } + // 검증 이후에도 연결 상태가 바뀌었다면 장비의 설정과 프로젝트를 어긋나게 만들지 않는다. + check(_uiState.value.communicationType == CommunicationType.NONE && !_uiState.value.isConnected) { + "파일을 읽는 동안 장비가 연결되었습니다. 연결 해제 후 다시 시도해 주세요." + } + if (mdbChanged || a2lChanged || dbcChanged) { + // 연결 전 마지막 계측값과 이전 보정 선택은 새 정의에 재사용할 수 없다. + clearFormulaTelemetry() + calibrationViewModel.clear() + } + setCurrentProject(prepared, preserveChartSignals = true) + if (mdbChanged && !prepared.localMdbPath.isNullOrBlank()) { + openMdbFromFile(File(prepared.localMdbPath)).join() + check(_uiState.value.mdbDatabase != null && _uiState.value.mdbFileName == prepared.mdbFileName) { + "MDB 파일 적용에 실패했습니다: ${_uiState.value.statusMessage}" + } + } + if (a2lChanged && !prepared.localA2lPath.isNullOrBlank()) { + openA2lFromFile(File(prepared.localA2lPath)).join() + check(_uiState.value.a2lFileName == prepared.a2lFileName && + (_a2lSignals.value.isNotEmpty() || _a2lCharacteristics.value.isNotEmpty() || + _a2lCharacteristicsArray.value.isNotEmpty())) { + "A2L 파일 적용에 실패했습니다: ${_uiState.value.statusMessage}" + } + } + if (dbcChanged && !prepared.localCanDbcPath.isNullOrBlank()) { + openCanDbcFromFile(File(prepared.localCanDbcPath)).join() + check(_uiState.value.canDbcFileName == prepared.canDbcFileName && _canSignals.value.isNotEmpty()) { + "DBC 파일 적용에 실패했습니다: ${_uiState.value.statusMessage}" + } + } + reconcilePropertySourceBindings(mdbChanged, a2lChanged, dbcChanged, oldMdb, oldA2l) + // PC MainFrm::OnFileProperty: 소스 교체는 메모리 상태만 바꾸고, + // 프로젝트 파일 경로 변경 시에만 즉시 저장한다. 그 외에는 Save/종료 저장을 기다린다. + val projectFileChanged = previous.projectFileName != prepared.projectFileName || + previous.projectFileUri != prepared.projectFileUri || + previous.localProjectPath != prepared.localProjectPath + val saved = !projectFileChanged || saveActiveProject(context, showFeedback = false) + setStatusMessage(when { + !saved -> "속성은 적용됐지만 프로젝트 파일 저장에 실패했습니다. 다시 저장하세요." + projectFileChanged -> "프로젝트 속성과 파일 경로를 저장했습니다." + else -> "프로젝트 속성을 적용했습니다. 파일에 반영하려면 프로젝트를 저장하세요." + }) + onComplete(true) + } catch (cancelled: kotlinx.coroutines.CancellationException) { + throw cancelled + } catch (error: Exception) { + setStatusMessage("프로젝트 속성 변경 실패: ${error.message}") + onComplete(false) + } finally { + propertyUpdateInProgress = false + } + } + } + + /** 교체된 소스만 PC 식별자/이름으로 대조한다. 유지된 소스의 샘플과 축 옵션은 건드리지 않는다. */ + private fun reconcilePropertySourceBindings( + mdbChanged: Boolean, + a2lChanged: Boolean, + dbcChanged: Boolean, + oldMdb: List, + oldA2l: List + ) { + val oldMdbNames = oldMdb.associate { "sig_${it.reclid}_${it.idx}" to it.name } + // PC _PREVIOUS_MDB_FIND_NAME / GetVarListItemByName: 이름으로 새 정의를 찾는다. + // 중복 이름은 PC의 목록 탐색과 동일하게 첫 정의를 사용한다. + val newMdbByName = buildMap { + _uiState.value.mdbDatabase?.allSignals.orEmpty().forEach { putIfAbsent(it.name, it) } + } + fun a2lNames(signals: List) = buildMap { + signals.forEach { put("a2l_${it.adl}", it.name); put("sig_0_${it.idx}", it.name) } + } + val oldA2lNames = a2lNames(oldA2l) + val newA2lByName = _a2lSignals.value.associateBy { it.name } + // PC creates all A2L category windows after a successful load, even without Measurements. + // Keep Android's existing window placement while replacing their contents. + val hasA2lDefinitions = _a2lSignals.value.isNotEmpty() || + _a2lCharacteristics.value.isNotEmpty() || _a2lCharacteristicsArray.value.isNotEmpty() + fun changed(id: String): Boolean = when { + id.startsWith("can_", true) -> dbcChanged + id.startsWith("a2l_", true) || id in oldA2lNames -> a2lChanged + id.startsWith("sig_", true) -> mdbChanged + else -> false + } + fun mappedId(id: String): String? = when { + !changed(id) -> id + id.startsWith("can_", true) -> null // PC는 DBC 교체 시 기존 CAN 아이템을 제거한다. + id in oldA2lNames -> newA2lByName[oldA2lNames[id]]?.let { signal -> + if (id.startsWith("sig_0_", true)) "sig_0_${signal.idx}" else "a2l_${signal.adl}" + } + id in oldMdbNames -> newMdbByName[oldMdbNames[id]]?.let { "sig_${it.reclid}_${it.idx}" } + else -> null + } + fun newDefinition(id: String): com.dct.app.domain.model.mdb.SignalDefinition? = + if (id in oldA2lNames) newA2lByName[oldA2lNames[id]] else newMdbByName[oldMdbNames[id]] + fun filtered(series: List) = series.mapNotNull { + when { + !changed(it.signalId) -> it + else -> mappedId(it.signalId)?.let { id -> + val definition = newDefinition(it.signalId) + it.copy(signalId = id, signalName = definition?.name ?: it.signalName, + color = definition?.color ?: it.color, + unit = definition?.unit ?: it.unit, + buffer = com.dct.app.data.ringbuffer.TimeSeriesRingBuffer(100_000)) + } + } + } + fun reconcileRenderer(renderer: ChartBatchRenderer) { + val series = renderer.seriesListFlow.value + if (series.any { changed(it.signalId) }) renderer.replaceSignalBindings(filtered(series)) + } + reconcileRenderer(chartRenderer) + _windowChartRenderers.values.forEach(::reconcileRenderer) + _uiState.update { state -> state.copy(dashboardTiles = state.dashboardTiles.mapNotNull { tile -> + mappedId(tile.signalId)?.let { id -> + if (changed(tile.signalId)) { + val definition = newDefinition(tile.signalId) + tile.copy(signalId = id, signalName = definition?.name ?: tile.signalName, value = 0.0, + color = definition?.color ?: tile.color, unit = definition?.unit ?: tile.unit, + realLen = definition?.realLen ?: tile.realLen, + minLimit = definition?.min ?: tile.minLimit, maxLimit = definition?.max ?: tile.maxLimit, + historyBuffer = com.dct.app.data.ringbuffer.TimeSeriesRingBuffer(1000)) + } else tile + } + }) } + val activeWindows = mdiViewModel.uiState.value.windows + _pages.update { pages -> pages.map { page -> + val windows = if (page.id == _activePageId.value) activeWindows else page.windows + page.copy( + boundSignals = if (page.id == _activePageId.value) chartRenderer.seriesListFlow.value else filtered(page.boundSignals), + alphanumericalSignals = page.alphanumericalSignals.mapNotNull(::mappedId).distinct(), + windows = windows.filterNot { window -> + (mdbChanged && _uiState.value.mdbDatabase == null && window.type in setOf( + MdiWindowType.MDB_SIGNAL_LIST, MdiWindowType.CONSTANT_SIGNAL_LIST, + MdiWindowType.COMPENSATION_TYPE_A, MdiWindowType.COMPENSATION_TYPE_B, MdiWindowType.MDB_TABLE + )) || (a2lChanged && !hasA2lDefinitions && window.type in setOf( + MdiWindowType.A2L_MEASUREMENTS, MdiWindowType.A2L_CHARACTERISTICS, + MdiWindowType.A2L_CHARACTERISTICS_ARRAY, MdiWindowType.A2L_SIGNAL_LIST + )) || (dbcChanged && _canSignals.value.isEmpty() && window.type == MdiWindowType.CAN_SIGNAL_LIST) + }.map { window -> + val renderer = _windowChartRenderers[window.id] + val sourceDefinitionChanged = (mdbChanged && window.type in setOf( + MdiWindowType.MDB_SIGNAL_LIST, MdiWindowType.CONSTANT_SIGNAL_LIST, + MdiWindowType.COMPENSATION_TYPE_A, MdiWindowType.COMPENSATION_TYPE_B, MdiWindowType.MDB_TABLE + )) || (a2lChanged && window.type in setOf( + MdiWindowType.A2L_MEASUREMENTS, MdiWindowType.A2L_CHARACTERISTICS, + MdiWindowType.A2L_CHARACTERISTICS_ARRAY, MdiWindowType.A2L_SIGNAL_LIST + )) || (dbcChanged && window.type == MdiWindowType.CAN_SIGNAL_LIST) + window.copy(config = window.config.copy( + boundSignals = renderer?.seriesListFlow?.value ?: filtered(window.config.boundSignals), + targetSignals = window.config.targetSignals.mapNotNull(::mappedId).distinct(), + hiddenSignalIds = window.config.hiddenSignalIds.mapNotNull(::mappedId).toSet(), + highlightColors = window.config.highlightColors.mapNotNull { (id, color) -> + mappedId(id)?.let { it to color } + }.toMap(), + chartState = renderer?.exportWorkspaceState() ?: window.config.chartState, + contentState = if (sourceDefinitionChanged) emptyMap() else window.config.contentState, + targetParameterName = if (sourceDefinitionChanged) "" else window.config.targetParameterName, + targetTableName = if (sourceDefinitionChanged) "" else window.config.targetTableName + )) + } + ) + } } + _pages.value.find { it.id == _activePageId.value }?.let { + mdiViewModel.setWindows(it.windows, mdiViewModel.uiState.value.activeWindowId) + } + } + fun saveActiveProject(context: Context, targetUri: Uri? = null, showFeedback: Boolean = true): Boolean { if (isProjectLoading) { if (showFeedback) setStatusMessage("프로젝트 로딩 완료 후 저장해 주세요.") @@ -2462,6 +2991,7 @@ class MainViewModel( } _currentProject.value = proj val saved = com.dct.app.data.repository.DpcProjectManager.saveProject(context, proj) + operationalLogger?.action(if (saved) "Save project file ${proj.projectFileName}" else "Save project failed: ${proj.projectFileName}") if (showFeedback) { if (saved) { _uiState.update { it.copy(statusMessage = "프로젝트를 저장했습니다") } @@ -2556,6 +3086,10 @@ class MainViewModel( private val activeCsvLoggers = java.util.concurrent.CopyOnWriteArrayList() private val activeMf4Loggers = java.util.concurrent.CopyOnWriteArrayList() + private var storageContext: Context? = null + private var activeRecordingStage: File? = null + private var lastRecordingSecond = "" + private var recordingWithinSecond = 0 /** * PC C++ 규격: 연결 시점 또는 프로젝트/신호 변경 시 장비 설정을 1회 동기화 주입 @@ -2571,43 +3105,28 @@ class MainViewModel( } } - private suspend fun syncDeviceConfigurationLocked(generation: Long): Boolean { + private suspend fun syncDeviceConfigurationLocked( + generation: Long, + notifyProgress: Boolean = true + ): Boolean { if (!packetRepository.isConnected.value && packetRepository.connectionState.value != DeviceConnectionState.READY) { return false } run { - _uiState.update { it.copy(statusMessage = "장비 측정 설정 동기화 중...") } + if (notifyProgress) { + _uiState.update { it.copy(statusMessage = "장비 측정 설정 동기화 중...") } + } val configuration = createDeviceMeasurementConfiguration() val configResult = packetRepository.configureMeasurement(configuration) - if (configResult is DctResult.Failure) { - _uiState.update { it.copy(statusMessage = "장비 설정 실패: ${configResult.error.message}") } + if (configResult !is DctResult.Success) { + val message = if (configResult is DctResult.Failure) "장비 설정 실패: ${configResult.error.message}" + else "장비 설정이 아직 완료되지 않았습니다." + _uiState.update { it.copy(statusMessage = message) } return false } - // A2L 신호가 존재하면 XCP on CAN 3단계 설정 시퀀스(0x40 -> 0x41 -> 0x42) 자동 발송 - if (_a2lSignals.value.isNotEmpty()) { - if (reportXcpFailure("XCP 사용 모드", packetRepository.sendXcpUse(0x02))) { - return false - } - if (reportXcpFailure( - "XCP CAN 설정", - packetRepository.sendXcpIfDatas( - currentXcpIfData ?: com.dct.app.data.parser.A2lParser.XcpIfData() - ) - ) - ) { - return false - } - if (reportXcpFailure( - "XCP 측정 목록", - packetRepository.sendXcpMeasurements(_a2lSignals.value) - ) - ) { - return false - } - } - if (generation != connectionGeneration.get()) return false + configuredMdbSelection = configuration.selectedMdbSignals.map { it.reclid to it.idx } configurationRequired = false _uiState.update { it.copy(statusMessage = "Wi-Fi 장비 연결 및 설정 완료 (Ready)") } return true @@ -2618,10 +3137,16 @@ class MainViewModel( * 실시간 계측 시작 (PC OnMeasureStart 호환: 0x20 START_TRANSMIT_DATA 발송, 텔레메트리/차트 공급 및 CSV/MF4 로깅) */ fun startMeasure(frequencyHz: Int = 30) { + if (packetRepository.connectionState.value == DeviceConnectionState.READY && !ensureBackgroundExecution()) return val generation = connectionGeneration.get() scope.launch(dispatchers.io) { sessionMutex.withLock { if (generation != connectionGeneration.get() || _uiState.value.isMeasuring) return@withLock + if (!_uiState.value.canDbcFileName.isNullOrBlank() && _canSignals.value.isEmpty()) { + setStatusMessage("CAN DBC 신호가 로드되지 않았습니다. DBC 파일을 다시 열어 주세요.") + Log.e(LOG_TAG, "계측 시작 차단: DBC 파일명은 있으나 CAN 신호 정의가 0개입니다.") + return@withLock + } if (configurationRequired) { setStatusMessage("장비 연결 및 설정 완료 후 계측을 시작하세요.") return@withLock @@ -2630,7 +3155,17 @@ class MainViewModel( setStatusMessage("Wi-Fi 장비 연결을 먼저 완료하세요.") return@withLock } + // Formulas/graph bindings can change after the initial connection handshake. + // Register their raw MDB inputs before START, while the device is still READY. + val mdbSignals = _uiState.value.mdbDatabase?.allSignals.orEmpty() + if (mdbSignals.isNotEmpty()) { + val selected = selectVisibleMdbSignals(mdbSignals).map { it.reclid to it.idx } + if (selected != configuredMdbSelection && !syncDeviceConfigurationLocked(generation, notifyProgress = false)) { + return@withLock + } + } setStatusMessage("계측 시작 요청 중...") + measurementRequestMillis = System.currentTimeMillis() when (val result = packetRepository.startStreaming(frequencyHz)) { is DctResult.Success -> { if (generation == connectionGeneration.get() && @@ -2655,18 +3190,29 @@ class MainViewModel( val project = _currentProject.value ?: com.dct.app.data.repository.DpcProjectManager.createDefaultProject() val maxDlc = if (project.canMode == "CAN(FD)") 64 else 8 val messages = _canSignals.value + .filter { it.messageId in 0L..0xFFFFL } .groupBy { it.messageId } .map { (canId, signals) -> val declaredDlc = signals.maxOfOrNull { it.messageDlc } ?: 0 com.dct.app.domain.model.protocol.DctCanMessageConfiguration(canId, declaredDlc.coerceAtMost(maxDlc)) } + Log.i( + LOG_TAG, + "CAN 0x11 설정 준비: signals=${_canSignals.value.size}, messages=${messages.size}, " + + "ids=${messages.take(24).joinToString { "0x%04X".format(it.canId) }}" + + if (messages.size > 24) " ..." else "" + ) val mdbSignals = _uiState.value.mdbDatabase?.allSignals.orEmpty() val selectedMdbSignals = selectVisibleMdbSignals(mdbSignals) + val projectName = project.projectFileName.substringBeforeLast('.').ifBlank { project.vehicleCode } return com.dct.app.domain.model.protocol.DctMeasurementConfiguration( + projectName = projectName, vehicleCode = project.vehicleCode, maker = project.maker, mdbSignals = mdbSignals, selectedMdbSignals = selectedMdbSignals, + a2lSignals = _a2lSignals.value, + xcpIfData = currentXcpIfData, canMessages = messages, sensorModules = _sensorModules.value, isHighSpeedCan = project.canMode == "CAN(HiSpeed)", @@ -2674,7 +3220,7 @@ class MainViewModel( ) } - /** PC의 Monitoring 플래그처럼 현재 화면에 실제 바인딩된 MDB 신호만 0x13에 등록한다. */ + /** Register visible MDB signals and Formula inputs in 0x13, even without raw graph bindings. */ private fun selectVisibleMdbSignals( mdbSignals: List ): List { @@ -2697,8 +3243,18 @@ class MainViewModel( selectedKeys += tile.signalName } + // Every Formula row is evaluated, including nested formulas. Collect all source + // references at configuration time only; never scan definitions for each packet. + _formulas.value.forEach { formula -> + com.dct.app.core.formula.FormulaEvaluator.dependencies(formula.expression).forEach { dependency -> + selectedKeys += dependency + if (dependency.startsWith("raw_sig_", ignoreCase = true)) selectedKeys += dependency.substring(4) + } + } + val foldedKeys = selectedKeys.mapTo(hashSetOf()) { it.lowercase(java.util.Locale.ROOT) } + val matched = mdbSignals.filter { signal -> - "sig_${signal.reclid}_${signal.idx}" in selectedKeys || signal.name in selectedKeys + "sig_${signal.reclid}_${signal.idx}" in foldedKeys || signal.name.lowercase(java.util.Locale.ROOT) in foldedKeys } // 0x13의 그룹 수와 그룹별 신호 수는 각각 1바이트다. return matched.groupBy { it.reclid } @@ -2709,6 +3265,22 @@ class MainViewModel( /** START ACK 성공 후에만 차트와 파일 로거를 활성화한다. */ private fun beginMeasurementSession(frequencyHz: Int) { + operationalLogger?.action("Started measuring") + // 같은 연결에서 계측을 다시 시작할 수 있다. 이전 세션에서 기록한 + // 단절 마커의 generation 가드를 새 세션 기준으로 되돌려, 이번 세션 + // 종료 시에도 반드시 마지막 샘플 뒤에 새 공백 경계를 남긴다. + gapMarkedGeneration = Long.MIN_VALUE + canReceivedFrames = 0L + canMatchedFrames = 0L + canUnmatchedFrames = 0L + canDecodedSignals = 0L + lastCanDiagnosticMillis = 0L + a3MdbPackets = 0L + a3CanPackets = 0L + a3MalformedPackets = 0L + graphMatchedSeries = 0L + lastA3DiagnosticMillis = 0L + unmatchedCanIds.clear() chartRenderer.resume() chartRenderLoopJob?.cancel() chartRenderLoopJob = chartRenderer.start(scope) @@ -2719,12 +3291,66 @@ class MainViewModel( _uiState.update { it.copy(isMeasuring = true, measurementFrequencyHz = frequencyHz, statusMessage = "실시간 계측 중") } + if (_currentProject.value?.autostartMeasuringRecording == true) { + startMeasurementRecording() + } + } + + /** + * PC OnMeasureStartRecording 호환. 장비 명령은 보내지 않고 현재 계측값의 + * 로컬 CSV/MF4 기록만 시작한다. + */ + fun startMeasurementRecording() { + scope.launch(dispatchers.io) { + recordingMutex.withLock { + val state = _uiState.value + if (!state.isConnected || !state.isMeasuring || + state.recordingState == MeasurementRecordingState.STARTING || + state.recordingState == MeasurementRecordingState.RECORDING || + state.recordingState == MeasurementRecordingState.STOPPING + ) return@withLock + + val configuredProject = _currentProject.value + ?: com.dct.app.data.repository.DpcProjectManager.createDefaultProject() + if (!configuredProject.writeCsv && !configuredProject.writeMf4) { + _uiState.update { + it.copy( + recordingState = MeasurementRecordingState.ERROR, + statusMessage = "기록 형식을 선택해 주세요 (CSV 또는 MF4)." + ) + } + return@withLock + } + + _uiState.update { + it.copy( + recordingState = MeasurementRecordingState.STARTING, + recordingStartedAtMillis = null, + recordingSampleCount = 0L, + statusMessage = "계측 기록 시작 중..." + ) + } val currentProj = _currentProject.value ?: com.dct.app.data.repository.DpcProjectManager.createDefaultProject() activeCsvLoggers.clear() activeMf4Loggers.clear() - if (!currentProj.writeCsv && !currentProj.writeMf4) return - val outDir = com.dct.app.data.logger.MeasurementLogPaths.resolve( + val outDir = if (currentProj.measuredFolderUri != null) { + val appContext = storageContext + if (appContext == null) { + _uiState.update { it.copy(recordingState = MeasurementRecordingState.ERROR, + statusMessage = "선택한 기록 폴더에 접근할 수 없습니다. 앱을 다시 열어 주세요.") } + return@withLock + } + try { + com.dct.app.data.logger.MeasurementSafExports.createStage(appContext, currentProj.measuredFolderUri).also { + activeRecordingStage = it + } + } catch (error: Exception) { + _uiState.update { it.copy(recordingState = MeasurementRecordingState.ERROR, + statusMessage = "기록 임시 파일 생성 실패: ${error.message}") } + return@withLock + } + } else com.dct.app.data.logger.MeasurementLogPaths.resolve( currentProj.measuredFilesFolder, currentProj.baseFolder, currentProj.rawJsonRoot?.optInt("logfile_path_use_basefolder", 0) == 1, File(com.dct.app.data.repository.DpcProjectManager.getDefaultBaseFolder()) @@ -2733,17 +3359,42 @@ class MainViewModel( outDir.mkdirs() } - val rawProjName = currentProj.projectFileName.substringBeforeLast(".dpc").ifBlank { + val rawProjName = com.dct.app.data.logger.PcMeasurementNames.projectName(currentProj.projectFileName).let { name -> + if (name != "PROJECT" || currentProj.projectFileName.isNotBlank()) name else { _uiState.value.mdbFileName?.substringBeforeLast(".") ?: _uiState.value.a2lFileName?.substringBeforeLast(".") ?: _uiState.value.canDbcFileName?.substringBeforeLast(".") ?: "PROJECT" + } + } + val recordingSecond = com.dct.app.data.logger.PcMeasurementNames.timestamp(Date()) + if (recordingSecond == lastRecordingSecond) recordingWithinSecond++ else recordingWithinSecond = 0 + lastRecordingSecond = recordingSecond + var timeStamp = if (recordingWithinSecond == 0) recordingSecond else + "$recordingSecond-${recordingWithinSecond.toString().padStart(2, '0')}" + while (outDir.listFiles()?.any { it.name.startsWith("${rawProjName}_${timeStamp}_") } == true) { + recordingWithinSecond++ + timeStamp = "$recordingSecond-${recordingWithinSecond.toString().padStart(2, '0')}" } - val timeStamp = SimpleDateFormat("yyyy-MM-dd_HH-mm-ss-SSS", Locale.US).format(Date()) - val mdbSignals = _uiState.value.mdbDatabase?.allSignals?.map { it.name to it.unit } ?: emptyList() - val canSignals = _canSignals.value.map { it.name to it.unit } - val a2lSignals = _a2lSignals.value.map { it.name to it.unit } + val mdbDefinitions = _uiState.value.mdbDatabase?.allSignals.orEmpty() + val canDefinitions = _canSignals.value + val a2lDefinitions = _a2lSignals.value + val mdbSignals = mdbDefinitions.map { it.name to it.unit } + val canSignals = canDefinitions.map { it.name to it.unit } + val a2lSignals = a2lDefinitions.map { it.name to it.unit } + val mdbColumns = mdbDefinitions.map { + com.dct.app.data.logger.PcCsvColumn(it.idx.toString(), "[0x${it.reclid.toString(16).uppercase(Locale.US)}-${it.idx}]", it.realLen) + } + val canColumns = canDefinitions.map { + com.dct.app.data.logger.PcCsvColumn(it.idx.toString(), "[0x${it.messageId.toString(16).uppercase(Locale.US)}-${it.idx}]", it.realLen) + } + val a2lColumns = a2lDefinitions.mapIndexed { index, definition -> + val decimals = Regex("\\.(\\d+)f").find(definition.measurementFormat) + ?.groupValues?.getOrNull(1)?.toIntOrNull()?.coerceIn(0, 3) ?: 3 + com.dct.app.data.logger.PcCsvColumn(index.toString(), + "[0x${definition.messageId.toString(16).uppercase(Locale.US)}]", decimals) + } val hasMdb = mdbSignals.isNotEmpty() val hasCan = canSignals.isNotEmpty() @@ -2751,10 +3402,10 @@ class MainViewModel( // 1. MDB 신호 로거 (프로젝트명_날짜_시간_mdb.csv / .mf4) if (hasMdb) { - val fnCsv = "${rawProjName}_${timeStamp}_mdb.csv" - val fnMf4 = "${rawProjName}_${timeStamp}_mdb.mf4" + val fnCsv = com.dct.app.data.logger.PcMeasurementNames.fileName(rawProjName, timeStamp, "mdb", "csv") + val fnMf4 = com.dct.app.data.logger.PcMeasurementNames.fileName(rawProjName, timeStamp, "mdb", "mf4") if (currentProj.writeCsv) { - com.dct.app.data.logger.CsvMeasurementLogger(outDir, mdbSignals, fnCsv).also { + com.dct.app.data.logger.CsvMeasurementLogger(outDir, mdbSignals, fnCsv, mdbColumns).also { if (it.start() != null) activeCsvLoggers.add(it) else setStatusMessage("CSV 기록 시작 실패: ${outDir.absolutePath} (신호 계측은 계속됩니다)") } @@ -2769,10 +3420,10 @@ class MainViewModel( // 2. CAN DBC 신호 로거 (프로젝트명_날짜_시간_dbc.csv / .mf4) if (hasCan) { - val fnCsv = "${rawProjName}_${timeStamp}_dbc.csv" - val fnMf4 = "${rawProjName}_${timeStamp}_dbc.mf4" + val fnCsv = com.dct.app.data.logger.PcMeasurementNames.fileName(rawProjName, timeStamp, "dbc", "csv") + val fnMf4 = com.dct.app.data.logger.PcMeasurementNames.fileName(rawProjName, timeStamp, "dbc", "mf4") if (currentProj.writeCsv) { - com.dct.app.data.logger.CsvMeasurementLogger(outDir, canSignals, fnCsv).also { + com.dct.app.data.logger.CsvMeasurementLogger(outDir, canSignals, fnCsv, canColumns).also { if (it.start() != null) activeCsvLoggers.add(it) else setStatusMessage("CSV 기록 시작 실패: ${outDir.absolutePath} (신호 계측은 계속됩니다)") } @@ -2787,10 +3438,10 @@ class MainViewModel( // 3. A2L 신호 로거 (PC 레거시 표준: 프로젝트명_날짜_시간_measurement.csv / .mf4) if (hasA2l) { - val fnCsv = "${rawProjName}_${timeStamp}_measurement.csv" - val fnMf4 = "${rawProjName}_${timeStamp}_measurement.mf4" + val fnCsv = com.dct.app.data.logger.PcMeasurementNames.fileName(rawProjName, timeStamp, "measurement", "csv") + val fnMf4 = com.dct.app.data.logger.PcMeasurementNames.fileName(rawProjName, timeStamp, "measurement", "mf4") if (currentProj.writeCsv) { - com.dct.app.data.logger.CsvMeasurementLogger(outDir, a2lSignals, fnCsv).also { + com.dct.app.data.logger.CsvMeasurementLogger(outDir, a2lSignals, fnCsv, a2lColumns, "NC").also { if (it.start() != null) activeCsvLoggers.add(it) else setStatusMessage("CSV 기록 시작 실패: ${outDir.absolutePath} (신호 계측은 계속됩니다)") } @@ -2807,10 +3458,14 @@ class MainViewModel( val formulaList = _formulas.value if (formulaList.isNotEmpty()) { val formulaSignals = formulaList.map { it.name to it.unit } - val fnCsv = "${rawProjName}_${timeStamp}_formula.csv" - val fnMf4 = "${rawProjName}_${timeStamp}_formula.mf4" + val fnCsv = com.dct.app.data.logger.PcMeasurementNames.fileName(rawProjName, timeStamp, "formula", "csv") + val fnMf4 = com.dct.app.data.logger.PcMeasurementNames.fileName(rawProjName, timeStamp, "formula", "mf4") if (currentProj.writeCsv) { - com.dct.app.data.logger.CsvMeasurementLogger(outDir, formulaSignals, fnCsv).also { + com.dct.app.data.logger.CsvMeasurementLogger(outDir, formulaSignals, fnCsv, + formulaList.mapIndexed { index, formula -> + com.dct.app.data.logger.PcCsvColumn(index.toString(), formula.name, + displayName = formula.expression, displayUnit = "") + }).also { if (it.start() != null) activeCsvLoggers.add(it) else setStatusMessage("CSV 기록 시작 실패: ${outDir.absolutePath} (신호 계측은 계속됩니다)") } @@ -2827,15 +3482,20 @@ class MainViewModel( val activeSmModules = _sensorModules.value.filter { it.isUsed } if (activeSmModules.isNotEmpty()) { val smSignals = mutableListOf>() + val smColumns = mutableListOf() for (mod in activeSmModules) { for (ch in mod.channels) { smSignals.add(ch.name to ch.unit) + smColumns.add(com.dct.app.data.logger.PcCsvColumn( + smColumns.size.toString(), + "[${mod.moduleIndex.toString().padStart(2, '0')}-${ch.channelIndex.toString().padStart(2, '0')}]" + )) } } - val fnCsv = "${rawProjName}_${timeStamp}_sm.csv" - val fnMf4 = "${rawProjName}_${timeStamp}_sm.mf4" + val fnCsv = com.dct.app.data.logger.PcMeasurementNames.fileName(rawProjName, timeStamp, "sm", "csv") + val fnMf4 = com.dct.app.data.logger.PcMeasurementNames.fileName(rawProjName, timeStamp, "sm", "mf4") if (currentProj.writeCsv) { - com.dct.app.data.logger.CsvMeasurementLogger(outDir, smSignals, fnCsv).also { + com.dct.app.data.logger.CsvMeasurementLogger(outDir, smSignals, fnCsv, smColumns).also { if (it.start() != null) activeCsvLoggers.add(it) else setStatusMessage("CSV 기록 시작 실패: ${outDir.absolutePath} (신호 계측은 계속됩니다)") } @@ -2848,15 +3508,153 @@ class MainViewModel( } } + val started = activeCsvLoggers.isNotEmpty() || activeMf4Loggers.isNotEmpty() + operationalLogger?.action(if (started) "Started writing measurement files (CSV/MF4)" else "Measurement recording failed") + if (!started) { + activeRecordingStage?.let { stage -> + File(stage, ".target-uri").delete() + stage.delete() + } + activeRecordingStage = null + } + _uiState.update { + it.copy( + recordingState = if (started) MeasurementRecordingState.RECORDING else MeasurementRecordingState.ERROR, + recordingStartedAtMillis = if (started) System.currentTimeMillis() else null, + statusMessage = if (started) "계측 데이터 기록 중" else "기록 파일을 만들 수 없습니다." + ) + } + } + } + } + + /** PC OnMeasureStopRecording 호환. 계측과 TCP 연결은 그대로 유지한다. */ + fun stopMeasurementRecording() { + scope.launch(dispatchers.io) { + recordingMutex.withLock { + if (_uiState.value.recordingState !in setOf( + MeasurementRecordingState.STARTING, + MeasurementRecordingState.RECORDING, + MeasurementRecordingState.ERROR + ) + ) return@withLock + _uiState.update { it.copy(recordingState = MeasurementRecordingState.STOPPING) } + closeMeasurementLoggers() + _uiState.update { + it.copy( + recordingState = MeasurementRecordingState.IDLE, + recordingStartedAtMillis = null, + statusMessage = if (it.isMeasuring) "기록 정지됨 - 계측 계속 중" else "기록 정지됨" + ) + } + } + } + } + + fun setMeasurementRecordingFormats(writeCsv: Boolean, writeMf4: Boolean) { + if (_uiState.value.recordingState == MeasurementRecordingState.RECORDING) { + setStatusMessage("기록 중에는 파일 형식을 변경할 수 없습니다.") + return + } + val project = _currentProject.value ?: return + _currentProject.value = project.copy(writeCsv = writeCsv, writeMf4 = writeMf4) + } + + /** 계측 시작 ACK가 확인되면 파일 기록도 함께 시작하는 프로젝트 옵션. */ + fun setAutostartMeasuringRecording(enabled: Boolean) { + val project = _currentProject.value + ?: com.dct.app.data.repository.DpcProjectManager.createDefaultProject() + _currentProject.value = project.copy(autostartMeasuringRecording = enabled) + } + + private fun closeMeasurementLoggers() { + if (activeCsvLoggers.isNotEmpty() || activeMf4Loggers.isNotEmpty()) + operationalLogger?.action("Stopped writing measurement files (CSV/MF4)") + for (logger in activeCsvLoggers) logger.stop() + activeCsvLoggers.clear() + for (logger in activeMf4Loggers) logger.stop() + activeMf4Loggers.clear() + val stage = activeRecordingStage ?: return + activeRecordingStage = null + val appContext = storageContext ?: return + try { + com.dct.app.data.logger.MeasurementSafExports.markReady(stage) + scope.launch(dispatchers.io) { + runCatching { com.dct.app.data.logger.MeasurementSafExports.exportReady(appContext) } + .onSuccess { count -> if (count > 0) setStatusMessage("선택한 폴더에 기록 파일 ${count}개를 저장했습니다.") } + .onFailure { error -> + Log.e(LOG_TAG, "측정 파일 SAF 내보내기 실패; 임시 사본 보존", error) + setStatusMessage("선택한 폴더에 기록을 저장하지 못했습니다. 앱 내부 사본을 보존하고 다음 실행 때 다시 시도합니다.") + } + } + } catch (error: Exception) { + Log.e(LOG_TAG, "측정 파일 내보내기 준비 실패; 임시 사본 보존", error) + setStatusMessage("기록 파일 내보내기 준비 실패: ${error.message}") + } + } + + /** 계측 정지/통신 단절 시 모든 그래프 시리즈에 동일한 NaN 경계점을 삽입합니다. */ + private fun markChartDataGap() { + val generation = connectionGeneration.get() + if (gapMarkedGeneration == generation) return + gapMarkedGeneration = generation + val timestampSec = System.currentTimeMillis() / 1000.0 + chartRenderer.markDataGap(timestampSec) + _windowChartRenderers.values.forEach { it.markDataGap(timestampSec) } + } + + private var preparedMdbSource: com.dct.app.domain.model.mdb.MdbDatabase? = null + private var preparedMdb: com.dct.app.data.protocol.MdbRecordDecoder.PreparedDefinitions? = null + private var preparedCanSource: List? = null + private var preparedCan: Map> = emptyMap() + private var canReceivedFrames = 0L + private var canMatchedFrames = 0L + private var canUnmatchedFrames = 0L + private var canDecodedSignals = 0L + private var lastCanDiagnosticMillis = 0L + private var a3MdbPackets = 0L + private var a3CanPackets = 0L + private var a3MalformedPackets = 0L + private var graphMatchedSeries = 0L + private var lastA3DiagnosticMillis = 0L + private val unmatchedCanIds = linkedSetOf() + private val mdbLogTimes = java.util.concurrent.ConcurrentHashMap() + + private fun shouldLogMdb(key: String): Boolean { + val now = System.nanoTime() + val previous = mdbLogTimes[key] + if (previous != null && now - previous < 5_000_000_000L) return false + if (mdbLogTimes.size >= 512) mdbLogTimes.clear() + mdbLogTimes[key] = now + return true + } + + /** + * A3 분류/그래프 바인딩 상태를 저빈도로 기록한다. 수신 패킷 수가 + * 증가하는데 CAN 분류가 0인지, 또는 디코드 후 활성 시리즈 매칭이 + * 0인지 logcat에서 즉시 구분할 수 있다. + */ + private fun maybeLogA3GraphDiagnostic(telemetryKeys: Int) { + val now = android.os.SystemClock.elapsedRealtime() + if (now - lastA3DiagnosticMillis < 5_000L) return + lastA3DiagnosticMillis = now + val bound = chartRenderer.seriesListFlow.value.size + + _windowChartRenderers.values.sumOf { it.seriesListFlow.value.size } + Log.i( + LOG_TAG, + "A3 graph: mdb=$a3MdbPackets, can=$a3CanPackets, malformed=$a3MalformedPackets, " + + "telemetryKeys=$telemetryKeys, boundSeries=$bound, matchedSamples=$graphMatchedSeries" + ) } private fun decodeMdbRecord(recordId: Int, data: ByteArray, output: MutableMap): String? { val database = _uiState.value.mdbDatabase ?: return "MDB가 로드되지 않았습니다." - return when (val result = com.dct.app.data.protocol.MdbRecordDecoder.decode( - recordId, - data, - database.allSignals - )) { + if (preparedMdbSource !== database) { + preparedMdb = com.dct.app.data.protocol.MdbRecordDecoder.PreparedDefinitions(database.allSignals) + preparedMdbSource = database + mdbLogTimes.clear() + } + return when (val result = preparedMdb!!.decode(recordId, data)) { is com.dct.app.data.protocol.MdbRecordDecoder.DecodeResult.Success -> { for (decoded in result.signals) { val signal = decoded.definition @@ -2865,14 +3663,40 @@ class MainViewModel( // Keep the received integer, including all bytes, independently of physical conversion. output["raw_sig_${signal.reclid}_${signal.idx}"] = decoded.rawValue.toDouble() } + result.warning?.let { warning -> + if (shouldLogMdb("length_${recordId}")) Log.w(LOG_TAG, warning) + } null } is com.dct.app.data.protocol.MdbRecordDecoder.DecodeResult.Failure -> result.reason } } + /** + * Resolve a value for a bound graph series using every persisted identifier + * format that the PC/older Android projects have used. The decoder emits + * canonical aliases for new packets, but an already-open window may still + * contain `can__`, `sig__`, an A2L ADL key, or only + * the human-readable name. Comparing only `series.signalId`/`signalName` + * made those windows appear frozen even though live telemetry was present. + * + * [updatedSignals] is checked before looking up the value so a previous + * packet's cached value is never inserted as a fresh sample. + */ + private fun updatedTelemetryValueForSeries( + series: ChartSignalSeries, + telemetryMap: Map, + updatedSignals: Set + ): Double? { + return chartTelemetryResolver.updatedValue(series, telemetryMap, updatedSignals) + } + /** 실제 수신된 바이너리 DctPacket 처리 및 텔레메트리 디코딩. */ private fun processIncomingPacket(packet: com.dct.app.domain.model.protocol.DctPacket, elapsedSec: Double) { + if (packet.transportSessionId != 0L && packet.transportSessionId != wifiSocketManager.currentSessionId) return + if (packet.receivedAtMillis > 0 && + packet.receivedAtMillis < (measurementRequestMillis - MEASUREMENT_TIMESTAMP_GRACE_MILLIS) + ) return // 재생(Play) 상태 또는 패킷 리포지토리 스트리밍 중일 때 패킷 처리 val isMeasuring = _uiState.value.isMeasuring || packetRepository.isStreaming.value || @@ -2887,7 +3711,7 @@ class MainViewModel( } val payload = packet.payload - val telemetryMap = _liveTelemetryValues.value.toMutableMap() + val telemetryMap = mutableMapOf() when (packet.command) { com.dct.app.domain.model.protocol.DctCommand.RES_START_TRANSMIT_DATA.code -> { @@ -2916,17 +3740,22 @@ class MainViewModel( } } com.dct.app.domain.model.protocol.DctCommand.SEND_SM_DATA.code -> { - val modules = com.dct.app.data.protocol.DctStreamPayloadParser.parseSensorStream(payload) + @Suppress("UNCHECKED_CAST") + val modules = (packet.decodedStreamPayload as? List) + ?: com.dct.app.data.protocol.DctStreamPayloadParser.parseSensorStream(payload) if (modules == null) { - _uiState.update { it.copy(statusMessage = "센서 모듈 수신 데이터가 손상되어 폐기되었습니다.") } + if (shouldLogMdb("sm_payload")) Log.w( + LOG_TAG, + "센서 모듈 수신 데이터 폐기: payload=${payload.size}B" + ) return } for (module in modules) { val configured = _sensorModules.value.firstOrNull { it.moduleIndex == module.moduleIndex } for (channel in module.channels) { val signal = configured?.channels?.firstOrNull { it.channelIndex == channel.channelIndex } - val moduleNumber = String.format(java.util.Locale.US, "%02d", module.moduleIndex + 1) - val channelNumber = String.format(java.util.Locale.US, "%02d", channel.channelIndex + 1) + val moduleNumber = (module.moduleIndex + 1).toString().padStart(2, '0') + val channelNumber = (channel.channelIndex + 1).toString().padStart(2, '0') val value = channel.value ?: Double.NaN telemetryMap["sm_${moduleNumber}_${channelNumber}"] = value if (signal != null) { @@ -2936,46 +3765,129 @@ class MainViewModel( } } com.dct.app.domain.model.protocol.DctCommand.SEND_CAN_FRAME.code -> { - when (val stream = com.dct.app.data.protocol.DctStreamPayloadParser.parseCanStream(payload)) { + val cachedStream = packet.decodedStreamPayload as? com.dct.app.data.protocol.DctStreamPayloadParser.CanStreamPayload + when (val stream = cachedStream + ?: com.dct.app.data.protocol.DctStreamPayloadParser.parseCanStream(payload)) { is com.dct.app.data.protocol.DctStreamPayloadParser.CanStreamPayload.Mdb -> { + a3MdbPackets++ val error = decodeMdbRecord(stream.recordId, stream.data, telemetryMap) if (error != null) { - _uiState.update { it.copy(statusMessage = "MDB 수신 데이터가 손상되어 폐기되었습니다: $error") } + if (error.startsWith("정의되지 않은 MDB ReclID ")) { + // 로그 재생/장비가 현재 프로젝트에 등록되지 않은 레코드를 + // 보낼 수 있다. 이는 프레임 손상이 아니므로 사용자 상태나 + // Toast를 변경하지 않고 해당 레코드만 건너뛴다. + if (shouldLogMdb("unknown_${stream.recordId}")) Log.w( + LOG_TAG, + "미등록 MDB 레코드 무시: ReclID=${stream.recordId} (0x${stream.recordId.toString(16).uppercase()}, ${stream.data.size}B)" + ) + return + } + // MDB 구조/길이/스키마 오류도 계측을 중단하지 않는다. + // 상태 메시지를 갱신하면 화면에서 Toast가 반복되므로 Logcat에만 + // ReclID, 원인, 길이 및 원시 데이터를 남기고 이 레코드만 버린다. + if (shouldLogMdb("${stream.recordId}:$error")) Log.e( + LOG_TAG, + "MDB 레코드 폐기: ReclID=${stream.recordId} " + + "(${stream.data.size}B), reason=$error, " + + "raw=${stream.data.joinToString(" ") { byte -> "%02X".format(byte.toInt() and 0xFF) }}" + ) return } } is com.dct.app.data.protocol.DctStreamPayloadParser.CanStreamPayload.Can -> { + a3CanPackets++ + val definitions = _canSignals.value + if (preparedCanSource !== definitions) { + // PC ProcessCANDBCData는 wire에서 받은 16-bit CAN ID와 DBC의 + // 원본 message ID를 정확히 비교한다. 확장 ID를 하위 16-bit로 + // 병합하면 서로 다른 메시지 신호를 같은 프레임으로 계산하게 된다. + val unsupportedExtendedIds = definitions.asSequence() + .map { it.messageId } + .distinct() + .filter { it !in 0L..0xFFFFL } + .toList() + if (unsupportedExtendedIds.isNotEmpty()) { + Log.w( + LOG_TAG, + "16-bit DCT CAN wire 범위를 벗어난 DBC 메시지 ${unsupportedExtendedIds.size}개는 " + + "PC와 동일하게 실시간 디코딩에서 제외합니다." + ) + } + preparedCan = definitions + .filter { it.messageId in 0L..0xFFFFL } + .groupBy { it.messageId } + preparedCanSource = definitions + } for (frame in stream.frames) { if (!frame.isReceived) continue - val frameSignals = _canSignals.value.filter { - (it.messageId and 0xFFFFL) == frame.canId + canReceivedFrames++ + val frameSignals = preparedCan[frame.canId] + if (frameSignals == null) { + canUnmatchedFrames++ + if (unmatchedCanIds.size < 24) unmatchedCanIds += frame.canId + continue } + canMatchedFrames++ val decodedSignals = com.dct.app.data.protocol.CanFrameSignalDecoder.decode( frame.data, frameSignals ) + canDecodedSignals += decodedSignals.size for (decoded in decodedSignals) { - telemetryMap["can_${decoded.definition.idx}"] = decoded.physicalValue + telemetryMap[com.dct.app.domain.model.mdb.CanSignalKey.stable(decoded.definition)] = decoded.physicalValue + // 구형 .dpc 및 이미 열린 창의 can_ 바인딩을 계속 지원한다. + telemetryMap[com.dct.app.domain.model.mdb.CanSignalKey.legacy(decoded.definition)] = decoded.physicalValue telemetryMap[decoded.definition.name] = decoded.physicalValue } } + val now = android.os.SystemClock.elapsedRealtime() + if (now - lastCanDiagnosticMillis >= 5_000L) { + lastCanDiagnosticMillis = now + Log.i( + LOG_TAG, + "CAN decode: definitions=${definitions.size}, messages=${preparedCan.size}, " + + "frames=$canReceivedFrames, matched=$canMatchedFrames, " + + "unmatched=$canUnmatchedFrames, decoded=$canDecodedSignals, " + + "unmatchedIds=${unmatchedCanIds.joinToString { "0x%04X".format(it) }}" + ) + } } null -> { - _uiState.update { it.copy(statusMessage = "CAN/MDB 수신 데이터가 손상되어 폐기되었습니다.") } + a3MalformedPackets++ + if (shouldLogMdb("can_payload")) Log.w( + LOG_TAG, + "CAN/MDB 수신 데이터 폐기: payload=${payload.size}B" + ) return } } } com.dct.app.domain.model.protocol.DctCommand.SEND_XCP_MEASUREMENT_DATA.code -> { + if (!xcpDaqDecoder.isConfigured()) { + if (shouldLogMdb("xcp_not_configured")) { + Log.w(LOG_TAG, "XCP D0 수신 데이터 폐기: A2L Measurements 디코더가 구성되지 않았습니다. " + + "a2lSignals=${_a2lSignals.value.size}, configurationRequired=$configurationRequired") + } + return + } if (xcpDaqDecoder.isConfigured()) { - when (val decoded = xcpDaqDecoder.decodeDaqPayloadResult(payload)) { + val cachedStream = packet.decodedStreamPayload as? + com.dct.app.data.protocol.DctStreamPayloadParser.XcpStream + val decoded = if (cachedStream != null) { + xcpDaqDecoder.decodeDaqPayloadResult(cachedStream) + } else { + xcpDaqDecoder.decodeDaqPayloadResult(payload) + } + when (decoded) { is com.dct.app.core.engine.XcpDaqStreamDecoder.DecodeResult.Success -> { telemetryMap.putAll(decoded.values) } is com.dct.app.core.engine.XcpDaqStreamDecoder.DecodeResult.Failure -> { - _uiState.update { - it.copy(statusMessage = "XCP 수신 데이터가 손상되어 폐기되었습니다: ${decoded.reason}") - } + if (shouldLogMdb("xcp_payload")) Log.w( + LOG_TAG, + "XCP 수신 데이터 폐기: ${decoded.reason}, payload=${payload.size}B, " + + "a2lSignals=${_a2lSignals.value.size}" + ) return } } @@ -2983,28 +3895,23 @@ class MainViewModel( } } - // Formula 수식 실시간 평가 - val activeFormulas = _formulas.value - if (activeFormulas.isNotEmpty()) { - for (f in activeFormulas) { - val calc = com.dct.app.core.formula.FormulaEvaluator.evaluate(f.expression, telemetryMap) - if (!calc.isNaN()) { - telemetryMap["formula_${f.name}"] = calc - telemetryMap[f.name] = calc - } - } - } - - _liveTelemetryValues.value = telemetryMap + // Only newly received signals create samples. Other packet families must not + // repeat cached values in every graph and every measurement file. + maybeLogA3GraphDiagnostic(telemetryMap.size) + if (telemetryMap.isEmpty()) return + val updatedSignals = telemetryMap.keys.toMutableSet() + updateFormulaTelemetry(telemetryMap, updatedSignals) if (isMeasuring) { + chartTelemetryResolver.updateSources(_uiState.value.mdbDatabase, _canSignals.value, _a2lSignals.value) // 1. 모든 개별 윈도우 차트 렌더러에 실측 샘플 공급 (각 윈도우별 독립 차트) for (renderer in _windowChartRenderers.values) { val activeSeries = renderer.seriesListFlow.value for (series in activeSeries) { - val sampleValue = telemetryMap[series.signalId] ?: telemetryMap[series.signalName] + val sampleValue = updatedTelemetryValueForSeries(series, telemetryMap, updatedSignals) if (sampleValue != null) { - renderer.addSampleSec(series.signalId, elapsedSec, sampleValue) + graphMatchedSeries++ + renderer.addSampleSec(series.signalId, elapsedSec, sampleValue, packet.ingressSequence) } } } @@ -3012,22 +3919,30 @@ class MainViewModel( // 2. 기본 차트 렌더러에도 샘플 공급 (기존 호환성) val activeSeries = chartRenderer.seriesListFlow.value for (series in activeSeries) { - val sampleValue = telemetryMap[series.signalId] ?: telemetryMap[series.signalName] + val sampleValue = updatedTelemetryValueForSeries(series, telemetryMap, updatedSignals) if (sampleValue != null) { - chartRenderer.addSampleSec(series.signalId, elapsedSec, sampleValue) + graphMatchedSeries++ + chartRenderer.addSampleSec(series.signalId, elapsedSec, sampleValue, packet.ingressSequence) } } // 파일 로거에 샘플 기록 (CSV / MF4) - if (activeCsvLoggers.isNotEmpty() || activeMf4Loggers.isNotEmpty()) { + if (_uiState.value.recordingState == MeasurementRecordingState.RECORDING && + (activeCsvLoggers.isNotEmpty() || activeMf4Loggers.isNotEmpty())) { + var wroteSample = false for (logger in activeCsvLoggers) { + if (logger.signalHeaders.none { it.first in updatedSignals }) continue val sampleValues = logger.signalHeaders.map { (sigName, _) -> telemetryMap[sigName] } logger.logSample(elapsedSec, sampleValues) + wroteSample = true } for (logger in activeMf4Loggers) { + if (logger.signalHeaders.none { it.first in updatedSignals }) continue val sampleValues = logger.signalHeaders.map { (sigName, _) -> telemetryMap[sigName] } logger.logSample(elapsedSec, sampleValues) + wroteSample = true } + if (wroteSample) _uiState.update { it.copy(recordingSampleCount = it.recordingSampleCount + 1L) } } } @@ -3035,7 +3950,7 @@ class MainViewModel( _uiState.update { current -> val updatedTiles = current.dashboardTiles.map { tile -> val v = telemetryMap[tile.signalId] ?: telemetryMap[tile.signalName] ?: tile.value - if (isMeasuring) { + if (isMeasuring && (tile.signalId in updatedSignals || tile.signalName in updatedSignals)) { tile.historyBuffer.addSample(elapsedSec, v) } tile.copy(value = v) @@ -3065,7 +3980,34 @@ class MainViewModel( } } + private fun refreshFormulaTelemetry() { + updateFormulaTelemetry(mutableMapOf(), mutableSetOf()) + } + + private fun clearFormulaTelemetry() { + synchronized(formulaTelemetryLock) { + formulaTelemetryEngine.reset() + _liveTelemetryValues.value = emptyMap() + } + } + + private fun updateFormulaTelemetry(values: MutableMap, updated: MutableSet) { + // Main-thread edits and IO packets share one dependency/alias cache. + synchronized(formulaTelemetryLock) { + val previous = _liveTelemetryValues.value + if (previous.isEmpty()) formulaTelemetryEngine.reset() + previous.forEach { (key, value) -> values.putIfAbsent(key, value) } + formulaTelemetryEngine.update(_formulas.value, values, updated) + _liveTelemetryValues.value = values + } + } + private fun finishMeasurementSession() { + if (_uiState.value.isMeasuring) operationalLogger?.action("Stopped measuring") + // STOP ACK/연결 단절 모두 마지막 샘플과 다음 계측 세션 사이를 + // 명시적으로 끊는다. NaN 마커는 렌더러에서 path를 종료하므로 + // 재시작 후 첫 샘플이 이전 구간과 직선으로 연결되지 않는다. + markChartDataGap() for (r in _windowChartRenderers.values) { r.stop() } @@ -3073,28 +4015,33 @@ class MainViewModel( chartRenderLoopJob?.cancel() chartRenderLoopJob = null - for (logger in activeCsvLoggers) { - logger.stop() - } - activeCsvLoggers.clear() - - for (logger in activeMf4Loggers) { - logger.stop() - } - activeMf4Loggers.clear() + closeMeasurementLoggers() val message = when (packetRepository.connectionState.value) { DeviceConnectionState.ERROR -> "연결 오류로 계측이 종료되었습니다." DeviceConnectionState.DISCONNECTED -> "연결 해제로 계측이 종료되었습니다." else -> "계측 정지됨 - 대기 중 (Ready)" } - _uiState.update { it.copy(isMeasuring = false, measurementFrequencyHz = 0, statusMessage = message) } + _uiState.update { + it.copy( + isMeasuring = false, + measurementFrequencyHz = 0, + recordingState = MeasurementRecordingState.IDLE, + recordingStartedAtMillis = null, + statusMessage = message + ) + } } override fun onCleared() { super.onCleared() + p2pObserver?.cancel() + registeredWifiDirectManager?.close() + wifiSocketManager.close() maintenanceService.stopSeedListener() if (_uiState.value.isMeasuring) finishMeasurementSession() + if (_currentProject.value != null) operationalLogger?.action("Close project file") + operationalLogger?.close() chartRenderLoopJob?.cancel() scope.cancel() } diff --git a/Android/app/src/main/java/com/dct/app/presentation/maintenance/LogTab.kt b/Android/app/src/main/java/com/dct/app/presentation/maintenance/LogTab.kt new file mode 100644 index 0000000..fabed90 --- /dev/null +++ b/Android/app/src/main/java/com/dct/app/presentation/maintenance/LogTab.kt @@ -0,0 +1,260 @@ +package com.dct.app.presentation.maintenance + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.Download +import androidx.compose.material.icons.filled.Refresh +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Checkbox +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateMapOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.dct.app.data.maintenance.MaintenanceService +import com.dct.app.domain.model.maintenance.RemoteLogFile +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +/** + * [ISSUE-R3-03] 단말기 로그 파일 목록 조회, 다운로드, 전체 선택(Select All) 및 선택 삭제(0x26) 연동 탭 + * (PC LogfileDownloadDlg.cpp 와 1:1 대응) + */ +@Composable +fun LogTab( + files: List, + enabled: Boolean, + service: MaintenanceService, + onRefresh: () -> Unit, + onDownload: (RemoteLogFile) -> Unit +) { + val scope = rememberCoroutineScope() + val selectedIndices = remember { mutableStateMapOf() } + var isDeleting by remember { mutableStateOf(false) } + var deleteStatusMessage by remember { mutableStateOf(null) } + var confirmDeleteDialog by remember { mutableStateOf(false) } + + val allSelected = files.isNotEmpty() && files.all { selectedIndices[it.index] == true } + val selectedCount = files.count { selectedIndices[it.index] == true } + + Column( + modifier = Modifier + .fillMaxSize() + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + // 상단 제어 바 + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + OutlinedButton( + onClick = onRefresh, + enabled = enabled && !isDeleting + ) { + Icon(Icons.Default.Refresh, contentDescription = null, modifier = Modifier.size(16.dp)) + Spacer(Modifier.size(6.dp)) + Text("장비 로그 목록 새로고침") + } + + OutlinedButton( + onClick = { + val target = !allSelected + files.forEach { selectedIndices[it.index] = target } + }, + enabled = enabled && !isDeleting && files.isNotEmpty() + ) { + Text(if (allSelected) "전체 선택 해제" else "전체 선택 (Select All)") + } + + Button( + onClick = { confirmDeleteDialog = true }, + enabled = enabled && !isDeleting && selectedCount > 0, + colors = ButtonDefaults.buttonColors(containerColor = Color(0xFFDC2626)) + ) { + Icon(Icons.Default.Delete, contentDescription = null, modifier = Modifier.size(16.dp)) + Spacer(Modifier.size(6.dp)) + Text("선택 로그 삭제 ($selectedCount)") + } + + if (isDeleting) { + CircularProgressIndicator(modifier = Modifier.size(20.dp), strokeWidth = 2.dp) + } + } + + deleteStatusMessage?.let { + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)) + ) { + Text( + text = it, + modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp), + fontSize = 12.sp, + fontFamily = FontFamily.Monospace + ) + } + } + + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + Text("저장 로그 목록", fontWeight = FontWeight.SemiBold, fontSize = 15.sp) + Spacer(Modifier.width(8.dp)) + Text("(${files.size}개 파일)", fontSize = 12.sp, color = Color.Gray) + } + + HorizontalDivider() + + if (files.isEmpty()) { + Box( + modifier = Modifier + .fillMaxSize() + .weight(1f), + contentAlignment = Alignment.Center + ) { + Text( + "장비에 저장된 로그 파일이 없습니다.\n[장비 로그 목록 새로고침]을 누르십시오.", + fontSize = 13.sp, + color = Color.Gray + ) + } + } else { + LazyColumn( + modifier = Modifier + .fillMaxSize() + .weight(1f), + verticalArrangement = Arrangement.spacedBy(6.dp) + ) { + items(files, key = { it.index }) { file -> + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface), + elevation = CardDefaults.cardElevation(defaultElevation = 1.dp) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 12.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Checkbox( + checked = selectedIndices[file.index] == true, + onCheckedChange = { selectedIndices[file.index] = it }, + enabled = enabled && !isDeleting + ) + + Box( + modifier = Modifier + .background(Color(0xFFE2E8F0), shape = RoundedCornerShape(3.dp)) + .padding(horizontal = 6.dp, vertical = 2.dp) + ) { + Text( + text = "#${file.index}", + fontSize = 11.sp, + fontWeight = FontWeight.Bold, + fontFamily = FontFamily.Monospace, + color = Color(0xFF334155) + ) + } + + Text( + text = file.name, + modifier = Modifier.weight(1f), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + fontSize = 13.sp, + fontFamily = FontFamily.Monospace + ) + + OutlinedButton( + onClick = { onDownload(file) }, + enabled = enabled && !isDeleting + ) { + Icon(Icons.Default.Download, contentDescription = null, modifier = Modifier.size(16.dp)) + Spacer(Modifier.size(4.dp)) + Text("다운로드", fontSize = 12.sp) + } + } + } + } + } + } + } + + // 삭제 확인 다이얼로그 (PC CLogfileDownloadDlg 동등) + if (confirmDeleteDialog) { + val targets = files.filter { selectedIndices[it.index] == true }.map { it.index }.sortedDescending() + AlertDialog( + onDismissRequest = { confirmDeleteDialog = false }, + title = { Text("단말 로그 파일 삭제 확인") }, + text = { Text("선택한 ${targets.size}개의 로그 파일을 단말 플래시 메모리에서 영구 삭제하시겠습니까?\n이 작업은 되돌릴 수 없습니다.") }, + confirmButton = { + Button( + onClick = { + confirmDeleteDialog = false + scope.launch { + isDeleting = true + deleteStatusMessage = "${targets.size}개 로그 파일 삭제 진행 중..." + runCatching { + withContext(Dispatchers.IO) { + // PC C++ m_listDeleteItem.rbegin()과 동일하게 역순으로 순차 삭제 + targets.forEach { index -> + service.deleteLogFile(index) + } + } + }.onSuccess { + deleteStatusMessage = "${targets.size}개 로그 파일 삭제 완료" + selectedIndices.clear() + onRefresh() + }.onFailure { + deleteStatusMessage = "로그 파일 삭제 실패: ${it.message}" + } + isDeleting = false + } + }, + colors = ButtonDefaults.buttonColors(containerColor = Color(0xFFDC2626)) + ) { Text("삭제 실행") } + }, + dismissButton = { OutlinedButton(onClick = { confirmDeleteDialog = false }) { Text("취소") } } + ) + } +} diff --git a/Android/app/src/main/java/com/dct/app/presentation/maintenance/MaintenanceDialog.kt b/Android/app/src/main/java/com/dct/app/presentation/maintenance/MaintenanceDialog.kt index 64a9ca3..dcf7335 100644 --- a/Android/app/src/main/java/com/dct/app/presentation/maintenance/MaintenanceDialog.kt +++ b/Android/app/src/main/java/com/dct/app/presentation/maintenance/MaintenanceDialog.kt @@ -138,7 +138,7 @@ fun MaintenanceDialog( OutlinedButton(onClick = onDismiss, enabled = !isBusy && !progress.isRunning) { Text("닫기") } } TabRow(selectedTabIndex = selectedTab) { - listOf("펌웨어", "플래시 로그", "Seed-Key").forEachIndexed { index, title -> + listOf("펌웨어", "플래시 로그", "UDS DID", "차종/DB", "Seed-Key").forEachIndexed { index, title -> Tab(selected = selectedTab == index, onClick = { selectedTab = index }, text = { Text(title) }) } } @@ -179,6 +179,7 @@ fun MaintenanceDialog( 1 -> LogTab( files = logFiles, enabled = !isBusy && !progress.isRunning, + service = service, onRefresh = { scope.launch { isBusy = true @@ -194,6 +195,14 @@ fun MaintenanceDialog( logDestination.launch(safeFileName(it.name)) } ) + 2 -> UdsDidTab( + service = service, + enabled = !isBusy && !progress.isRunning + ) + 3 -> VehicleDbTab( + service = service, + enabled = !isBusy && !progress.isRunning + ) else -> SeedKeyTab(seedState) } } @@ -252,30 +261,6 @@ private fun FirmwareTab( } } -@Composable -private fun LogTab( - files: List, - enabled: Boolean, - onRefresh: () -> Unit, - onDownload: (RemoteLogFile) -> Unit -) { - Column(Modifier.fillMaxSize().padding(20.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { - OutlinedButton(onClick = onRefresh, enabled = enabled) { Icon(Icons.Default.Refresh, null); Spacer(Modifier.size(6.dp)); Text("장비 로그 목록 새로고침") } - Text("저장 로그 (${files.size})", fontWeight = FontWeight.SemiBold) - HorizontalDivider() - LazyColumn { - items(files, key = { it.index }) { file -> - Row(Modifier.fillMaxWidth().padding(vertical = 5.dp), verticalAlignment = Alignment.CenterVertically) { - Text(file.name, modifier = Modifier.weight(1f), maxLines = 1, overflow = TextOverflow.Ellipsis) - OutlinedButton(onClick = { onDownload(file) }, enabled = enabled) { - Icon(Icons.Default.Download, null); Spacer(Modifier.size(4.dp)); Text("다운로드") - } - } - } - } - } -} - @Composable private fun SeedKeyTab(state: SeedKeyState) { val text = when (state) { diff --git a/Android/app/src/main/java/com/dct/app/presentation/maintenance/UdsDidTab.kt b/Android/app/src/main/java/com/dct/app/presentation/maintenance/UdsDidTab.kt new file mode 100644 index 0000000..2176bfc --- /dev/null +++ b/Android/app/src/main/java/com/dct/app/presentation/maintenance/UdsDidTab.kt @@ -0,0 +1,361 @@ +package com.dct.app.presentation.maintenance + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.Download +import androidx.compose.material.icons.filled.Refresh +import androidx.compose.material.icons.filled.Save +import androidx.compose.material.icons.filled.Upload +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Checkbox +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateMapOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.dct.app.data.maintenance.MaintenanceService +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +/** + * PC C++ CDIDInformationDlg.cpp 와 1:1 동등 사양을 갖는 표준 UDS DID 정의 목록. + */ +data class StandardDidDefinition( + val did: Int, + val didHex: String, + val name: String, + val bitmask: Int, + val maxChars: Int, + val description: String +) + +val STANDARD_DIDS = listOf( + StandardDidDefinition(0xF187, "F187", "Part Number", 0x40, 10, "단말 부품 번호 (최대 10자)"), + StandardDidDefinition(0xF1A1, "F1A1", "Supplier Code", 0x20, 4, "공급업체 코드 (최대 4자)"), + StandardDidDefinition(0xF18B, "F18B", "Product Date", 0x10, 8, "생산일자 (8자 / 4B Hex)"), + StandardDidDefinition(0xF197, "F197", "System Name", 0x08, 4, "시스템 명칭 (최대 4자)"), + StandardDidDefinition(0xF191, "F191", "HW Version", 0x04, 4, "하드웨어 버전 (최대 4자)"), + StandardDidDefinition(0xF193, "F193", "Supplier HW Version", 0x02, 4, "공급사 HW 버전 (최대 4자)"), + StandardDidDefinition(0xF100, "F100", "Part Name 2 / SW Version", 0x01, 25, "부품명 2 및 SW 버전 (최대 25자)") +) + +/** + * [ISSUE-R3-02] UDS DID 정보 조회 및 설정 탭 (PC CDIDInformationDlg 대응) + */ +@Composable +fun UdsDidTab( + service: MaintenanceService, + enabled: Boolean +) { + val scope = rememberCoroutineScope() + + val didValues = remember { mutableStateMapOf() } + val didChecked = remember { + mutableStateMapOf().apply { + STANDARD_DIDS.forEach { put(it.did, true) } + } + } + + var statusMessage by remember { mutableStateOf("준비 완료 (조회할 DID를 선택 후 [전체 읽기]를 누르십시오)") } + var isOperating by remember { mutableStateOf(false) } + + val allChecked = STANDARD_DIDS.all { didChecked[it.did] == true } + + fun calculateMask(): Int = STANDARD_DIDS.fold(0) { acc, def -> + if (didChecked[def.did] == true) acc or def.bitmask else acc + } + + fun parseDidBytes(def: StandardDidDefinition, bytes: ByteArray): String { + if (bytes.isEmpty()) return "" + if (def.did == 0xF18B && bytes.size == 4) { + val hex = bytes.joinToString("") { "%02X".format(it) } + val ascii = bytes.toString(Charsets.US_ASCII).trim('\u0000', ' ') + return if (ascii.all { it in '0'..'9' || it in 'A'..'Z' || it in 'a'..'z' } && ascii.isNotEmpty()) ascii else hex + } + return bytes.toString(Charsets.US_ASCII).trimEnd('\u0000') + } + + fun encodeDidBytes(def: StandardDidDefinition, text: String): ByteArray { + val trimmed = text.trim() + if (def.did == 0xF18B && trimmed.length == 8 && trimmed.all { it in '0'..'9' || it in 'a'..'f' || it in 'A'..'F' }) { + val num = trimmed.toLongOrNull(16) ?: 0L + return byteArrayOf( + (num ushr 24).toByte(), + (num ushr 16).toByte(), + (num ushr 8).toByte(), + num.toByte() + ) + } + val bytes = trimmed.toByteArray(Charsets.US_ASCII) + return if (bytes.size > def.maxChars) { + bytes.copyOfRange(0, def.maxChars) + } else { + val padded = ByteArray(def.maxChars) + System.arraycopy(bytes, 0, padded, 0, bytes.size) + padded + } + } + + Column( + modifier = Modifier + .fillMaxSize() + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + // 상단 제어 바 + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Button( + onClick = { + val mask = calculateMask() + if (mask == 0) { + statusMessage = "조회할 DID 항목을 1개 이상 선택하십시오." + return@Button + } + scope.launch { + isOperating = true + statusMessage = "선택된 DID 일괄 조회 요청 중 (Mask: 0x%02X)...".format(mask) + runCatching { + withContext(Dispatchers.IO) { service.readAllDid(mask) } + }.onSuccess { results -> + results.forEach { (did, bytes) -> + val def = STANDARD_DIDS.find { it.did == did } + if (def != null) { + didValues[did] = parseDidBytes(def, bytes) + } + } + statusMessage = "DID 일괄 읽기 완료 (${results.size}개 응답 수신)" + }.onFailure { + statusMessage = "DID 일괄 읽기 실패: ${it.message}" + } + isOperating = false + } + }, + enabled = enabled && !isOperating + ) { + Icon(Icons.Default.Download, contentDescription = null) + Spacer(Modifier.size(6.dp)) + Text("전체 읽기 (All Read)") + } + + OutlinedButton( + onClick = { + val mask = calculateMask() + scope.launch { + isOperating = true + statusMessage = "DID 프리셋(Setup) 설정 요청 중 (Mask: 0x%02X)...".format(mask) + runCatching { + withContext(Dispatchers.IO) { service.setupDid(mask) } + }.onSuccess { + statusMessage = "DID 프리셋 설정 완료 (0x%02X)".format(mask) + }.onFailure { + statusMessage = "DID 프리셋 설정 실패: ${it.message}" + } + isOperating = false + } + }, + enabled = enabled && !isOperating + ) { + Icon(Icons.Default.Save, contentDescription = null) + Spacer(Modifier.size(6.dp)) + Text("프리셋 저장 (Setup)") + } + + OutlinedButton( + onClick = { + val newCheck = !allChecked + STANDARD_DIDS.forEach { didChecked[it.did] = newCheck } + }, + enabled = enabled && !isOperating + ) { + Text(if (allChecked) "전체 선택 해제" else "전체 선택") + } + + if (isOperating) { + CircularProgressIndicator(modifier = Modifier.size(20.dp), strokeWidth = 2.dp) + } + } + + // 상태 피드백 영역 + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)) + ) { + Text( + text = statusMessage, + modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp), + fontSize = 12.sp, + fontWeight = FontWeight.Medium, + fontFamily = FontFamily.Monospace, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + + HorizontalDivider() + + // 표준 DID 7종 목록 + LazyColumn( + modifier = Modifier.fillMaxSize(), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + items(STANDARD_DIDS, key = { it.did }) { def -> + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface), + elevation = CardDefaults.cardElevation(defaultElevation = 1.dp) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + // 체크박스 (Setup & All Read 마스크 포함 여부) + Checkbox( + checked = didChecked[def.did] ?: false, + onCheckedChange = { didChecked[def.did] = it }, + enabled = enabled && !isOperating + ) + + // DID 식별 뱃지 + Box( + modifier = Modifier + .background(Color(0xFF0284C7), shape = RoundedCornerShape(4.dp)) + .padding(horizontal = 6.dp, vertical = 3.dp) + ) { + Text( + text = "0x${def.didHex}", + color = Color.White, + fontSize = 11.sp, + fontWeight = FontWeight.Bold, + fontFamily = FontFamily.Monospace + ) + } + + // 항목명 및 사양 설명 + Column(modifier = Modifier.width(170.dp)) { + Text( + text = def.name, + fontWeight = FontWeight.SemiBold, + fontSize = 13.sp + ) + Text( + text = def.description, + fontSize = 10.sp, + color = Color.Gray + ) + } + + // 입력 / 출력 텍스트 필드 + OutlinedTextField( + value = didValues[def.did].orEmpty(), + onValueChange = { newValue -> + if (newValue.length <= def.maxChars) { + didValues[def.did] = newValue + } + }, + modifier = Modifier + .weight(1f) + .height(52.dp), + singleLine = true, + enabled = enabled && !isOperating, + placeholder = { Text("최대 ${def.maxChars}자 입력", fontSize = 11.sp) }, + textStyle = androidx.compose.ui.text.TextStyle( + fontFamily = FontFamily.Monospace, + fontSize = 13.sp + ) + ) + + // 개별 읽기 (Read) 버튼 + OutlinedButton( + onClick = { + scope.launch { + isOperating = true + statusMessage = "DID 0x${def.didHex} 개별 읽기 요청 중..." + runCatching { + withContext(Dispatchers.IO) { service.readDid(def.did) } + }.onSuccess { bytes -> + didValues[def.did] = parseDidBytes(def, bytes) + statusMessage = "DID 0x${def.didHex} 읽기 성공 (${bytes.size} bytes)" + }.onFailure { + statusMessage = "DID 0x${def.didHex} 읽기 실패: ${it.message}" + } + isOperating = false + } + }, + enabled = enabled && !isOperating + ) { + Icon(Icons.Default.Download, contentDescription = null, modifier = Modifier.size(16.dp)) + Spacer(Modifier.size(4.dp)) + Text("읽기", fontSize = 12.sp) + } + + // 개별 쓰기 (Write) 버튼 + Button( + onClick = { + val currentVal = didValues[def.did].orEmpty() + val bytes = encodeDidBytes(def, currentVal) + scope.launch { + isOperating = true + statusMessage = "DID 0x${def.didHex} 개별 쓰기 요청 중 (${bytes.size} bytes)..." + runCatching { + withContext(Dispatchers.IO) { service.writeDid(def.did, bytes) } + }.onSuccess { + statusMessage = "DID 0x${def.didHex} 쓰기 성공" + }.onFailure { + statusMessage = "DID 0x${def.didHex} 쓰기 실패: ${it.message}" + } + isOperating = false + } + }, + enabled = enabled && !isOperating && !didValues[def.did].isNullOrBlank() + ) { + Icon(Icons.Default.Upload, contentDescription = null, modifier = Modifier.size(16.dp)) + Spacer(Modifier.size(4.dp)) + Text("쓰기", fontSize = 12.sp) + } + } + } + } + } + } +} diff --git a/Android/app/src/main/java/com/dct/app/presentation/maintenance/VehicleDbTab.kt b/Android/app/src/main/java/com/dct/app/presentation/maintenance/VehicleDbTab.kt new file mode 100644 index 0000000..d9b42a8 --- /dev/null +++ b/Android/app/src/main/java/com/dct/app/presentation/maintenance/VehicleDbTab.kt @@ -0,0 +1,452 @@ +package com.dct.app.presentation.maintenance + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.DirectionsCar +import androidx.compose.material.icons.filled.Refresh +import androidx.compose.material.icons.filled.Storage +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Checkbox +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.RadioButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateMapOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.dct.app.data.maintenance.MaintenanceService +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +/** + * [ISSUE-R3-02] 단말 차종(Project Code) 및 데이터베이스(Database) 제어 탭 + * (PC ProjectCodeRemoveDlg 및 RemoveDBDlg 와 1:1 대응) + */ +@Composable +fun VehicleDbTab( + service: MaintenanceService, + enabled: Boolean +) { + val scope = rememberCoroutineScope() + + // 차종 관리 상태 + var selectedMaker by remember { mutableStateOf(0) } + val carTypes = remember { mutableStateListOf() } + val selectedCarIndices = remember { mutableStateMapOf() } + var confirmDeleteCarType by remember { mutableStateOf(false) } + + // DB 관리 상태 + val databases = remember { mutableStateListOf() } + val selectedDbIndices = remember { mutableStateMapOf() } + var activeDbName by remember { mutableStateOf(null) } + var confirmDeleteDb by remember { mutableStateOf(false) } + + var isBusy by remember { mutableStateOf(false) } + var statusMessage by remember { mutableStateOf("차종 또는 DB 목록을 새로고침하여 관리할 수 있습니다.") } + + val makers = listOf(0 to "기본/ALL (0)", 1 to "현대 (1)", 2 to "기아 (2)") + + fun refreshCarTypes() { + scope.launch { + isBusy = true + statusMessage = "단말 차종 목록 조회 중 (Maker=$selectedMaker)..." + runCatching { + withContext(Dispatchers.IO) { service.listCarTypes(selectedMaker) } + }.onSuccess { list -> + carTypes.clear() + carTypes.addAll(list) + selectedCarIndices.clear() + statusMessage = "차종 목록 수신 완료 (${list.size}개)" + }.onFailure { + statusMessage = "차종 목록 조회 실패: ${it.message}" + } + isBusy = false + } + } + + fun refreshDatabases() { + scope.launch { + isBusy = true + statusMessage = "단말 데이터베이스 목록 조회 중..." + runCatching { + withContext(Dispatchers.IO) { service.listDatabases() } + }.onSuccess { list -> + databases.clear() + databases.addAll(list) + selectedDbIndices.clear() + statusMessage = "DB 목록 수신 완료 (${list.size}개)" + }.onFailure { + statusMessage = "DB 목록 조회 실패: ${it.message}" + } + isBusy = false + } + } + + Column( + modifier = Modifier + .fillMaxSize() + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + // 상태 표시 카드 + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(10.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + if (isBusy) { + CircularProgressIndicator(modifier = Modifier.size(16.dp), strokeWidth = 2.dp) + } + Text( + text = statusMessage, + fontSize = 12.sp, + fontFamily = FontFamily.Monospace, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + + // 차종 & DB 2-Section 분할 (태블릿 가로 레이아웃: Row 분할) + Row( + modifier = Modifier + .fillMaxSize() + .weight(1f), + horizontalArrangement = Arrangement.spacedBy(16.dp) + ) { + // 좌측 섹션: 단말 차종(Project Code) 관리 + Card( + modifier = Modifier + .weight(1f) + .fillMaxSize(), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface), + elevation = CardDefaults.cardElevation(defaultElevation = 2.dp) + ) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(14.dp), + verticalArrangement = Arrangement.spacedBy(10.dp) + ) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + Icon(Icons.Default.DirectionsCar, contentDescription = null, tint = Color(0xFF2563EB)) + Spacer(Modifier.width(6.dp)) + Text("단말 차종 (Project Code)", fontWeight = FontWeight.Bold, fontSize = 15.sp) + Spacer(Modifier.weight(1f)) + Text("(${carTypes.size}개)", fontSize = 12.sp, color = Color.Gray) + } + + // 메이커 라디오 버튼 그룹 + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text("제조사:", fontSize = 12.sp, fontWeight = FontWeight.SemiBold) + makers.forEach { (code, label) -> + Row(verticalAlignment = Alignment.CenterVertically) { + RadioButton( + selected = selectedMaker == code, + onClick = { selectedMaker = code }, + enabled = enabled && !isBusy + ) + Text(label, fontSize = 11.sp) + } + } + } + + // 제어 버튼 + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(6.dp) + ) { + OutlinedButton( + onClick = { refreshCarTypes() }, + enabled = enabled && !isBusy, + modifier = Modifier.weight(1f) + ) { + Icon(Icons.Default.Refresh, contentDescription = null, modifier = Modifier.size(14.dp)) + Spacer(Modifier.width(4.dp)) + Text("차종 새로고침", fontSize = 11.sp) + } + + val selectedCount = selectedCarIndices.count { it.value } + Button( + onClick = { confirmDeleteCarType = true }, + enabled = enabled && !isBusy && selectedCount > 0, + colors = ButtonDefaults.buttonColors(containerColor = Color(0xFFDC2626)), + modifier = Modifier.weight(1f) + ) { + Icon(Icons.Default.Delete, contentDescription = null, modifier = Modifier.size(14.dp)) + Spacer(Modifier.width(4.dp)) + Text("삭제 ($selectedCount)", fontSize = 11.sp) + } + } + + HorizontalDivider() + + // 차종 목록 + if (carTypes.isEmpty()) { + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Text("조회된 차종이 없습니다.\n[차종 새로고침]을 누르십시오.", fontSize = 12.sp, color = Color.Gray) + } + } else { + LazyColumn( + modifier = Modifier.fillMaxSize(), + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + itemsIndexed(carTypes) { index, carName -> + Row( + modifier = Modifier + .fillMaxWidth() + .background(Color(0xFFF8FAFC), shape = RoundedCornerShape(4.dp)) + .padding(horizontal = 8.dp, vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Checkbox( + checked = selectedCarIndices[index] == true, + onCheckedChange = { selectedCarIndices[index] = it }, + enabled = enabled && !isBusy + ) + Text( + text = "[#$index] $carName", + fontSize = 13.sp, + fontFamily = FontFamily.Monospace, + modifier = Modifier.weight(1f) + ) + } + } + } + } + } + } + + // 우측 섹션: 단말 데이터베이스(Database) 관리 + Card( + modifier = Modifier + .weight(1f) + .fillMaxSize(), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface), + elevation = CardDefaults.cardElevation(defaultElevation = 2.dp) + ) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(14.dp), + verticalArrangement = Arrangement.spacedBy(10.dp) + ) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + Icon(Icons.Default.Storage, contentDescription = null, tint = Color(0xFF0D9488)) + Spacer(Modifier.width(6.dp)) + Text("단말 데이터베이스 (Database)", fontWeight = FontWeight.Bold, fontSize = 15.sp) + Spacer(Modifier.weight(1f)) + Text("(${databases.size}개)", fontSize = 12.sp, color = Color.Gray) + } + + // 제어 버튼 + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(6.dp) + ) { + OutlinedButton( + onClick = { refreshDatabases() }, + enabled = enabled && !isBusy, + modifier = Modifier.weight(1f) + ) { + Icon(Icons.Default.Refresh, contentDescription = null, modifier = Modifier.size(14.dp)) + Spacer(Modifier.width(4.dp)) + Text("DB 새로고침", fontSize = 11.sp) + } + + val selectedDbCount = selectedDbIndices.count { it.value } + Button( + onClick = { confirmDeleteDb = true }, + enabled = enabled && !isBusy && selectedDbCount > 0, + colors = ButtonDefaults.buttonColors(containerColor = Color(0xFFDC2626)), + modifier = Modifier.weight(1f) + ) { + Icon(Icons.Default.Delete, contentDescription = null, modifier = Modifier.size(14.dp)) + Spacer(Modifier.width(4.dp)) + Text("삭제 ($selectedDbCount)", fontSize = 11.sp) + } + } + + HorizontalDivider() + + // DB 목록 + if (databases.isEmpty()) { + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Text("조회된 DB가 없습니다.\n[DB 새로고침]을 누르십시오.", fontSize = 12.sp, color = Color.Gray) + } + } else { + LazyColumn( + modifier = Modifier.fillMaxSize(), + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + itemsIndexed(databases) { index, dbName -> + Row( + modifier = Modifier + .fillMaxWidth() + .background(Color(0xFFF8FAFC), shape = RoundedCornerShape(4.dp)) + .padding(horizontal = 8.dp, vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Checkbox( + checked = selectedDbIndices[index] == true, + onCheckedChange = { selectedDbIndices[index] = it }, + enabled = enabled && !isBusy + ) + Text( + text = "[#$index] $dbName", + fontSize = 13.sp, + fontFamily = FontFamily.Monospace, + modifier = Modifier.weight(1f) + ) + OutlinedButton( + onClick = { + scope.launch { + isBusy = true + statusMessage = "DB 선택 요청 중 ($dbName)..." + runCatching { + withContext(Dispatchers.IO) { service.selectDatabase(dbName) } + }.onSuccess { + activeDbName = dbName + statusMessage = "기본 DB 선택 성공: $dbName" + }.onFailure { + statusMessage = "기본 DB 선택 실패: ${it.message}" + } + isBusy = false + } + }, + enabled = enabled && !isBusy + ) { + Text(if (activeDbName == dbName) "활성" else "선택", fontSize = 11.sp) + } + } + } + } + } + } + } + } + } + + // 차종 삭제 확인 팝업 + if (confirmDeleteCarType) { + val targets = selectedCarIndices.filter { it.value }.keys.sortedDescending() + AlertDialog( + onDismissRequest = { confirmDeleteCarType = false }, + title = { Text("차종 삭제 확인") }, + text = { Text("선택한 ${targets.size}개 차종을 단말에서 삭제하시겠습니까?\n이 작업은 되돌릴 수 없습니다.") }, + confirmButton = { + Button( + onClick = { + confirmDeleteCarType = false + scope.launch { + isBusy = true + statusMessage = "선택된 차종 삭제 처리 중..." + runCatching { + withContext(Dispatchers.IO) { + targets.forEach { idx -> + service.deleteCarType(selectedMaker, idx) + } + } + }.onSuccess { + statusMessage = "${targets.size}개 차종 삭제 완료" + refreshCarTypes() + }.onFailure { + statusMessage = "차종 삭제 실패: ${it.message}" + } + isBusy = false + } + }, + colors = ButtonDefaults.buttonColors(containerColor = Color(0xFFDC2626)) + ) { Text("삭제 실행") } + }, + dismissButton = { OutlinedButton(onClick = { confirmDeleteCarType = false }) { Text("취소") } } + ) + } + + // DB 삭제 확인 팝업 + if (confirmDeleteDb) { + val targets = selectedDbIndices.filter { it.value }.keys.sortedDescending() + AlertDialog( + onDismissRequest = { confirmDeleteDb = false }, + title = { Text("DB 삭제 확인") }, + text = { Text("선택한 ${targets.size}개 데이터베이스를 단말에서 삭제하시겠습니까?\n이 작업은 되돌릴 수 없습니다.") }, + confirmButton = { + Button( + onClick = { + confirmDeleteDb = false + scope.launch { + isBusy = true + statusMessage = "선택된 DB 삭제 처리 중..." + runCatching { + withContext(Dispatchers.IO) { + targets.forEach { idx -> + service.deleteDatabase(idx) + } + } + }.onSuccess { + statusMessage = "${targets.size}개 DB 삭제 완료" + refreshDatabases() + }.onFailure { + statusMessage = "DB 삭제 실패: ${it.message}" + } + isBusy = false + } + }, + colors = ButtonDefaults.buttonColors(containerColor = Color(0xFFDC2626)) + ) { Text("삭제 실행") } + }, + dismissButton = { OutlinedButton(onClick = { confirmDeleteDb = false }) { Text("취소") } } + ) + } +} diff --git a/Android/app/src/main/java/com/dct/app/presentation/mdi/MdiViewModel.kt b/Android/app/src/main/java/com/dct/app/presentation/mdi/MdiViewModel.kt index 46ad4a7..332a533 100644 --- a/Android/app/src/main/java/com/dct/app/presentation/mdi/MdiViewModel.kt +++ b/Android/app/src/main/java/com/dct/app/presentation/mdi/MdiViewModel.kt @@ -129,26 +129,23 @@ class MdiViewModel( val existing = _uiState.value.windows.find { it.type == type } if (existing != null) { bringToFront(existing.id) - // 기존 창이 현재 사용자가 보고 있는 화면 영역 밖으로 크게 벗어나 있다면 현재 화면 안으로 이동 - val isOutOfViewport = (viewportScrollX > 0f || viewportScrollY > 0f) && - (existing.offsetX < viewportScrollX - 100f || - existing.offsetX > viewportScrollX + 1200f || - existing.offsetY < viewportScrollY - 100f || - existing.offsetY > viewportScrollY + 800f) - if (isOutOfViewport) { - updateWindowBounds( - id = existing.id, - offsetX = viewportScrollX + 24f, - offsetY = viewportScrollY + 24f, - width = existing.width, - height = existing.height - ) - } + // Preserve project geometry. The workspace reveals it by scrolling. return existing.id } - val targetWidth = if (type == MdiWindowType.SIGNAL_LIST) 240f else defaultWidth - val targetHeight = if (type == MdiWindowType.SIGNAL_LIST) 520f else defaultHeight + val isCompensation = type == MdiWindowType.COMPENSATION_TYPE_A || + type == MdiWindowType.COMPENSATION_TYPE_B + // Reserve 300dp for point columns; the graph uses the remaining width. + val targetWidth = when { + type == MdiWindowType.SIGNAL_LIST -> 240f + isCompensation -> maxOf(defaultWidth, 800f) + else -> defaultWidth + } + val targetHeight = when { + type == MdiWindowType.SIGNAL_LIST -> 520f + isCompensation -> maxOf(defaultHeight, 620f) + else -> defaultHeight + } val cascadeCount = _uiState.value.windows.size val cascadeStep = (cascadeCount % 6) * 20f @@ -248,7 +245,7 @@ class MdiViewModel( * 새로운 Alphanumerical 윈도우 생성 ("Alphanumerical Window 01", "Alphanumerical Window 02" 등 프로젝트 전체 탭 무관 전역 순차 번호 자동 부여) */ fun addAlphanumericalWindow( - defaultWidth: Float = 380f, + defaultWidth: Float = ALPHANUMERICAL_DEFAULT_WIDTH_DP, defaultHeight: Float = 320f, viewportScrollX: Float = 0f, viewportScrollY: Float = 0f, diff --git a/Android/app/src/main/java/com/dct/app/presentation/mdi/MdiWindow.kt b/Android/app/src/main/java/com/dct/app/presentation/mdi/MdiWindow.kt index d83afc8..bd5d23d 100644 --- a/Android/app/src/main/java/com/dct/app/presentation/mdi/MdiWindow.kt +++ b/Android/app/src/main/java/com/dct/app/presentation/mdi/MdiWindow.kt @@ -3,6 +3,15 @@ package com.dct.app.presentation.mdi import com.dct.app.domain.model.chart.TimeWindowPreset import com.dct.app.presentation.dashboard.DashboardGridMode +/** + * 기본 Alphanumerical 타일 창 너비. + * + * 타일은 120dp, 열 사이 간격은 6dp, 좌우 내부 여백은 각각 6dp이므로 + * 3열을 표시하려면 최소 384dp가 필요하다. 400dp로 여유를 두어 창 + * 테두리/측정 오차가 있어도 최초 생성 시 3개가 한 줄에 배치되도록 한다. + */ +const val ALPHANUMERICAL_DEFAULT_WIDTH_DP = 400f + /** * MDI 서브윈도우 타입 */ diff --git a/Android/app/src/main/java/com/dct/app/presentation/mdi/WindowReveal.kt b/Android/app/src/main/java/com/dct/app/presentation/mdi/WindowReveal.kt new file mode 100644 index 0000000..928bdb6 --- /dev/null +++ b/Android/app/src/main/java/com/dct/app/presentation/mdi/WindowReveal.kt @@ -0,0 +1,13 @@ +package com.dct.app.presentation.mdi + +/** Minimal viewport movement; oversized windows expose their leading/title edge. */ +internal fun windowRevealScroll(current: Float, viewport: Float, start: Float, size: Float): Float { + if (viewport <= 0f) return current + val margin = minOf(16f, viewport / 4f) + return when { + size > viewport - 2f * margin -> start - margin + start < current + margin -> start - margin + start + size > current + viewport - margin -> start + size - viewport + margin + else -> current + }.coerceAtLeast(0f) +} 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 38d7c45..0bdd35a 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 @@ -29,6 +29,7 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.* import androidx.compose.material3.* import androidx.compose.runtime.* +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.zIndex @@ -65,8 +66,9 @@ import com.dct.app.domain.model.chart.AxisPosition import com.dct.app.domain.model.chart.ChartRenderSnapshot import com.dct.app.domain.model.mdb.SignalDefinition 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.dialog.BluetoothDeviceListDialog +import com.dct.app.presentation.mdi.MdiWindowType import com.dct.app.presentation.dialog.WifiDirectDeviceListDialog import com.dct.app.presentation.dialog.OpenProjectDialog import com.dct.app.presentation.dialog.SelectSpecificFileDialog @@ -75,7 +77,7 @@ import com.dct.app.presentation.monitor.components.WorkspacePageTabBar import com.dct.app.presentation.monitor.components.MdiSubWindow import com.dct.app.presentation.monitor.components.WorkspaceTouchNavigation import com.dct.app.presentation.monitor.components.LocalWorkspaceTouchNavigation -import com.dct.app.presentation.monitor.components.workspaceTouchNavigation +import com.dct.app.presentation.monitor.components.workspaceCanvasScroll import com.dct.app.presentation.monitor.components.AndroidModernHorizontalScrollbar import com.dct.app.presentation.monitor.components.AndroidModernVerticalScrollbar import com.dct.app.presentation.main.CommunicationType @@ -114,10 +116,15 @@ fun DiagnosticDataMonitorScreen( modifier: Modifier = Modifier ) { val context = LocalContext.current + val focusManager = androidx.compose.ui.platform.LocalFocusManager.current + val keyboardController = androidx.compose.ui.platform.LocalSoftwareKeyboardController.current val configuration = androidx.compose.ui.platform.LocalConfiguration.current val isPortrait = configuration.orientation == android.content.res.Configuration.ORIENTATION_PORTRAIT val coroutineScope = rememberCoroutineScope() val snackbarHostState = remember { SnackbarHostState() } + // Android queues Toast instances. Keep one handle so a newer status replaces the + // previous notification instead of appearing several seconds after the connection flow. + val activeStatusToast = remember { mutableStateOf(null) } var lastBackPressTime by remember { mutableStateOf(0L) } var showNewProjectDialog by remember { mutableStateOf(false) } var projectDialogTitle by remember { mutableStateOf("New project") } @@ -127,14 +134,59 @@ fun DiagnosticDataMonitorScreen( var showPermissionRationaleDialog by remember { mutableStateOf(false) } var savedProjectFiles by remember { mutableStateOf>(emptyList()) } var showFileMenu by remember { mutableStateOf(false) } - var showBluetoothDialog by remember { mutableStateOf(false) } var showWifiDialog by remember { mutableStateOf(false) } var showMaintenanceDialog by remember { mutableStateOf(false) } var showExitConfirmDialog by remember { mutableStateOf(false) } var newProjectState by remember { mutableStateOf(com.dct.app.data.repository.DpcProjectManager.createDefaultProject(context)) } + var pendingPropertyDocument by remember { mutableStateOf(null) } + var pendingNewProjectDocument by remember { mutableStateOf(null) } + var propertyApplying by remember { mutableStateOf(false) } + fun discardPendingPropertyDocument() { + val uri = pendingPropertyDocument ?: return + pendingPropertyDocument = null + coroutineScope.launch(kotlinx.coroutines.Dispatchers.IO) { + try { + android.provider.DocumentsContract.deleteDocument(context.contentResolver, uri) + } catch (error: Exception) { + android.util.Log.w("DctProjectProperty", "Unused project document cleanup failed", error) + } + } + } + fun discardPendingNewProjectDocument() { + val uri = pendingNewProjectDocument ?: return + pendingNewProjectDocument = null + coroutineScope.launch(kotlinx.coroutines.Dispatchers.IO) { + try { + android.provider.DocumentsContract.deleteDocument(context.contentResolver, uri) + } catch (error: Exception) { + android.util.Log.w("DctProjectProperty", "Unused new project document cleanup failed", error) + } + } + } + val projectLoadState by viewModel.projectLoadState.collectAsState() + val isProjectLoading = projectLoadState == ProjectLoadState.LOADING + var projectPickerInFlight by rememberSaveable { mutableStateOf(false) } + + fun requestOpenProject() { + if (isProjectLoading) { + viewModel.setStatusMessage("프로젝트 로딩 중입니다. 완료 후 다시 시도해 주세요.") + return + } + if (projectPickerInFlight) { + viewModel.setStatusMessage("파일 선택기가 이미 열려 있습니다.") + return + } + savedProjectFiles = com.dct.app.data.repository.DpcProjectManager.getSavedProjectsInConfiguration(context) + showOpenProjectDialog = true + } + + LaunchedEffect(isProjectLoading) { + if (isProjectLoading) showOpenProjectDialog = false + } // 상단 리본 통합 프로젝트 / MDB / DBC 열기 런처 val directOpenLauncher = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { uri -> + projectPickerInFlight = false uri?.let { try { val flags = android.content.Intent.FLAG_GRANT_READ_URI_PERMISSION @@ -199,18 +251,24 @@ fun DiagnosticDataMonitorScreen( // New Project 모달 다이얼로그 전용 파일 피커 런처들 // New Project 모달 다이얼로그 전용 파일 피커 런처들 (선택 시 파일 생성하지 않고 OK 시 확정 생성) - val modalDpcLauncher = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { uri -> + val modalDpcLauncher = rememberLauncherForActivityResult( + ActivityResultContracts.CreateDocument("application/octet-stream") + ) { uri -> uri?.let { - try { - val flags = android.content.Intent.FLAG_GRANT_READ_URI_PERMISSION or android.content.Intent.FLAG_GRANT_WRITE_URI_PERMISSION - context.contentResolver.takePersistableUriPermission(it, flags) - } catch (_: Exception) {} + discardPendingNewProjectDocument() + pendingNewProjectDocument = it val name = queryFileName(context, it) ?: "project.dpc" - if (!name.endsWith(".dpc", ignoreCase = true)) { - android.widget.Toast.makeText(context, "선택된 파일이 프로젝트 규격(*.dpc) 파일이 아닙니다.", android.widget.Toast.LENGTH_SHORT).show() - } else { - newProjectState = newProjectState.copy(projectFileName = name, projectFileUri = it) - } + newProjectState = newProjectState.copy(projectFileName = name, projectFileUri = it) + } + } + val propertyDpcLauncher = rememberLauncherForActivityResult( + ActivityResultContracts.CreateDocument("application/octet-stream") + ) { uri -> + uri?.let { + discardPendingPropertyDocument() + pendingPropertyDocument = it + val name = queryFileName(context, it) ?: newProjectState.projectFileName + newProjectState = newProjectState.copy(projectFileName = name, projectFileUri = it, localProjectPath = null) } } val modalMdbLauncher = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { uri -> @@ -224,10 +282,19 @@ fun DiagnosticDataMonitorScreen( if (!name.endsWith(".mdb", ignoreCase = true)) { android.widget.Toast.makeText(context, "선택된 파일이 MDB 규격(*.mdb) 파일이 아닙니다.", android.widget.Toast.LENGTH_SHORT).show() } else { - coroutineScope.launch(kotlinx.coroutines.Dispatchers.IO) { - val copiedFile = com.dct.app.data.repository.DpcProjectManager.copyUriToInternalStorage(context, it, name) + if (projectDialogTitle == "Property for project") { newProjectState = newProjectState.copy( - mdbFileName = name, + mdbFileName = name, mdbFileUri = it, localMdbPath = null, + a2lFileName = "", a2lFileUri = null, localA2lPath = null + ) + } else coroutineScope.launch(kotlinx.coroutines.Dispatchers.IO) { + val copiedFile = com.dct.app.data.repository.DpcProjectManager.importSourceFile(context, it, name, "MDB") + if (copiedFile == null) { + viewModel.setStatusMessage("MDB를 DCT Work/MDB에 복사할 수 없습니다. 저장소 권한을 확인하세요.") + return@launch + } + newProjectState = newProjectState.copy( + mdbFileName = copiedFile.name, mdbFileUri = it, localMdbPath = copiedFile?.absolutePath, a2lFileName = "", @@ -249,10 +316,19 @@ fun DiagnosticDataMonitorScreen( if (!name.endsWith(".a2l", ignoreCase = true)) { android.widget.Toast.makeText(context, "선택된 파일이 A2L 규격(*.a2l) 파일이 아닙니다.", android.widget.Toast.LENGTH_SHORT).show() } else { - coroutineScope.launch(kotlinx.coroutines.Dispatchers.IO) { - val copiedFile = com.dct.app.data.repository.DpcProjectManager.copyUriToInternalStorage(context, it, name) + if (projectDialogTitle == "Property for project") { newProjectState = newProjectState.copy( - a2lFileName = name, + a2lFileName = name, a2lFileUri = it, localA2lPath = null, + mdbFileName = "", mdbFileUri = null, localMdbPath = null + ) + } else coroutineScope.launch(kotlinx.coroutines.Dispatchers.IO) { + val copiedFile = com.dct.app.data.repository.DpcProjectManager.importSourceFile(context, it, name, "A2L") + if (copiedFile == null) { + viewModel.setStatusMessage("A2L을 DCT Work/A2L에 복사할 수 없습니다. 저장소 권한을 확인하세요.") + return@launch + } + newProjectState = newProjectState.copy( + a2lFileName = copiedFile.name, a2lFileUri = it, localA2lPath = copiedFile?.absolutePath, mdbFileName = "", @@ -274,10 +350,18 @@ fun DiagnosticDataMonitorScreen( if (!name.endsWith(".dbc", ignoreCase = true)) { android.widget.Toast.makeText(context, "선택된 파일이 CAN DBC 규격(*.dbc) 파일이 아닙니다.", android.widget.Toast.LENGTH_SHORT).show() } else { - coroutineScope.launch(kotlinx.coroutines.Dispatchers.IO) { - val copiedFile = com.dct.app.data.repository.DpcProjectManager.copyUriToInternalStorage(context, it, name) + if (projectDialogTitle == "Property for project") { newProjectState = newProjectState.copy( - canDbcFileName = name, + canDbcFileName = name, canDbcFileUri = it, localCanDbcPath = null + ) + } else coroutineScope.launch(kotlinx.coroutines.Dispatchers.IO) { + val copiedFile = com.dct.app.data.repository.DpcProjectManager.importSourceFile(context, it, name, "DBC") + if (copiedFile == null) { + viewModel.setStatusMessage("DBC를 DCT Work/DBC에 복사할 수 없습니다. 저장소 권한을 확인하세요.") + return@launch + } + newProjectState = newProjectState.copy( + canDbcFileName = copiedFile.name, canDbcFileUri = it, localCanDbcPath = copiedFile?.absolutePath ) @@ -322,45 +406,34 @@ fun DiagnosticDataMonitorScreen( val isCommConnected = mainUiState.communicationType != CommunicationType.NONE val isWifiConnected = mainUiState.communicationType == CommunicationType.WIFI - val isBtConnected = mainUiState.communicationType == CommunicationType.BLUETOOTH // 최상위 토스트 메시지 피드백 수신 (팝업창 위 전면 단일 알림) LaunchedEffect(mainUiState.statusMessage) { mainUiState.statusMessage?.let { msg -> if (msg.isNotBlank()) { - android.widget.Toast.makeText(context, msg, android.widget.Toast.LENGTH_SHORT).show() + activeStatusToast.value?.cancel() + activeStatusToast.value = android.widget.Toast.makeText( + context, + msg, + android.widget.Toast.LENGTH_SHORT + ).also { it.show() } viewModel.clearStatusMessage() } } } - val wifiDirectManager = remember { com.dct.app.data.communication.WifiDirectManager(context) } - - // 앱 실행 시 및 Wi-Fi Direct 연결 상태 실시간 동기화 (상대방 연결 감지, 기연결 감지 및 외부 연결 해제 감지) - LaunchedEffect(wifiDirectManager) { - viewModel.registerWifiDirectManager(wifiDirectManager) - wifiDirectManager.registerReceiver() - wifiDirectManager.checkExistingConnection { deviceName, hostIp, isGroupOwner -> - if (mainUiState.communicationType == CommunicationType.NONE) { - viewModel.connectWifi(deviceName, hostIp, 6000, isGroupOwner) - } - } - launch { - wifiDirectManager.connectionInfo.collect { info -> - if (info != null && info.groupFormed) { - if (mainUiState.communicationType == CommunicationType.NONE) { - val isOwner = info.isGroupOwner - val hostIp = if (isOwner) "192.168.49.1" else (info.groupOwnerAddress?.hostAddress ?: "192.168.49.1") - val devName = if (isOwner) "Wi-Fi Direct (Server)" else "Wi-Fi Direct Device" - viewModel.connectWifi(devName, hostIp, 6000, isOwner) - } - } else if (info?.groupFormed != true && mainUiState.communicationType == CommunicationType.WIFI) { - viewModel.disconnectCommunication(context) - } - } + DisposableEffect(Unit) { + onDispose { + activeStatusToast.value?.cancel() + activeStatusToast.value = null } } + // 앱 실행 시 Wi-Fi Direct 라이프사이클 초기화 + LaunchedEffect(Unit) { + viewModel.initializeWifiDirect(context) + } + // MDB 신호 동적 목록 추출 @@ -430,12 +503,18 @@ fun DiagnosticDataMonitorScreen( androidx.activity.compose.BackHandler { when { showExitConfirmDialog -> showExitConfirmDialog = false - showNewProjectDialog -> showNewProjectDialog = false + showNewProjectDialog -> { + if (!propertyApplying) { + discardPendingPropertyDocument() + discardPendingNewProjectDocument() + showNewProjectDialog = false + } + } showOpenProjectDialog -> showOpenProjectDialog = false - showBluetoothDialog -> showBluetoothDialog = false showWifiDialog -> showWifiDialog = false showOverwriteConfirmDialog -> showOverwriteConfirmDialog = false showPermissionRationaleDialog -> showPermissionRationaleDialog = false + showMaintenanceDialog -> showMaintenanceDialog = false else -> showExitConfirmDialog = true } } @@ -545,6 +624,9 @@ fun DiagnosticDataMonitorScreen( viewModel = viewModel, mainUiState = mainUiState, isProjectLoaded = isProjectLoaded, + projectActionsEnabled = !isProjectLoading && !projectPickerInFlight, + propertyProjectEnabled = isProjectLoaded && !isProjectLoading && + !isCommConnected && !mainUiState.isConnected, isPortrait = isPortrait, screenWidthDp = configuration.screenWidthDp.toFloat(), currentScrollXDp = currentScrollXDp, @@ -552,9 +634,9 @@ fun DiagnosticDataMonitorScreen( onOpenProjectClick = { if (!isStoragePermissionGranted(context)) { showPermissionRationaleDialog = true + } else { + requestOpenProject() } - savedProjectFiles = com.dct.app.data.repository.DpcProjectManager.getSavedProjectsInConfiguration(context) - showOpenProjectDialog = true }, onSaveProjectClick = { val currentProj = viewModel.currentProject.value @@ -572,24 +654,32 @@ fun DiagnosticDataMonitorScreen( saveProjectFilePicker.launch(defaultName) }, onNewProjectClick = { - projectDialogTitle = "New project" - newProjectState = com.dct.app.data.repository.DpcProjectManager.createDefaultProject(context) - showNewProjectDialog = true + if (!isProjectLoading) { + projectDialogTitle = "New project" + pendingNewProjectDocument = null + newProjectState = com.dct.app.data.repository.DpcProjectManager.createDefaultProject(context) + showNewProjectDialog = true + } }, onPropertyProjectClick = { - projectDialogTitle = "Property for project" - currentProject?.let { newProjectState = it } - showNewProjectDialog = true + if (!isProjectLoading && !isCommConnected && !mainUiState.isConnected) { + projectDialogTitle = "Property for project" + pendingPropertyDocument = null + currentProject?.let { newProjectState = it } + showNewProjectDialog = true + } else if (isCommConnected || mainUiState.isConnected) { + viewModel.setStatusMessage("장비 연결 중에는 프로젝트 속성을 변경할 수 없습니다.") + } }, onWifiClick = { showWifiDialog = true }, - onBluetoothClick = { - showBluetoothDialog = true - }, onMaintenanceClick = { showMaintenanceDialog = true }, + onResetGraphDataClick = { + viewModel.resetGraphData() + }, onDisconnectClick = { viewModel.disconnectCommunication(context) } @@ -624,6 +714,30 @@ fun DiagnosticDataMonitorScreen( val viewportHeightDp = maxHeight val viewportWidthPx = constraints.maxWidth.toFloat() val viewportHeightPx = constraints.maxHeight.toFloat() + var revealRequest by remember(activePageId, workspaceRevision) { + mutableStateOf?>(null) + } + LaunchedEffect(revealRequest, activePageId, workspaceRevision) { + val id = revealRequest?.first ?: return@LaunchedEffect + // Let the newly opened window extend the canvas scroll bounds first. + withFrameNanos { } + withFrameNanos { } + val target = viewModel.mdiViewModel.uiState.value.windows.find { it.id == id } + ?: return@LaunchedEffect + if (target.isMaximized) return@LaunchedEffect + val x = com.dct.app.presentation.mdi.windowRevealScroll( + horizontalScrollState.value / density, viewportWidthDp.value, + target.offsetX, target.width) + val y = com.dct.app.presentation.mdi.windowRevealScroll( + verticalScrollState.value / density, viewportHeightDp.value, + target.offsetY, if (target.isMinimized) 32f else target.height) + kotlinx.coroutines.coroutineScope { + launch { horizontalScrollState.animateScrollTo((x * density).toInt(), + androidx.compose.animation.core.tween(350)) } + launch { verticalScrollState.animateScrollTo((y * density).toInt(), + androidx.compose.animation.core.tween(350)) } + } + } // 드래그 중인 윈도우와 실제 손가락의 화면 좌표를 별도로 추적한다. // 창 좌표에서 손가락 위치를 역산하면 캔버스 스크롤/좌표 제한 시 두 위치가 분리된다. @@ -654,7 +768,7 @@ fun DiagnosticDataMonitorScreen( viewportHeightDp } - LaunchedEffect(hasWindows) { + LaunchedEffect(hasWindows, horizontalScrollState, verticalScrollState) { if (!hasWindows) { horizontalScrollState.scrollTo(0) verticalScrollState.scrollTo(0) @@ -822,29 +936,58 @@ fun DiagnosticDataMonitorScreen( modifier = Modifier .weight(1f) .fillMaxHeight() - .workspaceTouchNavigation(touchNavigation, horizontalScrollState, verticalScrollState, + .workspaceCanvasScroll(hasWindows, touchNavigation, horizontalScrollState, verticalScrollState, horizontalCanvasFling, verticalCanvasFling) { draggingWindowId != null || SignalDragDropManager.isDragging } - .horizontalScroll(horizontalScrollState, flingBehavior = horizontalCanvasFling) - .verticalScroll(verticalScrollState, flingBehavior = verticalCanvasFling) ) { Box( modifier = Modifier .width(canvasContentWidthDp) .height(canvasContentHeightDp) + .pointerInput(Unit) { + detectTapGestures { + focusManager.clearFocus() + keyboardController?.hide() + } + } ) { if (mdiUiState.windows.isEmpty()) { - EmptyCanvasGuideView( - onNewProject = { showNewProjectDialog = true }, - onOpenProject = { - if (!isStoragePermissionGranted(context)) { - showPermissionRationaleDialog = true + if (isProjectLoading) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + CircularProgressIndicator( + modifier = Modifier.size(32.dp), + strokeWidth = 3.dp, + color = ColorPrimary + ) + Text( + text = "프로젝트 불러오는 중...", + color = ColorTextSecondary, + fontSize = 14.sp + ) } - savedProjectFiles = com.dct.app.data.repository.DpcProjectManager.getSavedProjectsInConfiguration(context) - showOpenProjectDialog = true } - ) + } else { + EmptyCanvasGuideView( + onNewProject = { + if (!isProjectLoading) showNewProjectDialog = true + }, + onOpenProject = { + if (!isStoragePermissionGranted(context)) { + showPermissionRationaleDialog = true + } else { + requestOpenProject() + } + } + ) + } } else { mdiUiState.windows.forEach { win -> key(workspaceRevision, win.id) { @@ -859,11 +1002,15 @@ fun DiagnosticDataMonitorScreen( a2lCharacteristics = a2lCharacteristics, a2lCharacteristicsArray = a2lCharacteristicsArray, mdbSignals = mdbSignals, - liveTelemetryValues = liveTelemetryValues, + // Alphanumerical observes only its displayed values at its own UI rate. + liveTelemetryValues = if (win.type == MdiWindowType.ALPHANUMERICAL) emptyMap() else liveTelemetryValues, chartSnapshot = chartSnapshot, density = density, viewportScrollX = currentScrollXDp, viewportScrollY = currentScrollYDp, + onRevealWindow = { id -> + revealRequest = id to ((revealRequest?.second ?: 0L) + 1L) + }, onClose = { viewModel.mdiViewModel.closeWindow(win.id) }, onBringToFront = { viewModel.mdiViewModel.bringToFront(win.id) }, onMove = { dx, dy -> if (!win.isMaximized) onWindowMove(win, dx, dy) }, @@ -917,15 +1064,15 @@ fun DiagnosticDataMonitorScreen( } // 우측 안드로이드 모던 캡슐형 세로 스크롤바 - AndroidModernVerticalScrollbar( - scrollState = verticalScrollState - ) + if (hasWindows) { + AndroidModernVerticalScrollbar(scrollState = verticalScrollState) + } } // 하단 안드로이드 모던 캡슐형 가로 스크롤바 - AndroidModernHorizontalScrollbar( - scrollState = horizontalScrollState - ) + if (hasWindows) { + AndroidModernHorizontalScrollbar(scrollState = horizontalScrollState) + } } } } @@ -1023,14 +1170,31 @@ fun DiagnosticDataMonitorScreen( } // Figma node 1:740 New Project / Property for project 모달 다이얼로그 + fun applyPropertyProject(proj: com.dct.app.domain.model.project.DpcProject, closeOnSuccess: Boolean) { + if (propertyApplying) return + propertyApplying = true + viewModel.applyProjectProperties(context, proj) { success -> + propertyApplying = false + if (success) { + pendingPropertyDocument = null + newProjectState = viewModel.currentProject.value ?: proj + if (closeOnSuccess) showNewProjectDialog = false + } + } + } val applyAndSaveProject: (com.dct.app.domain.model.project.DpcProject) -> Unit = { proj -> + if (projectDialogTitle == "Property for project") { + applyPropertyProject(proj, closeOnSuccess = true) + } else { showNewProjectDialog = false if (projectDialogTitle == "New project") { viewModel.resetWorkspaceForNewProject() } viewModel.setCurrentProject(proj) // 1. .dpc 프로젝트 파일 저장 (Documents/DCT Work/Configuration/ 단일 경로) - viewModel.saveActiveProject(context, showFeedback = false) + val saved = viewModel.saveActiveProject(context, showFeedback = false) + pendingNewProjectDocument = null + if (!saved) viewModel.setStatusMessage("프로젝트 파일을 저장하지 못했습니다. 저장소 권한을 확인하세요.") // 2. MDB 파일 로드 if (!proj.localMdbPath.isNullOrBlank() && java.io.File(proj.localMdbPath).exists()) { viewModel.openMdbFromFile(java.io.File(proj.localMdbPath)) @@ -1049,6 +1213,7 @@ fun DiagnosticDataMonitorScreen( } else if (proj.canDbcFileUri != null) { viewModel.openCanDbcFromUri(context, proj.canDbcFileUri, fallbackFileName = proj.canDbcFileName) } + } } // Figma node 1:740 New Project / Property for project 모달 다이얼로그 @@ -1056,9 +1221,16 @@ fun DiagnosticDataMonitorScreen( com.dct.app.presentation.project.NewProjectDialog( dialogTitle = projectDialogTitle, project = newProjectState, - onProjectChange = { newProjectState = it }, - onDismiss = { showNewProjectDialog = false }, + onProjectChange = { if (!propertyApplying) newProjectState = it }, + onDismiss = { + if (!propertyApplying) { + discardPendingPropertyDocument() + discardPendingNewProjectDocument() + showNewProjectDialog = false + } + }, onConfirm = { proj -> + if (propertyApplying) return@NewProjectDialog val rawName = proj.projectFileName.ifEmpty { mainUiState.mdbFileName ?: mainUiState.a2lFileName ?: "PROJECT" } @@ -1073,11 +1245,22 @@ fun DiagnosticDataMonitorScreen( applyAndSaveProject(proj) } }, - onPickProjectFile = { modalDpcLauncher.launch(arrayOf("application/octet-stream", "*/*")) }, - onPickMdbFile = { modalMdbLauncher.launch(arrayOf("application/x-msaccess", "application/msaccess", "application/vnd.ms-access", "application/octet-stream", "*/*")) }, - onPickA2lFile = { modalA2lLauncher.launch(arrayOf("text/plain", "application/octet-stream", "*/*")) }, - onPickDbcFile = { modalDbcLauncher.launch(arrayOf("text/plain", "application/octet-stream", "*/*")) }, - onPickFolder = { modalFolderLauncher.launch(null) } + onApply = { proj -> applyPropertyProject(proj, closeOnSuccess = false) }, + projectFileNameReadOnly = projectDialogTitle == "Property for project" && newProjectState.projectFileUri != null, + onPickProjectFile = { + if (propertyApplying) Unit + else if (projectDialogTitle == "Property for project") { + val name = newProjectState.projectFileName.ifBlank { "PROJECT.dpc" } + propertyDpcLauncher.launch(if (name.endsWith(".dpc", ignoreCase = true)) name else "$name.dpc") + } else { + val name = newProjectState.projectFileName.ifBlank { "PROJECT.dpc" } + modalDpcLauncher.launch(if (name.endsWith(".dpc", ignoreCase = true)) name else "$name.dpc") + } + }, + onPickMdbFile = { if (!propertyApplying) modalMdbLauncher.launch(arrayOf("application/x-msaccess", "application/msaccess", "application/vnd.ms-access", "application/octet-stream", "*/*")) }, + onPickA2lFile = { if (!propertyApplying) modalA2lLauncher.launch(arrayOf("text/plain", "application/octet-stream", "*/*")) }, + onPickDbcFile = { if (!propertyApplying) modalDbcLauncher.launch(arrayOf("text/plain", "application/octet-stream", "*/*")) }, + onPickFolder = { if (!propertyApplying) modalFolderLauncher.launch(null) } ) } @@ -1147,35 +1330,26 @@ fun DiagnosticDataMonitorScreen( OpenProjectDialog( projectFiles = savedProjectFiles, onSelectFile = { file -> - showOpenProjectDialog = false - viewModel.openProjectFromFile(context, file) + if (!isProjectLoading) { + showOpenProjectDialog = false + viewModel.openProjectFromFile(context, file) + } }, onBrowseOther = { - showOpenProjectDialog = false - directOpenLauncher.launch(arrayOf("*/*")) + if (!isProjectLoading && !projectPickerInFlight) { + showOpenProjectDialog = false + projectPickerInFlight = true + directOpenLauncher.launch(arrayOf("*/*")) + } }, onDismiss = { showOpenProjectDialog = false } ) } - // Bluetooth 장치 선택 다이얼로그 - if (showBluetoothDialog) { - BluetoothDeviceListDialog( - currentConnectedDevice = if (isBtConnected) mainUiState.connectedDeviceName else null, - onSelectDevice = { deviceName -> viewModel.connectBluetooth(deviceName) }, - onDismiss = { showBluetoothDialog = false } - ) - } - // Wi-Fi Direct 및 TCP 소켓 연결 다이얼로그 if (showWifiDialog) { WifiDirectDeviceListDialog( - manager = wifiDirectManager, - currentConnectedDevice = if (isWifiConnected) mainUiState.connectedDeviceName else null, - onSelectDevice = { deviceName, hostIp, port -> - val isOwner = wifiDirectManager.connectionInfo.value?.isGroupOwner == true - viewModel.connectWifi(deviceName, hostIp, port, isOwner) - }, + viewModel = viewModel, onDismiss = { showWifiDialog = false } ) } @@ -1256,6 +1430,7 @@ fun DiagnosticDataMonitorScreen( onClick = { showExitConfirmDialog = false viewModel.autoSaveOnExit(context) + viewModel.disconnectCommunication(context.applicationContext) (context as? android.app.Activity)?.finish() }, colors = ButtonDefaults.buttonColors(containerColor = Color(0xFFDC2626)) 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 2921dae..8ebfb52 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 @@ -10,7 +10,6 @@ import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.BarChart -import androidx.compose.material.icons.filled.Bluetooth import androidx.compose.material.icons.filled.FolderOpen import androidx.compose.material.icons.filled.GridView import androidx.compose.material.icons.filled.NoteAdd @@ -186,7 +185,7 @@ fun EmptyCanvasGuideView( GuideStepCard( stepNumber = "3", title = "통신 연결 및 계측 시작/정지", - description = "Wi-Fi Direct 또는 Bluetooth로 차량/ECU와 연결한 후 계측을 시작합니다.", + description = "Wi-Fi Direct로 차량/ECU와 연결한 후 신호 재생(계측)을 시작합니다. 신호 기록은 계측 중 별도로 시작합니다.", chips = { Row( modifier = Modifier.padding(top = 6.dp), @@ -200,13 +199,6 @@ fun EmptyCanvasGuideView( bgColor = Color(0xFFEFF6FF), borderColor = Color(0xFFBFDBFE) ) - RibbonIconChip( - icon = Icons.Default.Bluetooth, - label = "Bluetooth", - tint = Color(0xFF2563EB), - bgColor = Color(0xFFEFF6FF), - borderColor = Color(0xFFBFDBFE) - ) RibbonIconChip( icon = Icons.Default.PlayArrow, label = "Start", diff --git a/Android/app/src/main/java/com/dct/app/presentation/monitor/components/AlphaSignal.kt b/Android/app/src/main/java/com/dct/app/presentation/monitor/components/AlphaSignal.kt index 7ffd3ea..1d8e59e 100644 --- a/Android/app/src/main/java/com/dct/app/presentation/monitor/components/AlphaSignal.kt +++ b/Android/app/src/main/java/com/dct/app/presentation/monitor/components/AlphaSignal.kt @@ -5,10 +5,18 @@ import com.dct.app.domain.model.mdb.SignalDefinition import com.dct.app.domain.model.formula.FormulaDefinition import com.dct.app.domain.model.project.DpcSensorModuleConfig import java.util.Locale +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.sample data class AlphaSignal(val id: String, val definition: SignalDefinition) { fun value(values: Map): String { - val v = values[id] ?: values[definition.name] + return formatValue(values[id] ?: values[definition.name]) + } + + fun formatValue(v: Double?): String { if (id.startsWith("sm_") && (v == null || !v.isFinite() || v == -999999.0)) return "NC" if (v == null || !v.isFinite()) return "N/A" val number = when { @@ -25,21 +33,50 @@ fun resolveAlphaSignals( ids: List, can: List, mdb: List, a2l: List, formulas: List, modules: List -): List = ids.mapNotNull { id -> - val sig = findSignalById(id, can, mdb, a2l, formulas, modules) ?: return@mapNotNull null - val key = when { - id.startsWith("formula_") -> "formula_${sig.name}" - id.startsWith("sm_") -> String.format(Locale.US, "sm_%02d_%02d", sig.reclid + 1, sig.idx + 1) - id.startsWith("a2l_") -> "a2l_${sig.adl}" - id.startsWith("can_") -> "can_${sig.idx}" - id.startsWith("sig_") || id.startsWith("mdb_") -> "sig_${sig.reclid}_${sig.idx}" - can.contains(sig) -> "can_${sig.idx}" - a2l.contains(sig) -> "a2l_${sig.adl}" - mdb.contains(sig) -> "sig_${sig.reclid}_${sig.idx}" - else -> "formula_${sig.name}" - } - AlphaSignal(key, sig) -}.distinctBy { it.id } +): List = resolveAlphaSignalSelection(ids, can, mdb, a2l, formulas, modules).signals + +data class AlphaSignalSelection( + val signals: List, + val unresolvedIds: List +) + +/** Resolve saved aliases and missing references in one pass, shared by header and body. */ +internal fun resolveAlphaSignalSelection( + ids: List, can: List, mdb: List, + a2l: List, formulas: List, + modules: List +): AlphaSignalSelection { + val unresolved = mutableListOf() + val signals = ids.mapNotNull { id -> + val sig = findSignalById(id, can, mdb, a2l, formulas, modules) + if (sig == null) { + unresolved.add(id) + return@mapNotNull null + } + val key = when { + id.startsWith("formula_") -> "formula_${sig.name}" + id.startsWith("sm_") -> String.format(Locale.US, "sm_%02d_%02d", sig.reclid + 1, sig.idx + 1) + id.startsWith("a2l_") -> "a2l_${sig.adl}" + id.startsWith("can_") -> com.dct.app.domain.model.mdb.CanSignalKey.stable(sig) + id.startsWith("sig_") || id.startsWith("mdb_") -> "sig_${sig.reclid}_${sig.idx}" + can.contains(sig) -> com.dct.app.domain.model.mdb.CanSignalKey.stable(sig) + a2l.contains(sig) -> "a2l_${sig.adl}" + mdb.contains(sig) -> "sig_${sig.reclid}_${sig.idx}" + else -> "formula_${sig.name}" + } + AlphaSignal(key, sig) + }.distinctBy { it.id } + return AlphaSignalSelection(signals, unresolved) +} + +internal fun selectAlphaValues( + signals: List, values: Map +): Map = signals.associate { it.id to (values[it.id] ?: values[it.definition.name]) } + +/** UI-only sampling: decoding, graph samples and file recording keep their original rate. */ +@OptIn(FlowPreview::class) +internal fun Flow>.alphaDisplayValues(signals: List): Flow> = + sample(33L).map { selectAlphaValues(signals, it) }.distinctUntilChanged() private fun findSignalById( targetId: String, @@ -93,11 +130,7 @@ private fun findSignalById( } } if (targetId.startsWith("can_")) { - val numStr = targetId.removePrefix("can_").takeWhile { it.isDigit() } - val idx = numStr.toIntOrNull() - return canSignals.find { "can_${it.idx}" == targetId || "can_${it.idx}_${it.name}" == targetId } - ?: (if (idx != null) canSignals.find { it.idx == idx } else null) - ?: canSignals.find { it.name.equals(targetId.removePrefix("can_"), ignoreCase = true) } + return com.dct.app.domain.model.mdb.CanSignalKey.find(targetId, canSignals) } if (targetId.startsWith("a2l_")) { val numStr = targetId.removePrefix("a2l_").takeWhile { it.isDigit() } diff --git a/Android/app/src/main/java/com/dct/app/presentation/monitor/components/AlphanumericalWindowView.kt b/Android/app/src/main/java/com/dct/app/presentation/monitor/components/AlphanumericalWindowView.kt index 6c7f541..d5d0c4c 100644 --- a/Android/app/src/main/java/com/dct/app/presentation/monitor/components/AlphanumericalWindowView.kt +++ b/Android/app/src/main/java/com/dct/app/presentation/monitor/components/AlphanumericalWindowView.kt @@ -9,6 +9,8 @@ import androidx.compose.foundation.clickable import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.rememberLazyListState +import com.dct.app.presentation.signaltree.slimLazyListScrollbar import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.lazy.grid.items @@ -47,12 +49,14 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import com.dct.app.core.theme.PcColorRef import com.dct.app.core.theme.toComposeColor import com.dct.app.domain.model.mdb.MdbAttrType import com.dct.app.domain.model.mdb.SignalDefinition import com.dct.app.presentation.chart.DraggedSignal import com.dct.app.presentation.chart.SignalDragDropManager import java.util.Locale +import kotlinx.coroutines.flow.StateFlow // PC Classic Visual Theme Color Tokens (Blue Header + Gray Content) private val AlphaLightBg = Color(0xFFF0F4F8) @@ -68,13 +72,8 @@ private val AlphaDragBorder = Color(0xFF2563EB) @Composable fun AlphanumericalWindowContent( windowId: String, - pinnedSignalIds: List, - liveTelemetryValues: Map, - canSignals: List, - mdbSignals: List, - a2lSignals: List = emptyList(), - formulas: List = emptyList(), - sensorModules: List = emptyList(), + selection: AlphaSignalSelection, + telemetryValues: StateFlow>, isTileMode: Boolean = true, hiddenSignalIds: Set = emptySet(), highlightColors: Map = emptyMap(), @@ -89,12 +88,10 @@ fun AlphanumericalWindowContent( onDispose { SignalDragDropManager.unregisterDropTarget(windowId) } } - val displaySignals = resolveAlphaSignals(pinnedSignalIds, canSignals, mdbSignals, a2lSignals, formulas, sensorModules) - val unresolvedIds = pinnedSignalIds.filter { id -> - resolveAlphaSignals(listOf(id), canSignals, mdbSignals, a2lSignals, formulas, sensorModules).isEmpty() - } - val visibleSignals = displaySignals.filterNot { - it.id in hiddenSignalIds || it.definition.name in hiddenSignalIds + val displaySignals = selection.signals + val unresolvedIds = selection.unresolvedIds + val visibleSignals = remember(displaySignals, hiddenSignalIds) { + displaySignals.filterNot { it.id in hiddenSignalIds || it.definition.name in hiddenSignalIds } } Box( @@ -141,46 +138,58 @@ fun AlphanumericalWindowContent( ) } } - } else if (isTileMode) { - LazyVerticalGrid( - columns = GridCells.FixedSize(120.dp), - modifier = Modifier - .fillMaxSize() - .padding(6.dp), - verticalArrangement = Arrangement.spacedBy(6.dp), - horizontalArrangement = Arrangement.spacedBy(6.dp) - ) { - itemsIndexed(visibleSignals, key = { _, entry -> entry.id }) { _, entry -> - val sig = entry.definition - val hlColor = highlightColors[entry.id] ?: highlightColors[sig.name] ?: 0L - - AlphanumericalLightCell( - signal = sig, - signalId = entry.id, - value = entry.value(liveTelemetryValues), - highlightColor = hlColor, - onSignalDrop = onSignalDrop - ) - } - } } else { - LazyColumn( - modifier = Modifier - .fillMaxSize() - .padding(4.dp), - verticalArrangement = Arrangement.spacedBy(2.dp) - ) { - itemsIndexed(visibleSignals, key = { _, entry -> entry.id }) { _, entry -> - val sig = entry.definition - val hlColor = highlightColors[entry.id] ?: highlightColors[sig.name] ?: 0L - - AlphanumericalTableRow( - signal = sig, - signalId = entry.id, - value = entry.value(liveTelemetryValues), - highlightColor = hlColor, - onSignalDrop = onSignalDrop - ) + // Restart the value subscription on definition changes so old project values are + // never paired with a newly restored signal list. The marquee nodes stay in place. + val values = key(visibleSignals, telemetryValues) { + val valueFlow = remember { telemetryValues.alphaDisplayValues(visibleSignals) } + val initialValues = remember { selectAlphaValues(visibleSignals, telemetryValues.value) } + valueFlow.collectAsState(initial = initialValues) + } + if (isTileMode) { + LazyVerticalGrid( + columns = GridCells.FixedSize(120.dp), + modifier = Modifier + .fillMaxSize() + .padding(6.dp), + verticalArrangement = Arrangement.spacedBy(6.dp), + horizontalArrangement = Arrangement.spacedBy(6.dp) + ) { + itemsIndexed(visibleSignals, key = { _, entry -> entry.id }) { _, entry -> + val sig = entry.definition + val hlColor = highlightColors[entry.id] ?: highlightColors[sig.name] ?: 0L + val rawValue = values.value[entry.id] + AlphanumericalLightCell( + signal = sig, + signalId = entry.id, + value = remember(entry, rawValue) { entry.formatValue(rawValue) }, + highlightColor = hlColor, + onSignalDrop = onSignalDrop + ) + } + } + } else { + val tableListState = rememberLazyListState() + LazyColumn( + state = tableListState, + modifier = Modifier + .fillMaxSize() + .padding(4.dp) + .slimLazyListScrollbar(tableListState), + verticalArrangement = Arrangement.spacedBy(2.dp) + ) { + itemsIndexed(visibleSignals, key = { _, entry -> entry.id }) { _, entry -> + val sig = entry.definition + val hlColor = highlightColors[entry.id] ?: highlightColors[sig.name] ?: 0L + val rawValue = values.value[entry.id] + AlphanumericalTableRow( + signal = sig, + signalId = entry.id, + value = remember(entry, rawValue) { entry.formatValue(rawValue) }, + highlightColor = hlColor, + onSignalDrop = onSignalDrop + ) + } } } } @@ -356,9 +365,9 @@ fun AlphanumericalSettingsButton( val colorOptions = listOf( 0L to "Default", - 0xFFFEE2E2L to "Red", - 0xFFFFEDD5L to "Orange", - 0xFFDBEAFEL to "Blue" + PcColorRef.fromRgb(0xFE, 0xE2, 0xE2) to "Red", + PcColorRef.fromRgb(0xFF, 0xED, 0xD5) to "Orange", + PcColorRef.fromRgb(0xDB, 0xEA, 0xFE) to "Blue" ) for (entry in displaySignals) { val sig = entry.definition @@ -375,7 +384,7 @@ fun AlphanumericalSettingsButton( for ((cVal, cName) in colorOptions) { val isSelected = (highlightColors[entry.id] ?: highlightColors[sig.name] ?: 0L) == cVal Surface( - color = if (cVal == 0L) Color(0xFFF1F5F9) else Color(cVal), + color = if (cVal == 0L) Color(0xFFF1F5F9) else cVal.toComposeColor(), shape = RoundedCornerShape(3.dp), border = if (isSelected) androidx.compose.foundation.BorderStroke(1.5.dp, Color(0xFF2563EB)) else null, modifier = Modifier @@ -458,7 +467,7 @@ private fun AlphanumericalLightCell( onSignalDrop: ((DraggedSignal) -> Unit)? = null ) { var cellPosInRoot by remember { mutableStateOf(Offset.Zero) } - val valueBgColor = if (highlightColor != 0L) Color(highlightColor) else AlphaValueBg + val valueBgColor = if (highlightColor != 0L) highlightColor.toComposeColor() else AlphaValueBg val signalColor = signal.color.toComposeColor() val signalTextColor = if (signalColor.luminance() > 0.5f) Color.Black else Color.White @@ -545,6 +554,8 @@ private fun AlphanumericalLightCell( color = AlphaValueText, fontFamily = FontFamily.SansSerif, maxLines = 1, + overflow = TextOverflow.Ellipsis, + softWrap = false, textAlign = TextAlign.Center, style = androidx.compose.ui.text.TextStyle( platformStyle = androidx.compose.ui.text.PlatformTextStyle( @@ -566,7 +577,7 @@ private fun AlphanumericalTableRow( onSignalDrop: ((DraggedSignal) -> Unit)? = null ) { var cellPosInRoot by remember { mutableStateOf(Offset.Zero) } - val valueBgColor = if (highlightColor != 0L) Color(highlightColor) else AlphaValueBg + val valueBgColor = if (highlightColor != 0L) highlightColor.toComposeColor() else AlphaValueBg val signalColor = signal.color.toComposeColor() val signalTextColor = if (signalColor.luminance() > 0.5f) Color.Black else Color.White @@ -657,6 +668,8 @@ private fun AlphanumericalTableRow( fontWeight = FontWeight.Bold, color = AlphaValueText, maxLines = 1, + overflow = TextOverflow.Ellipsis, + softWrap = false, textAlign = TextAlign.Center, style = androidx.compose.ui.text.TextStyle( platformStyle = androidx.compose.ui.text.PlatformTextStyle( diff --git a/Android/app/src/main/java/com/dct/app/presentation/monitor/components/FormulaEditDialog.kt b/Android/app/src/main/java/com/dct/app/presentation/monitor/components/FormulaEditDialog.kt index e0f9f73..55e21ec 100644 --- a/Android/app/src/main/java/com/dct/app/presentation/monitor/components/FormulaEditDialog.kt +++ b/Android/app/src/main/java/com/dct/app/presentation/monitor/components/FormulaEditDialog.kt @@ -4,7 +4,8 @@ import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* -import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Close @@ -13,7 +14,6 @@ import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight @@ -21,21 +21,12 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.compose.ui.window.Dialog import com.dct.app.core.formula.FormulaEvaluator +import com.dct.app.core.theme.PcColorRef +import com.dct.app.presentation.signaltree.SignalColorPresetGrid +import com.dct.app.presentation.signaltree.SignalColorPickerDialog import com.dct.app.domain.model.formula.FormulaDefinition import java.util.Locale -// 기본 신호 색상 프리셋 -private val FORMULA_COLOR_PRESETS = listOf( - 0xFF2196F3L, // Blue - 0xFF4CAF50L, // Green - 0xFFFF9800L, // Orange - 0xFFE91E63L, // Pink - 0xFF9C27B0L, // Purple - 0xFF00BCD4L, // Cyan - 0xFFFFEB3BL, // Yellow - 0xFFF44336L // Red -) - /** * Formula 수식 생성 및 편집 대화상자 (InputFormulaDlg) */ @@ -49,25 +40,26 @@ fun FormulaEditDialog( var name by remember { mutableStateOf(initialFormula?.name ?: "") } var expression by remember { mutableStateOf(initialFormula?.expression ?: "") } var unit by remember { mutableStateOf(initialFormula?.unit ?: "") } - var color by remember { mutableStateOf(initialFormula?.color ?: 0xFF2196F3L) } + var color by remember { mutableStateOf(initialFormula?.color ?: PcColorRef.DEFAULT_SIGNAL) } var isForSm by remember { mutableStateOf(initialFormula?.isForSm ?: false) } var description by remember { mutableStateOf(initialFormula?.description ?: "") } var errorMessage by remember { mutableStateOf(null) } + var showColorPicker by remember { mutableStateOf(false) } // 실시간 수식 미리보기 계산 + val isValid = remember(expression) { FormulaEvaluator.validate(expression) } val previewResult by remember(expression, liveTelemetryValues) { derivedStateOf { if (expression.isBlank()) { "수식을 입력하세요" } else { - val isValid = FormulaEvaluator.validate(expression) if (!isValid) { "문법 오류" } else { val calc = FormulaEvaluator.evaluate(expression, liveTelemetryValues) if (calc.isNaN()) { - "계산 불가 (변수 미수신)" + "계산 불가 (미수신 또는 연산 오류)" } else { String.format(Locale.US, "%.3f", calc) } @@ -86,6 +78,7 @@ fun FormulaEditDialog( Column( modifier = Modifier .fillMaxWidth() + .verticalScroll(rememberScrollState()) .padding(16.dp) ) { // Header @@ -186,25 +179,8 @@ fun FormulaEditDialog( Column(modifier = Modifier.weight(1f)) { Text("차트 표시 색상", fontSize = 12.sp, fontWeight = FontWeight.SemiBold, color = Color(0xFF475569)) Spacer(modifier = Modifier.height(8.dp)) - Row( - horizontalArrangement = Arrangement.spacedBy(6.dp), - verticalAlignment = Alignment.CenterVertically - ) { - FORMULA_COLOR_PRESETS.take(6).forEach { c -> - Box( - modifier = Modifier - .size(22.dp) - .clip(CircleShape) - .background(Color(c)) - .border( - width = if (color == c) 2.dp else 1.dp, - color = if (color == c) Color.Black else Color(0x33000000), - shape = CircleShape - ) - .clickable { color = c } - ) - } - } + SignalColorPresetGrid(color, onSelect = { color = it }, tagPrefix = "FormulaColorPreset") + TextButton(onClick = { showColorPicker = true }) { Text("사용자 지정 색상", fontSize = 12.sp) } } } @@ -271,4 +247,9 @@ fun FormulaEditDialog( } } } -} \ No newline at end of file + if (showColorPicker) { + SignalColorPickerDialog(initialColor = color, + onConfirm = { color = it; showColorPicker = false }, + onDismiss = { showColorPicker = false }) + } +} diff --git a/Android/app/src/main/java/com/dct/app/presentation/monitor/components/FormulaWindowView.kt b/Android/app/src/main/java/com/dct/app/presentation/monitor/components/FormulaWindowView.kt index ad6d414..4fe7ebb 100644 --- a/Android/app/src/main/java/com/dct/app/presentation/monitor/components/FormulaWindowView.kt +++ b/Android/app/src/main/java/com/dct/app/presentation/monitor/components/FormulaWindowView.kt @@ -7,6 +7,8 @@ import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.rememberLazyListState +import com.dct.app.presentation.signaltree.slimLazyListScrollbar import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape @@ -15,29 +17,35 @@ import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.Edit import androidx.compose.material.icons.filled.Functions +import androidx.compose.material.icons.filled.MoreVert import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.draw.clipToBounds import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Brush import androidx.compose.ui.input.pointer.pointerInput -import androidx.compose.ui.layout.boundsInRoot +import androidx.compose.ui.geometry.Offset import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.layout.positionInRoot +import androidx.compose.ui.platform.testTag import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.sp -import com.dct.app.core.formula.FormulaEvaluator import com.dct.app.domain.model.formula.FormulaDefinition import com.dct.app.presentation.chart.DraggedSignal import com.dct.app.presentation.chart.SignalDragDropManager import com.dct.app.presentation.signaltree.TableSubHeader +import com.dct.app.presentation.signaltree.EditableTableColorCell +import com.dct.app.presentation.signaltree.ResizableTableHeaderCell +import com.dct.app.presentation.signaltree.rememberAutoFitWidth +import com.dct.app.presentation.signaltree.rememberWindowColumnWidths import java.util.Locale import com.dct.app.presentation.signaltree.rememberWindowString -import com.dct.app.presentation.signaltree.rememberWindowInt /** * Formula (가상 수식 계산기) 윈도우 콘텐츠 컴포넌트 (Windows Classic 스타일) @@ -55,6 +63,7 @@ fun FormulaWindowContent( var searchQuery by rememberWindowString("search") var editingFormula by remember { mutableStateOf(null) } var isAddingNew by remember { mutableStateOf(false) } + var deletingFormulaId by remember { mutableStateOf(null) } val filteredFormulas = remember(formulas, searchQuery) { if (searchQuery.isBlank()) formulas @@ -65,6 +74,22 @@ fun FormulaWindowContent( } } + val widths = rememberWindowColumnWidths() + val widthName = widths["name"] ?: 110.dp + val widthColor = widths["color"] ?: 55.dp + val widthValue = widths["value"] ?: 80.dp + val widthUnit = widths["unit"] ?: 45.dp + val widthExpression = widths["expression"] ?: 180.dp + // Scroll padding lets the full expression pass both the floating button and its fade mask. + val menuBackdropWidth = 82.dp + val tableWidth = widthName + widthValue + widthUnit + widthColor + widthExpression + menuBackdropWidth + val horizontalScroll = rememberScrollState() + + val nameAutoFitWidth = rememberAutoFitWidth("Name", filteredFormulas, 110.dp, + bodyStyle = LocalTextStyle.current.copy(fontSize = 11.sp, fontWeight = FontWeight.Medium), bodyPadding = 12.dp) { it.name } + val expressionAutoFitWidth = rememberAutoFitWidth("expression", filteredFormulas, 180.dp, + bodyStyle = LocalTextStyle.current.copy(fontSize = 11.sp), bodyPadding = 12.dp) { it.expression } + Column( modifier = Modifier .fillMaxSize() @@ -82,7 +107,9 @@ fun FormulaWindowContent( Box(modifier = Modifier.weight(1f)) { TableSubHeader( searchQuery = searchQuery, - onSearchChange = { searchQuery = it } + onSearchChange = { searchQuery = it }, + // Formula has no extra columns to expand; keep column resizing separate. + showExpand = false ) } Spacer(modifier = Modifier.width(6.dp)) @@ -104,9 +131,9 @@ fun FormulaWindowContent( modifier = Modifier .weight(1f) .fillMaxWidth() - .horizontalScroll(rememberScrollState()) + .testTag("FormulaTable") ) { - Column(modifier = Modifier.width(420.dp).fillMaxHeight()) { + Column(modifier = Modifier.fillMaxSize()) { // Table Header Row( modifier = Modifier @@ -116,13 +143,26 @@ fun FormulaWindowContent( .border(1.dp, Color(0xFFCED4DA)), verticalAlignment = Alignment.CenterVertically ) { - Text("Name", fontSize = 11.sp, fontWeight = FontWeight.Bold, color = Color(0xFF212529), modifier = Modifier.width(110.dp).padding(horizontal = 6.dp)) - Box(modifier = Modifier.width(1.dp).fillMaxHeight().background(Color(0xFFCED4DA))) - Text("Value", fontSize = 11.sp, fontWeight = FontWeight.Bold, color = Color(0xFF212529), modifier = Modifier.width(80.dp).padding(horizontal = 6.dp)) - Box(modifier = Modifier.width(1.dp).fillMaxHeight().background(Color(0xFFCED4DA))) - Text("expression", fontSize = 11.sp, fontWeight = FontWeight.Bold, color = Color(0xFF212529), modifier = Modifier.weight(1f).padding(horizontal = 6.dp)) - Box(modifier = Modifier.width(1.dp).fillMaxHeight().background(Color(0xFFCED4DA))) - Text("Act", fontSize = 11.sp, fontWeight = FontWeight.Bold, color = Color(0xFF212529), modifier = Modifier.width(50.dp).padding(horizontal = 4.dp)) + Box(Modifier.weight(1f).fillMaxHeight().clipToBounds().horizontalScroll(horizontalScroll) + .testTag("FormulaHeaderViewport")) { + Row(Modifier.width(tableWidth).fillMaxHeight(), verticalAlignment = Alignment.CenterVertically) { + ResizableTableHeaderCell("Name", widthName, modifier = Modifier.testTag("FormulaHeader_name"), + onWidthChange = { widths["name"] = it }, showAutoFit = true, + isAutoFit = widths.isAutoFit("name", nameAutoFitWidth), + onAutoFitClick = { widths.toggleAutoFit("name", 110.dp, nameAutoFitWidth) }) + ResizableTableHeaderCell("Value", widthValue, modifier = Modifier.testTag("FormulaHeader_value"), + onWidthChange = { widths["value"] = it }) + ResizableTableHeaderCell("Unit", widthUnit, modifier = Modifier.testTag("FormulaHeader_unit"), + onWidthChange = { widths["unit"] = it }) + ResizableTableHeaderCell("Color", widthColor, modifier = Modifier.testTag("FormulaHeader_color"), + onWidthChange = { widths["color"] = it }) + ResizableTableHeaderCell("expression", widthExpression, modifier = Modifier.testTag("FormulaHeader_expression"), + onWidthChange = { widths["expression"] = it }, showAutoFit = true, + isAutoFit = widths.isAutoFit("expression", expressionAutoFitWidth), + onAutoFitClick = { widths.toggleAutoFit("expression", 180.dp, expressionAutoFitWidth) }) + Spacer(Modifier.width(menuBackdropWidth)) + } + } } // Table Body @@ -145,129 +185,130 @@ fun FormulaWindowContent( } } } else { + val tableListState = rememberLazyListState() LazyColumn( + state = tableListState, modifier = Modifier .fillMaxSize() .background(Color.White) + .slimLazyListScrollbar(tableListState) + .testTag("FormulaRows") ) { itemsIndexed(filteredFormulas, key = { _, f -> f.id }) { index, formula -> - var itemBounds by remember { mutableStateOf(androidx.compose.ui.geometry.Rect.Zero) } + var itemOrigin by remember { mutableStateOf(Offset.Zero) } + val rowBackground = if (index % 2 == 0) Color.White else Color(0xFFF8F9FA) - // 실시간 계산 결과 (텔레메트리 맵에서 조회 또는 즉시 평가) - val liveVal = liveTelemetryValues[formula.name] - ?: FormulaEvaluator.evaluate(formula.expression, liveTelemetryValues) + // The telemetry engine owns evaluation; rendering never scans signal values. + val liveVal = liveTelemetryValues[formula.name] ?: Double.NaN val valStr = if (liveVal.isNaN()) { - "0.000" + "No value" } else { String.format(Locale.US, "%.3f", liveVal) } - Row( + Box( modifier = Modifier .fillMaxWidth() - .height(28.dp) - .background(if (index % 2 == 0) Color.White else Color(0xFFF8F9FA)) + .height(48.dp) + .background(rowBackground) .border(1.dp, Color(0xFFF1F3F5)) - .onGloballyPositioned { coords -> - itemBounds = coords.boundsInRoot() - } - .pointerInput(formula) { - detectDragGesturesAfterLongPress( - onDragStart = { localOffset -> - SignalDragDropManager.startDrag( - DraggedSignal( - signalId = "formula_${formula.name}", - signalName = formula.name, - colorLong = formula.color, - unit = formula.unit, - isCan = false, - isA2l = false - ), - itemBounds.topLeft + localOffset - ) - }, - onDrag = { change, dragAmount -> - change.consume() - SignalDragDropManager.updateDrag(dragAmount) - }, - onDragEnd = { - SignalDragDropManager.endDrag { droppedSignal -> - onSignalDrop(droppedSignal) - } - }, - onDragCancel = { - SignalDragDropManager.cancelDrag() - } - ) - } - .clickable { onFormulaClick(formula) }, - verticalAlignment = Alignment.CenterVertically ) { - // 1. Name & Color indicator - Row( - modifier = Modifier.width(110.dp).padding(horizontal = 6.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Box( - modifier = Modifier - .size(8.dp) - .background(Color(formula.color), RoundedCornerShape(2.dp)) - ) - Spacer(modifier = Modifier.width(6.dp)) - Text( - text = formula.name, - fontSize = 11.sp, - fontWeight = FontWeight.Medium, - color = Color(0xFF212529), - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - } - Box(modifier = Modifier.width(1.dp).fillMaxHeight().background(Color(0xFFE9ECEF))) - - // 2. Value - Text( - text = valStr, - fontSize = 11.sp, - fontFamily = FontFamily.Monospace, - fontWeight = FontWeight.Bold, - color = if (liveVal.isNaN()) Color.Red else Color(0xFF0F172A), - modifier = Modifier.width(80.dp).padding(horizontal = 6.dp), - maxLines = 1 - ) - Box(modifier = Modifier.width(1.dp).fillMaxHeight().background(Color(0xFFE9ECEF))) - - // 3. Expression - Text( - text = formula.expression, - fontSize = 11.sp, - color = Color(0xFF495057), - modifier = Modifier.weight(1f).padding(horizontal = 6.dp), - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - Box(modifier = Modifier.width(1.dp).fillMaxHeight().background(Color(0xFFE9ECEF))) - - // 4. Action Buttons (Edit / Delete) - Row( - modifier = Modifier.width(50.dp), - horizontalArrangement = Arrangement.Center, - verticalAlignment = Alignment.CenterVertically - ) { - IconButton( - onClick = { editingFormula = formula }, - modifier = Modifier.size(22.dp) - ) { - Icon(Icons.Default.Edit, contentDescription = "Edit", modifier = Modifier.size(13.dp), tint = Color(0xFF1E88E5)) - } - IconButton( - onClick = { onDeleteFormula(formula) }, - modifier = Modifier.size(22.dp) + Box(Modifier.fillMaxSize().clipToBounds() + .horizontalScroll(horizontalScroll).testTag("FormulaViewport_${formula.id}")) { + Row( + modifier = Modifier.width(tableWidth).fillMaxHeight() + .onGloballyPositioned { coords -> + // Use the unclipped origin when the row is horizontally scrolled. + itemOrigin = coords.positionInRoot() + } + .pointerInput(formula) { + detectDragGesturesAfterLongPress( + onDragStart = { localOffset -> + SignalDragDropManager.startDrag( + DraggedSignal( + signalId = "formula_${formula.name}", + signalName = formula.name, + colorLong = formula.color, + unit = formula.unit, + isCan = false, + isA2l = false + ), + itemOrigin + localOffset + ) + }, + onDrag = { change, dragAmount -> + change.consume() + SignalDragDropManager.updateDrag(dragAmount) + }, + onDragEnd = { + SignalDragDropManager.endDrag { droppedSignal -> + onSignalDrop(droppedSignal) + } + }, + onDragCancel = { + SignalDragDropManager.cancelDrag() + } + ) + } + .clickable { onFormulaClick(formula) }, + verticalAlignment = Alignment.CenterVertically ) { - Icon(Icons.Default.Delete, contentDescription = "Delete", modifier = Modifier.size(13.dp), tint = Color(0xFFE53935)) + FormulaTableCell(widthName, "FormulaCell_name_${formula.id}") { + Text( + text = formula.name, + fontSize = 11.sp, + fontWeight = FontWeight.Medium, + color = Color(0xFF212529), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.padding(horizontal = 6.dp) + ) + } + FormulaTableCell(widthValue, "FormulaCell_value_${formula.id}") { + Text( + text = valStr, + fontSize = 11.sp, + fontFamily = FontFamily.Monospace, + fontWeight = FontWeight.Bold, + color = if (liveVal.isNaN()) Color.Red else Color(0xFF0F172A), + modifier = Modifier.padding(horizontal = 6.dp), + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + FormulaTableCell(widthUnit, "FormulaCell_unit_${formula.id}") { + Text( + text = formula.unit, + fontSize = 11.sp, + color = Color(0xFF495057), + modifier = Modifier.padding(horizontal = 6.dp), + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + FormulaTableCell(widthColor, "FormulaCell_color_${formula.id}") { + EditableTableColorCell(formula.color, + onColorChanged = { onUpdateFormula(formula.copy(color = it)) }, + modifier = Modifier.fillMaxSize()) + } + FormulaTableCell(widthExpression, "FormulaCell_expression_${formula.id}") { + Text( + text = formula.expression, + fontSize = 11.sp, + color = Color(0xFF495057), + modifier = Modifier.padding(horizontal = 6.dp), + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + Spacer(Modifier.width(menuBackdropWidth)) } } + FormulaActionMenu(formula, menuBackdropWidth, rowBackground, + modifier = Modifier.align(Alignment.CenterEnd), + onEdit = { editingFormula = formula }, + onDelete = { deletingFormulaId = formula.id }) } } } @@ -276,6 +317,27 @@ fun FormulaWindowContent( } } + // Resolve by ID so confirmation never deletes a stale definition after an external update/removal. + val deletingFormula = remember(formulas, deletingFormulaId) { + deletingFormulaId?.let { id -> formulas.firstOrNull { it.id == id } } + } + if (deletingFormula != null) { + AlertDialog( + onDismissRequest = { deletingFormulaId = null }, + title = { Text("수식 삭제") }, + text = { Text("‘${deletingFormula.name}’ 수식을 삭제하시겠습니까?") }, + confirmButton = { + TextButton(onClick = { + deletingFormulaId = null + onDeleteFormula(deletingFormula) + }, modifier = Modifier.testTag("FormulaConfirmDelete")) { Text("삭제", color = Color(0xFFE53935)) } + }, + dismissButton = { + TextButton(onClick = { deletingFormulaId = null }) { Text("취소") } + } + ) + } + // 신규 수식 추가 다이얼로그 if (isAddingNew) { FormulaEditDialog( @@ -302,3 +364,38 @@ fun FormulaWindowContent( ) } } + +/** Floating menu masks trailing cell text without a separate Act column. */ +@Composable +private fun FormulaActionMenu(formula: FormulaDefinition, width: Dp, background: Color, + modifier: Modifier = Modifier, onEdit: () -> Unit, onDelete: () -> Unit) { + var menuExpanded by remember(formula.id) { mutableStateOf(false) } + val backdrop = remember(background) { + Brush.horizontalGradient(0f to background.copy(alpha = 0f), 0.4f to background, 1f to background) + } + Box(modifier.width(width).fillMaxHeight().background(backdrop) + .testTag("FormulaMenuBackdrop_${formula.id}"), contentAlignment = Alignment.CenterEnd) { + IconButton(onClick = { menuExpanded = true }, + modifier = Modifier.padding(end = 1.dp).size(48.dp).testTag("FormulaActions_${formula.id}")) { + Icon(Icons.Default.MoreVert, contentDescription = "${formula.name} 수식 메뉴", + modifier = Modifier.size(20.dp), tint = Color(0xFF475569)) + } + DropdownMenu(expanded = menuExpanded, onDismissRequest = { menuExpanded = false }) { + DropdownMenuItem(text = { Text("수식 수정") }, + leadingIcon = { Icon(Icons.Default.Edit, contentDescription = null) }, + onClick = { menuExpanded = false; onEdit() }) + DropdownMenuItem(text = { Text("수식 삭제", color = Color(0xFFE53935)) }, + leadingIcon = { Icon(Icons.Default.Delete, contentDescription = null, tint = Color(0xFFE53935)) }, + onClick = { menuExpanded = false; onDelete() }) + } + } +} + +/** Divider is inside the cell width so headers and rows stay aligned after resizing. */ +@Composable +private fun FormulaTableCell(width: Dp, tag: String, content: @Composable BoxScope.() -> Unit) { + Box(Modifier.width(width).fillMaxHeight().clipToBounds().testTag(tag), contentAlignment = Alignment.CenterStart) { + content() + Box(Modifier.align(Alignment.CenterEnd).width(1.dp).fillMaxHeight().background(Color(0xFFE9ECEF))) + } +} diff --git a/Android/app/src/main/java/com/dct/app/presentation/monitor/components/GraphWindowView.kt b/Android/app/src/main/java/com/dct/app/presentation/monitor/components/GraphWindowView.kt index 0924db8..0c7ff73 100644 --- a/Android/app/src/main/java/com/dct/app/presentation/monitor/components/GraphWindowView.kt +++ b/Android/app/src/main/java/com/dct/app/presentation/monitor/components/GraphWindowView.kt @@ -12,9 +12,12 @@ import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons @@ -30,11 +33,11 @@ import androidx.compose.material.icons.filled.Timeline import androidx.compose.material.icons.filled.Tune import androidx.compose.material.icons.filled.Visibility import androidx.compose.material.icons.filled.VisibilityOff -import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton +import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect @@ -48,10 +51,20 @@ import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.boundsInRoot import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.Density import androidx.compose.ui.unit.DpOffset +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.IntRect +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import androidx.compose.ui.window.Popup +import androidx.compose.ui.window.PopupPositionProvider +import androidx.compose.ui.window.PopupProperties import com.dct.app.core.theme.toComposeColor import com.dct.app.domain.model.chart.AxisPosition import com.dct.app.domain.model.chart.ChartRenderSnapshot @@ -59,7 +72,6 @@ import com.dct.app.domain.model.chart.InterpolationMode import com.dct.app.domain.model.chart.SeriesRenderData import com.dct.app.domain.model.chart.TimeWindowPreset import com.dct.app.presentation.chart.DraggedSignal -import com.dct.app.presentation.chart.GraphYAxisDialog import com.dct.app.presentation.chart.MultiXGraphCanvas import com.dct.app.presentation.chart.SignalDragDropManager import java.util.Locale @@ -77,6 +89,11 @@ fun GraphWindowContent( onCrosshairTimeChange: ((Double?) -> Unit)? = null, onPanTime: ((Double) -> Unit)? = null, onZoomTime: ((Float, Double) -> Unit)? = null, + onYAxisPan: ((AxisPosition, Float) -> Unit)? = null, + onYAxisZoom: ((AxisPosition, Float, Float) -> Unit)? = null, + onResetYAxis: ((AxisPosition) -> Unit)? = null, + /** Kept for source compatibility with the pre-pan/pinch adapter. */ + onYAxisScale: ((AxisPosition, Float) -> Unit)? = null, onResetPan: (() -> Unit)? = null, onSignalDrop: ((DraggedSignal) -> Unit)? = null, onToggleVisibility: ((String) -> Unit)? = null, @@ -88,7 +105,9 @@ fun GraphWindowContent( onSelectYAxisAuto: (() -> Unit)? = null, onOpenYAxisSetting: ((SeriesRenderData?) -> Unit)? = null, onChangeTitleName: (() -> Unit)? = null, - onQuitWindow: (() -> Unit)? = null + onQuitWindow: (() -> Unit)? = null, + onWidthChanged: ((Int) -> Unit)? = null, + onSelectYAxisManual: (() -> Unit)? = null ) { val density = LocalDensity.current val isDragging = SignalDragDropManager.isDragging @@ -105,6 +124,7 @@ fun GraphWindowContent( modifier = Modifier .fillMaxSize() .background(Color.White) + .onSizeChanged { onWidthChanged?.invoke(it.width) } .onGloballyPositioned { coordinates -> SignalDragDropManager.registerDropTarget(windowId, coordinates.boundsInRoot()) } @@ -122,6 +142,10 @@ fun GraphWindowContent( onCrosshairTimeChange = onCrosshairTimeChange, onPanTime = onPanTime, onZoomTime = onZoomTime, + onYAxisPan = onYAxisPan, + onYAxisZoom = onYAxisZoom, + onResetYAxis = onResetYAxis, + onYAxisScale = onYAxisScale, onResetPan = onResetPan, onOpenContextMenu = { offset -> contextMenuOffset = with(density) { DpOffset(offset.x.toDp(), offset.y.toDp()) } @@ -137,7 +161,10 @@ fun GraphWindowContent( snapshot = snapshot, isMultiAxis = isMultiAxis, offset = DpOffset.Zero, - onRemoveSignal = { onRemoveSignal(it); isContextMenuOpen = false }, + // Removing one item must not dismiss the options menu. The + // snapshot updates in-place so another signal can be removed + // without reopening the menu. + onRemoveSignal = { onRemoveSignal(it) }, onToggleVisibility = { onToggleVisibility?.invoke(it) }, onChangeThickness = { id, th -> onChangeThickness?.invoke(id, th); isContextMenuOpen = false }, onChangeDashStyle = { id, st -> onChangeDashStyle?.invoke(id, st); isContextMenuOpen = false }, @@ -145,6 +172,7 @@ fun GraphWindowContent( onSelectTimePreset = { preset -> onSelectTimePreset?.invoke(preset); isContextMenuOpen = false }, onSelectXAxisManual = { onSelectXAxisManual?.invoke(); isContextMenuOpen = false }, onSelectYAxisAuto = { onSelectYAxisAuto?.invoke(); isContextMenuOpen = false }, + onSelectYAxisManual = { onSelectYAxisManual?.invoke(); isContextMenuOpen = false }, onOpenYAxisSetting = { series -> onOpenYAxisSetting?.invoke(series); isContextMenuOpen = false }, onChangeTitleName = { onChangeTitleName?.invoke(); isContextMenuOpen = false }, onQuitWindow = { onQuitWindow?.invoke(); isContextMenuOpen = false } @@ -181,25 +209,60 @@ fun GraphContextMenu( onSelectYAxisAuto: () -> Unit, onOpenYAxisSetting: (SeriesRenderData?) -> Unit, onChangeTitleName: () -> Unit, - onQuitWindow: () -> Unit + onQuitWindow: () -> Unit, + onSelectYAxisManual: () -> Unit = {} ) { var currentMenuPath by remember(expanded) { mutableStateOf>(emptyList()) } var selectedSignalForSubmenu by remember(expanded) { mutableStateOf(null) } - DropdownMenu( - expanded = expanded, - onDismissRequest = { - currentMenuPath = emptyList() - selectedSignalForSubmenu = null - onDismissRequest() - }, - offset = offset, - modifier = Modifier - .background(Color.White) - .border(1.dp, Color(0xFFCBD5E1), RoundedCornerShape(4.dp)) - .width(220.dp) - ) { - val path = currentMenuPath.joinToString("/") + val path = currentMenuPath.joinToString("/") + val density = LocalDensity.current + // Leave room for the system bars so a long root menu can be placed above + // the anchor without being clipped. The content itself remains + // scrollable for projects with many signals. + val maxMenuHeight = (LocalConfiguration.current.screenHeightDp - 96) + .coerceAtLeast(180) + .dp + + // Keep one Popup instance for the complete root/submenu navigation. The + // provider captures the position on the first measurement and reuses it + // while the content changes, so entering a shorter submenu cannot make + // the menu jump from above the anchor to below it (or vice versa). + // Remember it outside the conditional so closing and reopening always + // creates a fresh position session. + val positionProvider = remember(expanded, offset, density) { + GraphContextMenuPositionProvider( + offset = offset, + density = density, + // The initial root menu determines the side. Submenus keep + // this same side for the lifetime of the open menu. + preferAbove = true + ) + } + if (expanded) { + Popup( + popupPositionProvider = positionProvider, + onDismissRequest = { + currentMenuPath = emptyList() + selectedSignalForSubmenu = null + onDismissRequest() + }, + properties = PopupProperties(focusable = true) + ) { + Surface( + modifier = Modifier + .width(220.dp) + .heightIn(max = maxMenuHeight) + .border(1.dp, Color(0xFFCBD5E1), RoundedCornerShape(4.dp)), + shape = RoundedCornerShape(4.dp), + color = Color.White, + shadowElevation = 8.dp + ) { + Column( + modifier = Modifier + .verticalScroll(rememberScrollState()) + .padding(vertical = 8.dp) + ) { when (path) { // 1. Remove Submenu @@ -334,7 +397,7 @@ fun GraphContextMenu( } } - // 6. X-Axis Submenu (Auto -> [Auto, 10s, 30s, 1min, 5min, 10min] / Manual) + // 6. X-Axis Submenu (Auto / Hold / Manual) "xaxis" -> { SubMenuHeader(title = "X-Axis", onBack = { currentMenuPath = emptyList() }) DropdownMenuItem( @@ -350,10 +413,18 @@ fun GraphContextMenu( ) DropdownMenuItem( leadingIcon = { - if (!snapshot.isXAxisAuto) Icon(Icons.Default.Check, contentDescription = null, tint = Color(0xFF2563EB), modifier = Modifier.size(16.dp)) + if (snapshot.xAxisPreset == TimeWindowPreset.HOLD) Icon(Icons.Default.Check, contentDescription = null, tint = Color(0xFF2563EB), modifier = Modifier.size(16.dp)) + else Spacer(modifier = Modifier.size(16.dp)) + }, + text = { Text("Hold", fontSize = 12.sp, fontWeight = if (snapshot.xAxisPreset == TimeWindowPreset.HOLD) FontWeight.Bold else FontWeight.Normal) }, + onClick = { onSelectTimePreset(TimeWindowPreset.HOLD) } + ) + DropdownMenuItem( + leadingIcon = { + if (!snapshot.isXAxisAuto && snapshot.xAxisPreset != TimeWindowPreset.HOLD) Icon(Icons.Default.Check, contentDescription = null, tint = Color(0xFF2563EB), modifier = Modifier.size(16.dp)) else Spacer(modifier = Modifier.size(16.dp)) }, - text = { Text("Manual", fontSize = 12.sp, fontWeight = if (!snapshot.isXAxisAuto) FontWeight.Bold else FontWeight.Normal) }, + text = { Text("Manual", fontSize = 12.sp, fontWeight = if (!snapshot.isXAxisAuto && snapshot.xAxisPreset != TimeWindowPreset.HOLD) FontWeight.Bold else FontWeight.Normal) }, onClick = { onSelectXAxisManual() } ) } @@ -391,7 +462,6 @@ fun GraphContextMenu( text = { Text("Auto", fontSize = 12.sp, fontWeight = if (snapshot.isYAxisAuto) FontWeight.Bold else FontWeight.Normal) }, onClick = { onSelectYAxisAuto() } ) - val hasMultipleSignals = isMultiAxis && snapshot.seriesList.size > 1 DropdownMenuItem( leadingIcon = { if (!snapshot.isYAxisAuto) Icon(Icons.Default.Check, contentDescription = null, tint = Color(0xFF2563EB), modifier = Modifier.size(16.dp)) @@ -399,35 +469,20 @@ fun GraphContextMenu( }, text = { Text("Manual", fontSize = 12.sp, fontWeight = if (!snapshot.isYAxisAuto) FontWeight.Bold else FontWeight.Normal) }, trailingIcon = { - if (hasMultipleSignals) Icon(Icons.Default.ChevronRight, contentDescription = null, modifier = Modifier.size(14.dp)) + if (isMultiAxis) Spacer(modifier = Modifier.size(14.dp)) else Icon(Icons.Default.Tune, contentDescription = null, modifier = Modifier.size(14.dp)) }, onClick = { - if (hasMultipleSignals) { - currentMenuPath = listOf("yaxis", "manual") + // MultiXGraphWnd.cpp applies Manual to every configured + // axis and does not open the single-axis numeric dialog. + if (isMultiAxis) { + onSelectYAxisManual() } else { onOpenYAxisSetting(snapshot.seriesList.firstOrNull()) } } ) } - "yaxis/manual" -> { - SubMenuHeader(title = "Select Signal Axis", onBack = { currentMenuPath = listOf("yaxis") }) - if (snapshot.seriesList.isEmpty()) { - EmptyItem("No signals registered") - } else { - for (series in snapshot.seriesList) { - val axisLabel = (if (series.axisPosition.isLeft) "L" else "R") + (series.axisPosition.index + 1) - DropdownMenuItem( - leadingIcon = { SignalColorDot(series.color) }, - text = { Text("${series.signalName} ($axisLabel)", fontSize = 12.sp) }, - trailingIcon = { Icon(Icons.Default.Tune, contentDescription = null, modifier = Modifier.size(14.dp)) }, - onClick = { onOpenYAxisSetting(series) } - ) - } - } - } - // Root Menu else -> { // 1. Remove @@ -496,6 +551,82 @@ fun GraphContextMenu( ) } } + } + } + } + } +} + +/** + * Deterministic vertical placement for the graph options popup. + * + * The first measurement chooses a side for the complete menu session. That + * side is retained for every submenu, while the corresponding popup edge + * remains attached to the anchor (bottom edge when placed above, top edge + * when placed below). This prevents a shorter submenu from flipping sides + * or becoming detached from the graph window. + */ +private class GraphContextMenuPositionProvider( + private val offset: DpOffset, + private val density: Density, + private val preferAbove: Boolean +) : PopupPositionProvider { + private var placeAbove: Boolean? = null + + override fun calculatePosition( + anchorBounds: IntRect, + windowSize: IntSize, + layoutDirection: LayoutDirection, + popupContentSize: IntSize + ): IntOffset { + val offsetX = with(density) { offset.x.roundToPx() } + val offsetY = with(density) { offset.y.roundToPx() } + val margin = with(density) { 48.dp.roundToPx() } + + val xCandidate = if (layoutDirection == LayoutDirection.Ltr) { + anchorBounds.left + offsetX + } else { + anchorBounds.right - popupContentSize.width - offsetX + } + val maxX = (windowSize.width - popupContentSize.width).coerceAtLeast(0) + val belowY = anchorBounds.bottom + offsetY + val aboveY = anchorBounds.top - popupContentSize.height + offsetY + val availableHeight = (windowSize.height - popupContentSize.height).coerceAtLeast(0) + val minY = if (availableHeight >= margin * 2) margin else 0 + val maxY = if (availableHeight >= margin * 2) { + windowSize.height - popupContentSize.height - margin + } else { + availableHeight + }.coerceAtLeast(minY) + + fun fits(y: Int): Boolean = y in minY..maxY + val currentX = xCandidate.coerceIn(0, maxX) + // Decide the side only for the first (root menu) measurement. Once + // selected, the side never changes while this Popup is open. + val isAbove = placeAbove ?: run { + val preferredY = if (preferAbove) aboveY else belowY + val alternateY = if (preferAbove) belowY else aboveY + val selected = when { + fits(preferredY) -> preferAbove + fits(alternateY) -> !preferAbove + else -> preferAbove + } + selected.also { placeAbove = it } + } + + // Recalculate the coordinate from the current content height, but + // keep the selected edge attached to the anchor. A shorter submenu + // therefore moves toward the anchor instead of staying at a detached + // screen position, and it still cannot flip to the opposite side. + val attachedY = if (isAbove) { + anchorBounds.top - popupContentSize.height + offsetY + } else { + anchorBounds.bottom + offsetY + } + return IntOffset( + currentX, + attachedY.coerceIn(minY, maxY) + ) } } @@ -546,7 +677,8 @@ fun GraphSettingsButton( onSelectYAxisAuto: () -> Unit = {}, onOpenYAxisSetting: (SeriesRenderData?) -> Unit, onChangeTitleName: () -> Unit = {}, - onQuitWindow: () -> Unit = {} + onQuitWindow: () -> Unit = {}, + onSelectYAxisManual: () -> Unit = {} ) { var isExpanded by remember { mutableStateOf(false) } @@ -568,7 +700,9 @@ fun GraphSettingsButton( onDismissRequest = { isExpanded = false }, snapshot = snapshot, isMultiAxis = isMultiAxis, - onRemoveSignal = { onRemoveSignal(it); isExpanded = false }, + // Keep the Remove submenu open after each deletion so multiple + // graph items can be removed in one pass. + onRemoveSignal = { onRemoveSignal(it) }, onToggleVisibility = { onToggleVisibility(it) }, onChangeThickness = { id, th -> onChangeThickness(id, th); isExpanded = false }, onChangeDashStyle = { id, st -> onChangeDashStyle(id, st); isExpanded = false }, @@ -576,6 +710,7 @@ fun GraphSettingsButton( onSelectTimePreset = { preset -> onSelectTimePreset(preset); isExpanded = false }, onSelectXAxisManual = { onSelectXAxisManual(); isExpanded = false }, onSelectYAxisAuto = { onSelectYAxisAuto(); isExpanded = false }, + onSelectYAxisManual = { onSelectYAxisManual(); isExpanded = false }, onOpenYAxisSetting = { series -> onOpenYAxisSetting(series); isExpanded = false }, onChangeTitleName = { onChangeTitleName(); isExpanded = false }, onQuitWindow = { onQuitWindow(); isExpanded = false } diff --git a/Android/app/src/main/java/com/dct/app/presentation/monitor/components/MdiSubWindowFrame.kt b/Android/app/src/main/java/com/dct/app/presentation/monitor/components/MdiSubWindowFrame.kt index 786fddd..2e76bfd 100644 --- a/Android/app/src/main/java/com/dct/app/presentation/monitor/components/MdiSubWindowFrame.kt +++ b/Android/app/src/main/java/com/dct/app/presentation/monitor/components/MdiSubWindowFrame.kt @@ -66,6 +66,7 @@ fun MdiSubWindow( density: Float, viewportScrollX: Float = 0f, viewportScrollY: Float = 0f, + onRevealWindow: (String) -> Unit = {}, onClose: () -> Unit, onBringToFront: () -> Unit, onMove: (dx: Float, dy: Float) -> Unit, @@ -82,16 +83,29 @@ fun MdiSubWindow( val currentOnDragEnd by rememberUpdatedState(onDragEnd) val currentOnDragCancel by rememberUpdatedState(onDragCancel) val currentOnDragPointerPosition by rememberUpdatedState(onDragPointerPosition) + val workspaceNavigation = LocalWorkspaceTouchNavigation.current var windowPositionInRoot by remember { mutableStateOf(Offset.Zero) } + var windowBoundsInRoot by remember { mutableStateOf(androidx.compose.ui.geometry.Rect.Zero) } var headerPositionInRoot by remember { mutableStateOf(Offset.Zero) } var editingSeriesForYAxis by remember { mutableStateOf(null) } + var isYAxisDialogOpen by remember { mutableStateOf(false) } val contentRevision by viewModel.workspaceRevision.collectAsState() val context = androidx.compose.ui.platform.LocalContext.current val isTileMode = window.config.isTileMode val hiddenAlphaSignals = window.config.hiddenSignalIds val highlightColors = window.config.highlightColors + // Signal definitions change on project/source edits, not on every value or canvas movement. + // Reuse the parent's cached MDB list and share the result with the settings menu. + val alphaSelection = if (window.type == MdiWindowType.ALPHANUMERICAL) { + val formulas by viewModel.formulas.collectAsState() + val modules by viewModel.sensorModules.collectAsState() + remember(window.config.targetSignals, canSignals, mdbSignals, a2lSignals, formulas, modules) { + resolveAlphaSignalSelection(window.config.targetSignals, canSignals, mdbSignals, a2lSignals, formulas, modules) + } + } else null + val isGraphWindow = window.type in setOf(MdiWindowType.GRAPH, MdiWindowType.CHART, MdiWindowType.MULTI_AXIS_GRAPH) if (isGraphWindow) { @@ -102,6 +116,17 @@ fun MdiSubWindow( } } + // Reserve the complete MDI surface before any pointer reaches the page + // canvas. Nested tables/lists can consume their own scroll deltas and the + // frame-level nested-scroll barrier consumes any remainder at an edge. + // Single-finger canvas fallback is therefore blocked inside every window, + // while the workspace navigator may still promote a two-finger sequence + // to a page pan. + DisposableEffect(workspaceNavigation, window.id) { + workspaceNavigation?.registerWindowTouchRegion(window.id) { windowBoundsInRoot } + onDispose { workspaceNavigation?.unregisterWindowTouchRegion(window.id) } + } + // 서브 윈도우 내부(테이블/목록 등)에서 스크롤 시, 스크롤 끝에 도달했을 때 잔여 스크롤이 상위 메인 캔버스를 움직이지 않도록 전파 차단 val stopNestedScrollConnection = remember { object : NestedScrollConnection { @@ -141,8 +166,16 @@ fun MdiSubWindow( } .size(window.width.dp, (if (window.isMinimized) 32f else window.height).dp) .nestedScroll(stopNestedScrollConnection) + // Let nested controls consume their own movement first, then + // consume any remaining pointer delta at the window surface. This + // prevents the outer canvas scroll modifiers from receiving a + // single-finger drag that started inside a window, including when + // the inner list is already at its scroll boundary. A two-finger + // page gesture is captured by the workspace navigator earlier. + .windowTouchScrollBarrier(window.id) .onGloballyPositioned { coordinates -> windowPositionInRoot = coordinates.positionInRoot() + windowBoundsInRoot = coordinates.boundsInRoot() if (isGraphWindow) { SignalDragDropManager.registerDropTarget(window.id, coordinates.boundsInRoot()) } @@ -161,9 +194,10 @@ fun MdiSubWindow( .border(1.dp, Color(0xFFC8CED8)) .padding(horizontal = 8.dp) .onGloballyPositioned { headerPositionInRoot = it.positionInRoot() } - .pointerInput(window.id) { + .pointerInput(window.id, workspaceNavigation) { detectDragGestures( onDragStart = { offset -> + workspaceNavigation?.claimSinglePointerDrag() currentOnDragStart(headerPositionInRoot - windowPositionInRoot + offset) currentOnDragPointerPosition(headerPositionInRoot + offset) }, @@ -218,9 +252,17 @@ fun MdiSubWindow( onChangeDashStyle = { signalId, st -> windowRenderer.setSeriesDashStyle(signalId, st) }, onSelectLegendPosition = { pos -> windowRenderer.setLegendPosition(pos) }, onSelectTimePreset = { preset -> viewModel.setChartTimeWindowPreset(preset, window.id) }, - onSelectXAxisManual = { windowRenderer.setXAxisManualMode() }, + // GraphWnd.cpp only disables X-axis auto-follow; + // Manual does not open a numeric range dialog. + onSelectXAxisManual = { + windowRenderer.setXAxisManualMode() + }, onSelectYAxisAuto = { windowRenderer.setYAxisAuto(true) }, - onOpenYAxisSetting = { series -> editingSeriesForYAxis = series }, + onSelectYAxisManual = { windowRenderer.setYAxisAuto(false) }, + onOpenYAxisSetting = { series -> + editingSeriesForYAxis = series + isYAxisDialogOpen = true + }, onChangeTitleName = { isHeaderRenameDialogOpen = true }, onQuitWindow = { currentOnClose() } ) @@ -237,12 +279,8 @@ fun MdiSubWindow( } } MdiWindowType.ALPHANUMERICAL -> { - val currentDb = viewModel.uiState.collectAsState().value.mdbDatabase - val activeMdbSignals = currentDb?.allSignals ?: mdbSignals - val formulas by viewModel.formulas.collectAsState() - val modules by viewModel.sensorModules.collectAsState() var renameAlpha by remember { mutableStateOf(false) } - val alphaDisplaySignals = resolveAlphaSignals(window.config.targetSignals, canSignals, activeMdbSignals, a2lSignals, formulas, modules) + val alphaDisplaySignals = requireNotNull(alphaSelection).signals if (renameAlpha) RenameWindowDialog(window.title, { viewModel.mdiViewModel.renameWindow(window.id, it); renameAlpha = false }, { renameAlpha = false }) @@ -307,7 +345,8 @@ fun MdiSubWindow( } if (window.isClosable) { - Spacer(modifier = Modifier.width(4.dp)) + // Keep the destructive close target clear of the adjacent page controls. + if (!isSignalListWindow) Spacer(modifier = Modifier.width(12.dp)) Box( modifier = Modifier .size(20.dp) @@ -343,7 +382,7 @@ fun MdiSubWindow( // 단순 항목 선택 (그래프 창 자동 생성하지 않음) }, onCategoryClick = { categoryId -> - when (categoryId) { + val openedId = when (categoryId) { "category_mdb_signal" -> viewModel.mdiViewModel.openOrFocusCategoryWindow( type = MdiWindowType.MDB_SIGNAL_LIST, viewportScrollX = viewportScrollX, @@ -394,7 +433,9 @@ fun MdiSubWindow( viewportScrollX = viewportScrollX, viewportScrollY = viewportScrollY ) + else -> null } + openedId?.takeIf { it.isNotEmpty() }?.let(onRevealWindow) } ) } @@ -437,7 +478,7 @@ fun MdiSubWindow( onSignalDrop = { sig -> viewModel.handleUniversalSignalDrop( signal = DraggedSignal( - signalId = "can_${sig.idx}", + signalId = com.dct.app.domain.model.mdb.CanSignalKey.stable(sig), signalName = sig.name, colorLong = sig.color, unit = sig.unit, @@ -583,11 +624,14 @@ fun MdiSubWindow( MdiWindowType.GRAPH, MdiWindowType.MULTI_AXIS_GRAPH, MdiWindowType.CHART -> { val windowRenderer = remember(window.id) { viewModel.getChartRenderer(window.id) } val windowSnapshot by windowRenderer.renderSnapshotFlow.collectAsState() - var editingSeriesForYAxis by remember { mutableStateOf(null) } - var isYAxisDialogOpen by remember { mutableStateOf(false) } + DisposableEffect(windowRenderer) { + windowRenderer.setRenderingEnabled(true) + onDispose { windowRenderer.setRenderingEnabled(false) } + } var isRenameDialogOpen by remember { mutableStateOf(false) } GraphWindowContent( + onWidthChanged = { windowRenderer.setScreenWidth(it) }, onCrosshairTimeChange = { windowRenderer.setCrosshairTimeSec(it) }, snapshot = windowSnapshot, windowId = window.id, @@ -596,6 +640,17 @@ fun MdiSubWindow( onClearAll = { viewModel.clearChartSignals(window.id) }, onPanTime = { deltaSec -> windowRenderer.panTimeWindow(deltaSec) }, onZoomTime = { factor, anchor -> windowRenderer.zoomTimeWindow(factor, anchor) }, + onYAxisPan = if (window.type == MdiWindowType.MULTI_AXIS_GRAPH) { + { axis, fraction -> windowRenderer.panYAxis(axis, fraction.toDouble()) } + } else null, + onYAxisZoom = if (window.type == MdiWindowType.MULTI_AXIS_GRAPH) { + { axis, factor, anchor -> + windowRenderer.zoomYAxis(axis, factor.toDouble(), anchor.toDouble()) + } + } else null, + onResetYAxis = if (window.type == MdiWindowType.MULTI_AXIS_GRAPH) { + { axis -> windowRenderer.resetYAxis(axis) } + } else null, onResetPan = { windowRenderer.resetPan() }, onSignalDrop = { sig -> viewModel.handleUniversalSignalDrop( @@ -622,11 +677,20 @@ fun MdiSubWindow( windowRenderer.setTimeWindow(preset) }, onSelectXAxisManual = { + // Match GraphWnd.cpp: selecting Manual only + // disables auto-follow and keeps the current + // visible interval. windowRenderer.setXAxisManualMode() }, onSelectYAxisAuto = { windowRenderer.setYAxisAuto(true) }, + onSelectYAxisManual = { + // MultiXGraphWnd.cpp switches every configured + // axis to manual and does not show the + // single-axis min/max dialog. + windowRenderer.setYAxisAuto(false) + }, onOpenYAxisSetting = { series -> editingSeriesForYAxis = series isYAxisDialogOpen = true @@ -674,19 +738,10 @@ fun MdiSubWindow( } } MdiWindowType.ALPHANUMERICAL -> { - val currentDb = viewModel.uiState.collectAsState().value.mdbDatabase - val activeMdbSignals = currentDb?.allSignals ?: mdbSignals - val formulas by viewModel.formulas.collectAsState() - val sensorModules by viewModel.sensorModules.collectAsState() AlphanumericalWindowContent( windowId = window.id, - pinnedSignalIds = window.config.targetSignals, - liveTelemetryValues = liveTelemetryValues, - canSignals = canSignals, - mdbSignals = activeMdbSignals, - a2lSignals = a2lSignals, - formulas = formulas, - sensorModules = sensorModules, + selection = requireNotNull(alphaSelection), + telemetryValues = viewModel.liveTelemetryValues, isTileMode = isTileMode, hiddenSignalIds = hiddenAlphaSignals, highlightColors = highlightColors, @@ -710,8 +765,9 @@ fun MdiSubWindow( modifier = Modifier .align(Alignment.BottomEnd) .size(32.dp) - .pointerInput(window.id) { + .pointerInput(window.id, workspaceNavigation) { detectDragGestures( + onDragStart = { workspaceNavigation?.claimSinglePointerDrag() }, onDrag = { change, dragAmount -> change.consume() currentOnResize( diff --git a/Android/app/src/main/java/com/dct/app/presentation/monitor/components/SensorModuleWindowView.kt b/Android/app/src/main/java/com/dct/app/presentation/monitor/components/SensorModuleWindowView.kt index d22aa2f..26fd47c 100644 --- a/Android/app/src/main/java/com/dct/app/presentation/monitor/components/SensorModuleWindowView.kt +++ b/Android/app/src/main/java/com/dct/app/presentation/monitor/components/SensorModuleWindowView.kt @@ -7,6 +7,8 @@ import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.rememberLazyListState +import com.dct.app.presentation.signaltree.slimLazyListScrollbar import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CircleShape @@ -30,6 +32,7 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.dct.app.domain.model.project.DpcSensorModuleChannel import com.dct.app.domain.model.project.DpcSensorModuleConfig +import com.dct.app.core.theme.toComposeColor import com.dct.app.presentation.chart.DraggedSignal import com.dct.app.presentation.chart.SignalDragDropManager import com.dct.app.presentation.project.SensorModuleConfigDialog @@ -192,10 +195,13 @@ fun SensorModuleWindowContent( } } } else { + val tableListState = rememberLazyListState() LazyColumn( + state = tableListState, modifier = Modifier .fillMaxSize() .background(Color.White) + .slimLazyListScrollbar(tableListState) ) { itemsIndexed(allActiveChannels, key = { _, (mIdx, ch) -> "sm_${mIdx}_${ch.channelIndex}" }) { index, (mIdx, ch) -> var itemBounds by remember { mutableStateOf(androidx.compose.ui.geometry.Rect.Zero) } @@ -258,7 +264,7 @@ fun SensorModuleWindowContent( Box( modifier = Modifier .size(7.dp) - .background(Color(ch.color), RoundedCornerShape(2.dp)) + .background(ch.color.toComposeColor(), RoundedCornerShape(2.dp)) ) Spacer(modifier = Modifier.width(6.dp)) Text( @@ -280,7 +286,9 @@ fun SensorModuleWindowContent( fontWeight = FontWeight.Bold, color = if (isNc) Color(0xFF9E9E9E) else Color(0xFF0F172A), modifier = Modifier.width(70.dp).padding(horizontal = 6.dp), - maxLines = 1 + maxLines = 1, + overflow = TextOverflow.Ellipsis, + softWrap = false ) Box(modifier = Modifier.width(1.dp).fillMaxHeight().background(Color(0xFFE9ECEF))) @@ -290,7 +298,9 @@ fun SensorModuleWindowContent( fontSize = 11.sp, color = Color(0xFF495057), modifier = Modifier.width(45.dp).padding(horizontal = 6.dp), - maxLines = 1 + maxLines = 1, + overflow = TextOverflow.Ellipsis, + softWrap = false ) Box(modifier = Modifier.width(1.dp).fillMaxHeight().background(Color(0xFFE9ECEF))) @@ -301,7 +311,9 @@ fun SensorModuleWindowContent( fontWeight = FontWeight.SemiBold, color = if (ch.isVoltage) Color(0xFFB78103) else Color(0xFF198754), modifier = Modifier.width(85.dp).padding(horizontal = 6.dp), - maxLines = 1 + maxLines = 1, + overflow = TextOverflow.Ellipsis, + softWrap = false ) Box(modifier = Modifier.width(1.dp).fillMaxHeight().background(Color(0xFFE9ECEF))) @@ -313,7 +325,7 @@ fun SensorModuleWindowContent( Box( modifier = Modifier .size(10.dp) - .background(Color(ch.color), CircleShape) + .background(ch.color.toComposeColor(), CircleShape) .border(0.5.dp, Color(0xFFCED4DA), CircleShape) ) Spacer(modifier = Modifier.width(4.dp)) diff --git a/Android/app/src/main/java/com/dct/app/presentation/monitor/components/TwoFingerFling.kt b/Android/app/src/main/java/com/dct/app/presentation/monitor/components/TwoFingerFling.kt index db72750..57b98cf 100644 --- a/Android/app/src/main/java/com/dct/app/presentation/monitor/components/TwoFingerFling.kt +++ b/Android/app/src/main/java/com/dct/app/presentation/monitor/components/TwoFingerFling.kt @@ -6,6 +6,21 @@ import androidx.compose.ui.unit.Velocity /** Track the stable two-pointer centroid, never the jumping centroid of a remaining finger. */ internal class TwoFingerFling(private val maximumVelocity: Float) { + private companion object { + /** + * A fling must be based on a movement sample from the last pointer frame. + * Keeping this reasonable allows for slight touch report latency under 60-120Hz display refresh. + */ + const val MAX_SAMPLE_AGE_MILLIS = 100L + + /** + * ACTION_POINTER_UP and ACTION_UP are normally delivered in different frames. + * Allow a realistic hand-off window (up to 250ms) so natural human finger lift-off timing + * does not discard the already measured two-finger velocity. + */ + const val MAX_RELEASE_GAP_MILLIS = 250L + } + private val tracker = VelocityTracker() private var lastSampleTime: Long? = null private var releaseTime: Long? = null @@ -22,18 +37,28 @@ internal class TwoFingerFling(private val maximumVelocity: Float) { if (cancelled || releaseTime != null) return releaseTime = time val last = lastSampleTime ?: return - // Same stopped-pointer interval used by Compose's VelocityTracker (40 ms). - if (isPan && time - last in 0L..40L) { - val limit = Velocity(maximumVelocity, maximumVelocity) - val velocity = tracker.calculateVelocity(limit) - releaseVelocity = Velocity(-velocity.x, -velocity.y) // Content drag -> scroll direction. - } + // Do not resurrect velocity after the user has paused. This mirrors the + // one-finger scroll gesture's short stopped-pointer window. + if (!isPan || time < last || time - last > MAX_SAMPLE_AGE_MILLIS) return + + val limit = maximumVelocity.coerceAtLeast(0f) + val velocity = tracker.calculateVelocity(Velocity(limit, limit)) + // Content drag and ScrollState use opposite directions. Sanitize the + // platform result before handing it to the decay animation. + releaseVelocity = Velocity( + x = velocity.x.takeIf { it.isFinite() }?.let { -it } ?: 0f, + y = velocity.y.takeIf { it.isFinite() }?.let { -it } ?: 0f + ) } fun cancel() { cancelled = true; releaseVelocity = Velocity.Zero } fun finish(time: Long): Velocity { val released = releaseTime ?: return Velocity.Zero - return if (!cancelled && time - released in 0L..40L) releaseVelocity else Velocity.Zero + return if (!cancelled && time >= released && time - released <= MAX_RELEASE_GAP_MILLIS) { + releaseVelocity + } else { + Velocity.Zero + } } } 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 5e1fd96..62d76bd 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 @@ -1,23 +1,31 @@ package com.dct.app.presentation.monitor.components import androidx.compose.foundation.background +import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.* import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.* import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.text.PlatformTextStyle +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.dct.app.presentation.main.CommunicationType import com.dct.app.presentation.main.MainUiState import com.dct.app.presentation.main.MainViewModel +import com.dct.app.presentation.main.MeasurementRecordingState import com.dct.app.presentation.mdi.MdiWindowType +import com.dct.app.presentation.mdi.ALPHANUMERICAL_DEFAULT_WIDTH_DP import com.dct.app.domain.model.chart.InterpolationMode /** @@ -29,18 +37,47 @@ fun RibbonButton( tooltip: String, tint: Color = ColorSecondary, enabled: Boolean = true, + label: String? = null, onClick: () -> Unit = {} ) { + // Match the empty-canvas guide's icon + text chips without changing other tools. + if (label != null) { + TextButton( + onClick = onClick, + enabled = enabled, + modifier = Modifier.height(32.dp), + shape = RoundedCornerShape(4.dp), + border = BorderStroke(1.dp, ColorOutlineVariant), + contentPadding = PaddingValues(horizontal = 7.dp, vertical = 4.dp), + colors = ButtonDefaults.textButtonColors( + containerColor = Color(0xFFF1F5F9), + contentColor = tint, + disabledContentColor = ColorOutlineVariant.copy(alpha = 0.5f) + ) + ) { + Icon(icon, contentDescription = tooltip, modifier = Modifier.size(19.dp)) + Spacer(Modifier.width(4.dp)) + Text( + text = label, + fontSize = 11.sp, + fontWeight = FontWeight.SemiBold, + maxLines = 1, overflow = TextOverflow.Ellipsis, + softWrap = false, + style = TextStyle(platformStyle = PlatformTextStyle(includeFontPadding = false)) + ) + } + return + } IconButton( onClick = onClick, enabled = enabled, - modifier = Modifier.size(28.dp) + modifier = Modifier.size(32.dp) ) { Icon( imageVector = icon, contentDescription = tooltip, tint = if (enabled) tint else ColorOutlineVariant.copy(alpha = 0.5f), - modifier = Modifier.size(17.dp) + modifier = Modifier.size(19.dp) ) } } @@ -53,6 +90,8 @@ fun WorkspaceRibbonBar( viewModel: MainViewModel, mainUiState: MainUiState, isProjectLoaded: Boolean, + projectActionsEnabled: Boolean = true, + propertyProjectEnabled: Boolean = isProjectLoaded, isPortrait: Boolean, screenWidthDp: Float, currentScrollXDp: Float, @@ -63,17 +102,18 @@ fun WorkspaceRibbonBar( onNewProjectClick: () -> Unit, onPropertyProjectClick: () -> Unit, onWifiClick: () -> Unit, - onBluetoothClick: () -> Unit, onMaintenanceClick: () -> Unit, + onResetGraphDataClick: () -> Unit, onDisconnectClick: () -> Unit = { viewModel.disconnectCommunication() } ) { var showFileMenu by remember { mutableStateOf(false) } + val currentProject by viewModel.currentProject.collectAsState() Surface( color = Color.White, modifier = Modifier .fillMaxWidth() - .height(40.dp) + .height(44.dp) ) { Row( verticalAlignment = Alignment.CenterVertically, @@ -96,11 +136,12 @@ fun WorkspaceRibbonBar( modifier = Modifier.background(Color.White) ) { DropdownMenuItem( + enabled = projectActionsEnabled, text = { Text( text = "Open Project file", fontSize = 13.sp, - color = ColorTextPrimary, + color = if (projectActionsEnabled) ColorTextPrimary else ColorTextSecondary.copy(alpha = 0.5f), modifier = Modifier.padding(horizontal = 4.dp) ) }, @@ -108,7 +149,7 @@ fun WorkspaceRibbonBar( Icon( Icons.Default.FolderOpen, contentDescription = "Open", - tint = ColorSecondary, + tint = if (projectActionsEnabled) ColorSecondary else ColorOutlineVariant, modifier = Modifier.size(16.dp) ) }, @@ -166,12 +207,12 @@ fun WorkspaceRibbonBar( ) HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp), color = ColorOutlineVariant.copy(alpha = 0.5f)) DropdownMenuItem( - enabled = isProjectLoaded, + enabled = propertyProjectEnabled, text = { Text( text = "Property for project", fontSize = 13.sp, - color = if (isProjectLoaded) ColorTextPrimary else ColorTextSecondary.copy(alpha = 0.5f), + color = if (propertyProjectEnabled) ColorTextPrimary else ColorTextSecondary.copy(alpha = 0.5f), modifier = Modifier.padding(horizontal = 4.dp) ) }, @@ -179,7 +220,7 @@ fun WorkspaceRibbonBar( Icon( Icons.Default.Tune, contentDescription = "Property", - tint = if (isProjectLoaded) ColorSecondary else ColorOutlineVariant, + tint = if (propertyProjectEnabled) ColorSecondary else ColorOutlineVariant, modifier = Modifier.size(16.dp) ) }, @@ -189,6 +230,29 @@ fun WorkspaceRibbonBar( } ) HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp), color = ColorOutlineVariant.copy(alpha = 0.5f)) + DropdownMenuItem( + text = { + Text( + text = "Reset graph data", + fontSize = 13.sp, + color = ColorTextPrimary, + modifier = Modifier.padding(horizontal = 4.dp) + ) + }, + leadingIcon = { + Icon( + Icons.Default.Clear, + contentDescription = "Reset graph data", + tint = ColorSecondary, + modifier = Modifier.size(16.dp) + ) + }, + onClick = { + showFileMenu = false + onResetGraphDataClick() + } + ) + HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp), color = ColorOutlineVariant.copy(alpha = 0.5f)) Text( text = "Graph Type", fontSize = 12.sp, @@ -207,6 +271,76 @@ fun WorkspaceRibbonBar( } ) } + HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp), color = ColorOutlineVariant.copy(alpha = 0.5f)) + Text( + text = "Measurement Recording", + fontSize = 12.sp, + color = ColorTextSecondary, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 6.dp) + ) + DropdownMenuItem( + enabled = mainUiState.recordingState != MeasurementRecordingState.RECORDING, + text = { Text("Write CSV", fontSize = 13.sp, color = ColorTextPrimary) }, + leadingIcon = { + Checkbox(checked = currentProject?.writeCsv == true, onCheckedChange = null) + }, + onClick = { + viewModel.setMeasurementRecordingFormats( + writeCsv = currentProject?.writeCsv != true, + writeMf4 = currentProject?.writeMf4 == true + ) + } + ) + DropdownMenuItem( + enabled = mainUiState.recordingState != MeasurementRecordingState.RECORDING, + text = { Text("Write MF4", fontSize = 13.sp, color = ColorTextPrimary) }, + leadingIcon = { + Checkbox(checked = currentProject?.writeMf4 == true, onCheckedChange = null) + }, + onClick = { + viewModel.setMeasurementRecordingFormats( + writeCsv = currentProject?.writeCsv == true, + writeMf4 = currentProject?.writeMf4 != true + ) + } + ) + Text( + text = "계측과 함께 측정", + fontSize = 13.sp, + color = ColorTextPrimary, + modifier = Modifier.padding(start = 16.dp, top = 8.dp, bottom = 2.dp) + ) + listOf(true to "On", false to "Off").forEach { (enabled, label) -> + DropdownMenuItem( + text = { + Text( + text = label, + fontSize = 13.sp, + color = ColorTextPrimary, + modifier = Modifier.padding(start = 12.dp) + ) + }, + leadingIcon = { + RadioButton( + selected = currentProject?.autostartMeasuringRecording == enabled, + onClick = null + ) + }, + onClick = { + viewModel.setAutostartMeasuringRecording(enabled) + showFileMenu = false + } + ) + } + currentProject?.measuredFilesFolder?.takeIf { it.isNotBlank() }?.let { folder -> + Text( + text = "Folder: $folder", + fontSize = 10.sp, + color = ColorTextSecondary, + maxLines = 2, + modifier = Modifier.widthIn(max = 300.dp).padding(horizontal = 16.dp, vertical = 6.dp) + ) + } } } @@ -216,11 +350,13 @@ fun WorkspaceRibbonBar( RibbonButton( icon = Icons.Default.NoteAdd, tooltip = "New Project", + enabled = projectActionsEnabled, onClick = onNewProjectClick ) RibbonButton( icon = Icons.Default.FolderOpen, tooltip = "Open", + enabled = projectActionsEnabled, onClick = onOpenProjectClick ) RibbonButton( @@ -232,55 +368,78 @@ fun WorkspaceRibbonBar( VerticalDivider(modifier = Modifier.height(20.dp).padding(horizontal = 4.dp), color = ColorOutlineVariant) - // Group 2: Disconnect / Wifi / Bluetooth (통신 버튼) + // Group 2: 통신 연결 val isCommConnected = mainUiState.isConnected && mainUiState.communicationType != CommunicationType.NONE + val p2pState by viewModel.wifiDirectState.collectAsState() val isWifiConnected = isCommConnected && mainUiState.communicationType == CommunicationType.WIFI - val isBtConnected = isCommConnected && mainUiState.communicationType == CommunicationType.BLUETOOTH RibbonButton( icon = Icons.Default.LinkOff, tooltip = "통신 끊기 (Disconnect)", - enabled = isCommConnected, + enabled = isCommConnected || p2pState.link != null, tint = if (isCommConnected) Color(0xFFEF4444) else ColorSecondary, onClick = onDisconnectClick ) RibbonButton( icon = Icons.Default.Wifi, - tooltip = if (isWifiConnected) "Wi-Fi Direct 연결됨 (${mainUiState.connectedDeviceName})" else "Wi-Fi Direct", - enabled = (!isCommConnected || isWifiConnected), + 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), tint = if (isWifiConnected) Color(0xFF2563EB) else ColorSecondary, onClick = onWifiClick ) - RibbonButton( - icon = Icons.Default.Bluetooth, - tooltip = if (isBtConnected) "Bluetooth 연결됨 (${mainUiState.connectedDeviceName})" else "Bluetooth 연결", - enabled = (!isCommConnected || isBtConnected), - tint = if (isBtConnected) Color(0xFF2563EB) else ColorSecondary, - onClick = onBluetoothClick - ) - VerticalDivider(modifier = Modifier.height(20.dp).padding(horizontal = 4.dp), color = ColorOutlineVariant) - // Group 3: Measure Control (통신 버튼 바로 옆 재생 / 멈춤 - 통신 연결 시에만 활성화) + // Group 3: 실시간 신호 재생(계측)과 파일 기록을 별도 조작으로 표시한다. val isMeasuring = mainUiState.isMeasuring val canStartMeasure = isCommConnected && !isMeasuring val canStopMeasure = isCommConnected && isMeasuring + Text("신호 재생", fontSize = 12.sp, color = ColorTextSecondary) RibbonButton( icon = Icons.Default.PlayArrow, - tooltip = if (isCommConnected) "계측 시작 (Start Measure)" else "통신 연결 필요 (Connect required)", + tooltip = if (isCommConnected) "신호 재생 시작 (계측 시작)" else "신호 재생: 통신 연결 필요", enabled = canStartMeasure, tint = if (canStartMeasure) Color(0xFF16A34A) else ColorSecondary.copy(alpha = 0.4f), onClick = { viewModel.startMeasure() } ) RibbonButton( icon = Icons.Default.Stop, - tooltip = if (isCommConnected) "계측 정지 (Stop Measure)" else "통신 연결 필요 (Connect required)", + tooltip = if (isCommConnected) "신호 재생 정지 (계측 정지)" else "신호 재생: 통신 연결 필요", enabled = canStopMeasure, tint = if (canStopMeasure) Color(0xFFDC2626) else ColorSecondary.copy(alpha = 0.4f), onClick = { viewModel.stopMeasure() } ) - // 유지보수 기능 UI 노출 플래그 (코드는 보존하고 UI만 임시 숨김 처리) + VerticalDivider(modifier = Modifier.height(20.dp).padding(horizontal = 4.dp), color = ColorOutlineVariant) + Text("신호 기록", fontSize = 12.sp, color = ColorTextSecondary) + val recordingState = mainUiState.recordingState + val canStartRecording = isCommConnected && isMeasuring && + recordingState !in setOf( + MeasurementRecordingState.STARTING, + MeasurementRecordingState.RECORDING, + MeasurementRecordingState.STOPPING + ) && (currentProject?.writeCsv == true || currentProject?.writeMf4 == true) + val canStopRecording = recordingState == MeasurementRecordingState.RECORDING + RibbonButton( + icon = Icons.Default.FiberManualRecord, + tooltip = when { + !isCommConnected -> "신호 기록: 통신 연결 필요" + !isMeasuring -> "신호 기록: 계측 시작 필요" + recordingState == MeasurementRecordingState.RECORDING -> "신호 기록 중" + else -> "신호 기록 시작" + }, + enabled = canStartRecording, + tint = Color(0xFFDC2626), + onClick = { viewModel.startMeasurementRecording() } + ) + RibbonButton( + icon = Icons.Default.Stop, + tooltip = if (canStopRecording) "신호 기록 중지" else "신호 기록 중이 아닙니다", + enabled = canStopRecording, + tint = Color(0xFF374151), + onClick = { viewModel.stopMeasurementRecording() } + ) + // 유지보수 기능은 구현 상태로 보존하되 사용자 요구에 따라 UI 진입은 숨긴다. val enableMaintenanceUi = false if (enableMaintenanceUi) { RibbonButton( @@ -298,6 +457,7 @@ fun WorkspaceRibbonBar( RibbonButton( icon = Icons.Default.BarChart, tooltip = "New Graph Window", + label = "Graph", onClick = { val defaultW = if (isPortrait) (screenWidthDp - 24f).coerceIn(280f, 440f) else 480f val defaultH = if (isPortrait) 280f else 320f @@ -312,8 +472,16 @@ fun WorkspaceRibbonBar( RibbonButton( icon = Icons.Default.GridView, tooltip = "Alphanumerical", + label = "Alphanumerical", onClick = { - val defaultW = if (isPortrait) (screenWidthDp - 24f).coerceIn(280f, 440f) else 380f + // 120dp 타일 3개 + 간격/여백(약 24dp)을 수용하는 기본 폭. + // 세로 모드에서는 작은 화면에서 창이 화면 밖으로 밀리지 않도록 + // 뷰포트 폭을 우선한다(400dp 이상인 태블릿에서는 3열이 유지됨). + val defaultW = if (isPortrait) { + (screenWidthDp - 24f).coerceIn(280f, 440f) + } else { + ALPHANUMERICAL_DEFAULT_WIDTH_DP + } val defaultH = if (isPortrait) 300f else 340f viewModel.addAlphanumericalWindow( defaultWidth = defaultW, @@ -326,6 +494,7 @@ fun WorkspaceRibbonBar( RibbonButton( icon = Icons.Default.OpenWith, tooltip = "MultiAxisGraph", + label = "MultiAxisGraph", onClick = { val defaultW = if (isPortrait) (screenWidthDp - 24f).coerceIn(280f, 440f) else 480f val defaultH = if (isPortrait) 280f else 320f diff --git a/Android/app/src/main/java/com/dct/app/presentation/monitor/components/WorkspaceTouchNavigation.kt b/Android/app/src/main/java/com/dct/app/presentation/monitor/components/WorkspaceTouchNavigation.kt index bf2e1cd..0bd5801 100644 --- a/Android/app/src/main/java/com/dct/app/presentation/monitor/components/WorkspaceTouchNavigation.kt +++ b/Android/app/src/main/java/com/dct/app/presentation/monitor/components/WorkspaceTouchNavigation.kt @@ -2,6 +2,8 @@ package com.dct.app.presentation.monitor.components import androidx.compose.foundation.MutatePriority import androidx.compose.foundation.ScrollState +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.gestures.awaitEachGesture import androidx.compose.foundation.gestures.awaitFirstDown import androidx.compose.foundation.gestures.FlingBehavior @@ -14,9 +16,13 @@ import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.staticCompositionLocalOf import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Rect import androidx.compose.ui.input.pointer.PointerEventPass import androidx.compose.ui.input.pointer.PointerType import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.input.pointer.positionChange +import androidx.compose.ui.input.pointer.positionChangeIgnoreConsumed +import androidx.compose.ui.input.pointer.util.VelocityTracker import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.layout.positionInRoot import androidx.compose.ui.unit.Velocity @@ -33,17 +39,115 @@ internal class WorkspaceTouchNavigation { private set var hadMultiplePointers = false private set + var singlePointerDragClaimed = false + private set + /** Keep committed object edits (move/resize) intact, but allow scroll -> two-finger pan. */ + fun claimSinglePointerDrag() { + if (!ownsTouches && !hadMultiplePointers) singlePointerDragClaimed = true + } + /** + * A child graph may reserve a two-finger gesture before the workspace + * navigator captures it. The callback receives root-space pointer points + * and returns true only when the complete pair belongs to that graph's + * axis gesture. + */ + var axisGestureReservation: ((List) -> Boolean)? = null + /** Reserve one-finger child gestures (graph plot/axis) from workspace pan. */ + var singlePointerGestureReservation: ((Offset) -> Boolean)? = null + /** + * Window body hit regions are persistent for the lifetime of a composed MDI + * window. A gesture that starts inside one of these regions belongs to the + * window (or one of its nested scrollables), never to the page canvas. + * + * This is kept separately from the per-gesture graph reservation above: + * graph callbacks are installed while a graph gesture is being resolved, + * whereas every MDI window must reserve its whole surface before the first + * pointer event arrives. + */ + private val windowTouchRegions = LinkedHashMap Rect>() + + fun registerWindowTouchRegion(windowId: String, bounds: () -> Rect) { + windowTouchRegions[windowId] = bounds + } + + fun unregisterWindowTouchRegion(windowId: String) { + windowTouchRegions.remove(windowId) + } + + fun isWindowTouchReserved(rootPoint: Offset): Boolean = + windowTouchRegions.values.any { provider -> provider().contains(rootPoint) } + + fun isWindowTouchReserved(rootPoints: List): Boolean = + rootPoints.any(::isWindowTouchReserved) + + /** Includes persistent MDI regions and the active graph-specific claim. */ + fun isSinglePointerReserved(rootPoint: Offset): Boolean = + isWindowTouchReserved(rootPoint) || singlePointerGestureReservation?.invoke(rootPoint) == true fun observePointerCount(count: Int) { if (count > 1) hadMultiplePointers = true } var zoomAtRoot: ((Float, Offset) -> Unit)? = null var canZoomAtRoot: ((Offset) -> Boolean)? = null fun capture() { ownsTouches = true } - fun reset() { ownsTouches = false; hadMultiplePointers = false; zoomAtRoot = null; canZoomAtRoot = null } + fun reset() { + ownsTouches = false + hadMultiplePointers = false + singlePointerDragClaimed = false + zoomAtRoot = null + canZoomAtRoot = null + axisGestureReservation = null + singlePointerGestureReservation = null + } } internal val LocalWorkspaceTouchNavigation = staticCompositionLocalOf { null } +/** + * Consumes only the pointer delta left after a window's child content has had + * a chance to scroll. It prevents the outer page scroll modifiers from + * receiving a drag that started inside an MDI window while preserving nested + * list/table/graph scrolling and direct header/resize drags. + */ +internal fun Modifier.windowTouchScrollBarrier(key: Any): Modifier = + pointerInput(key) { + awaitEachGesture { + val down = awaitFirstDown(requireUnconsumed = false, pass = PointerEventPass.Initial) + var pastSlop = false + while (true) { + val event = awaitPointerEvent(PointerEventPass.Main) + val pointer = event.changes.firstOrNull { it.id == down.id } + val distance = pointer?.let { it.position - down.position } ?: Offset.Zero + // Consuming sub-slop motion cancels child drag detectors in + // their Final pass before they can recognise a slow drag. + // Wait until an axis crosses slop; children get Main first. + if (kotlin.math.abs(distance.x) > viewConfiguration.touchSlop || + kotlin.math.abs(distance.y) > viewConfiguration.touchSlop) pastSlop = true + event.changes.forEach { change -> + if (pastSlop && change.pressed && change.positionChange() != Offset.Zero) { + change.consume() + } + } + if (event.changes.none { it.pressed }) break + } + } + } + +/** Empty pages must keep finite viewport constraints and leave guide button taps untouched. */ +@Composable +internal fun Modifier.workspaceCanvasScroll( + enabled: Boolean, + navigation: WorkspaceTouchNavigation, + horizontal: ScrollState, + vertical: ScrollState, + horizontalFling: FlingBehavior = ScrollableDefaults.flingBehavior(), + verticalFling: FlingBehavior = ScrollableDefaults.flingBehavior(), + isDragActive: () -> Boolean = { false } +): Modifier = if (enabled) { + workspaceTouchNavigation(navigation, horizontal, vertical, horizontalFling, verticalFling, isDragActive) + .horizontalScroll(horizontal, flingBehavior = horizontalFling) + .verticalScroll(vertical, flingBehavior = verticalFling) +} else this + /** Must be outside both scroll modifiers, so pointer positions do not move with the content. */ @Composable internal fun Modifier.workspaceTouchNavigation( @@ -65,21 +169,83 @@ internal fun Modifier.workspaceTouchNavigation( navigation.reset() var scrollLock: Job? = null val fling = TwoFingerFling(viewConfiguration.maximumFlingVelocity) + val singleFling = OneFingerFling(viewConfiguration.maximumFlingVelocity) + var singlePanStarted = false + var singleSupplementHorizontal = false + var singleSupplementVertical = false + var pendingSingleDelta = Offset.Zero + + fun dispatchSingleDelta( + delta: Offset + ) { + if (delta == Offset.Zero) return + // Canvas touch motion has one owner. ScrollState changes are + // asynchronous and cannot identify consumption of this event. + if (delta.x != 0f) { + horizontal.dispatchRawDelta(-delta.x) + singleSupplementHorizontal = true + } + if (delta.y != 0f) { + vertical.dispatchRawDelta(-delta.y) + singleSupplementVertical = true + } + } + try { val down = awaitFirstDown(false, PointerEventPass.Initial) flingJob?.cancel() // Any new press, including over a child window, stops inertia. navigation.observePointerCount(currentEvent.changes.count { it.pressed }) - var blocked = down.type != PointerType.Touch + // Capture the hit target at DOWN. If the finger later + // crosses a window boundary, it must not hand the same + // gesture over to the canvas halfway through. + val downRootPoint = origin[0] + down.position + val windowReservedAtDown = navigation.isWindowTouchReserved(downRootPoint) + val singleReservedAtDown = navigation.isSinglePointerReserved(downRootPoint) + var blocked = down.type != PointerType.Touch || windowReservedAtDown + // A window reserves a single-finger sequence for its own + // scrollable content, but it must not permanently veto a + // later two-finger page-pan. Keep the reasons that make a + // two-finger takeover unsafe separate from the ordinary + // single-finger window reservation. + var disallowTwoFingerCapture = down.type != PointerType.Touch var gesture: TwoFingerGesture? = null var pairIds: List? = null + var remainingFingerOrigin: Offset? = null + var firstReleaseTime: Long? = null while (true) { val event = awaitPointerEvent(PointerEventPass.Initial) val pressed = event.changes.filter { it.pressed } // Shared history also covers fingers landing on different window hit paths. navigation.observePointerCount(pressed.size) if (!navigation.ownsTouches) { - if (currentIsDragActive() || pressed.any { it.type != PointerType.Touch }) blocked = true - if (!blocked && pressed.size == 2) { + val directEditActive = currentIsDragActive() || navigation.singlePointerDragClaimed + if (directEditActive || pressed.any { it.type != PointerType.Touch }) { + blocked = true + disallowTwoFingerCapture = true + } + val rootPoints = pressed.map { origin[0] + it.position } + // A graph Y-axis may own a pair only when both + // pointers remain on that axis. The MDI window + // surface itself only blocks one-finger fallback; + // a pair over a window is the page-pan gesture. + val axisReserved = pressed.size == 2 && + navigation.axisGestureReservation?.invoke(rootPoints) == true + if (axisReserved) { + // Let the owning graph consume the pair. Mark + // this workspace gesture blocked so it cannot + // later steal the same touch sequence. + blocked = true + disallowTwoFingerCapture = true + } else if (pressed.size == 2 && !disallowTwoFingerCapture && + (!blocked || windowReservedAtDown)) { + // A staggered one-finger drag is promoted to + // the real two-finger navigator. Do not retain + // one-finger supplement/fling state. + singleFling.cancel() + singlePanStarted = false + pendingSingleDelta = Offset.Zero + singleSupplementHorizontal = false + singleSupplementVertical = false navigation.capture() pairIds = pressed.map { it.id } val canZoom = navigation.zoomAtRoot != null && pressed.all { @@ -87,22 +253,49 @@ internal fun Modifier.workspaceTouchNavigation( } gesture = TwoFingerGesture(viewConfiguration.touchSlop, canZoom) // Cancel an earlier canvas fling and reserve both axes for this gesture. - scrollLock = scope.launch(start = CoroutineStart.UNDISPATCHED) { - horizontal.scroll(MutatePriority.UserInput) { - vertical.scroll(MutatePriority.UserInput) { awaitCancellation() } + if (scrollLock == null) { + scrollLock = scope.launch(start = CoroutineStart.UNDISPATCHED) { + horizontal.scroll(MutatePriority.UserInput) { + vertical.scroll(MutatePriority.UserInput) { awaitCancellation() } + } } } - } else if (pressed.size > 2) blocked = true + } else if (pressed.size > 2) { + blocked = true + disallowTwoFingerCapture = true + } } if (navigation.ownsTouches) { - if (pressed.size != 2 || pressed.map { it.id } != pairIds) { - val originalPair = event.changes.filter { it.id in pairIds.orEmpty() } - val normalRelease = pressed.size < 2 && originalPair.any { it.previousPressed && !it.pressed } - if (gesture?.mode != TwoFingerGesture.Mode.Ended && normalRelease) { - fling.release(event.changes.maxOf { it.uptimeMillis }, gesture?.mode == TwoFingerGesture.Mode.Pan) - } else if (pressed.any { it.id !in pairIds.orEmpty() } || pressed.size > 2 || - pressed.any { it.previousPressed && it.position != it.previousPosition }) { + val expectedPair = pairIds.orEmpty().toSet() + // MotionEvent pointer indices are not guaranteed to keep the + // same order on every device. Compare ids as a set so a + // harmless index reorder cannot cancel a two-finger fling. + val isOriginalPair = pressed.size == 2 && pressed.map { it.id }.toSet() == expectedPair + if (!isOriginalPair) { + val originalPair = event.changes.filter { it.id in expectedPair } + val releasedPair = originalPair.any { it.previousPressed && !it.pressed } + val unexpectedPointer = pressed.any { it.id !in expectedPair } || pressed.size > 2 + val normalRelease = pressed.size < 2 && releasedPair + if (unexpectedPointer) { fling.cancel() + } else if (gesture?.mode != TwoFingerGesture.Mode.Ended && normalRelease) { + // The first UP may include movement of the other finger. + // Freeze the two-finger speed before considering the tail. + val releaseTime = event.changes.maxOf { it.uptimeMillis } + fling.release(releaseTime, gesture?.mode == TwoFingerGesture.Mode.Pan) + remainingFingerOrigin = pressed.singleOrNull()?.position + firstReleaseTime = releaseTime + } else if (remainingFingerOrigin != null) { + val currentTime = event.changes.maxOf { it.uptimeMillis } + val releaseTime = firstReleaseTime ?: currentTime + val origin = remainingFingerOrigin!! + val remainingMovement = originalPair.any { it.previousPressed && + (it.position - origin).getDistance() > viewConfiguration.touchSlop * 2.5f } + // If the remaining finger lingers beyond the lift-off window or moves deliberately far, cancel fling. + if ((currentTime - releaseTime > 120L && remainingMovement) || + originalPair.any { (it.position - origin).getDistance() > viewConfiguration.touchSlop * 5f }) { + fling.cancel() + } } gesture?.end() // No pointer handoff or one-finger tail after a pinch/pan. } else { @@ -121,19 +314,80 @@ internal fun Modifier.workspaceTouchNavigation( // Initial pass wins over nested tables, buttons, headers and graph tap handlers. event.changes.forEach { it.consume() } } + // Consume blank-canvas motion before either native axis + // can enqueue it. Leave window/graph sequences untouched. + if (!navigation.ownsTouches && !blocked && !singleReservedAtDown && + navigation.singlePointerGestureReservation?.invoke(downRootPoint) != true && + pressed.size <= 1 && !navigation.hadMultiplePointers && + !navigation.singlePointerDragClaimed && !currentIsDragActive()) { + event.changes.forEach { if (it.positionChangeIgnoreConsumed() != Offset.Zero) it.consume() } + } + // Apply each canvas delta once, in viewport pixels. val finalEvent = awaitPointerEvent(PointerEventPass.Final) - if (!navigation.ownsTouches && finalEvent.changes.any { - it.isConsumed && it.pressed && it.previousPressed && it.position != it.previousPosition - }) blocked = true // Preserve an already established single-finger drag. + val singlePointer = event.changes.singleOrNull { it.pressed || it.previousPressed } + val singleReserved = singlePointer != null && ( + // Window bounds move with the canvas. Never re-hit-test + // them against the old DOWN point midway through a pan. + // Only the graph on the original hit path may register + // a late gesture reservation after the Initial DOWN. + singleReservedAtDown || navigation.singlePointerGestureReservation?.invoke(downRootPoint) == true + ) + if (!navigation.ownsTouches && !blocked && pressed.size <= 1 && + !singleReserved && !navigation.singlePointerDragClaimed && !currentIsDragActive()) { + val rawDelta = singlePointer?.positionChangeIgnoreConsumed() ?: Offset.Zero + if (singlePointer != null && singlePointer.pressed) { + singleFling.add(singlePointer.uptimeMillis, singlePointer.position) + if (!singlePanStarted) pendingSingleDelta += rawDelta + if (!singlePanStarted && pendingSingleDelta.getDistance() > viewConfiguration.touchSlop) { + singlePanStarted = true + val delta = pendingSingleDelta + pendingSingleDelta = Offset.Zero + dispatchSingleDelta(delta) + finalEvent.changes.forEach { it.consume() } + } else if (singlePanStarted && rawDelta != Offset.Zero) { + dispatchSingleDelta(rawDelta) + finalEvent.changes.forEach { it.consume() } + } + } else if (singlePointer != null && singlePointer.previousPressed && singlePanStarted) { + // Include the final pointer movement before + // releasing so diagonal drags do not lose the + // last frame of one axis. + dispatchSingleDelta(rawDelta) + singleFling.add(singlePointer.uptimeMillis, singlePointer.position) + finalEvent.changes.forEach { it.consume() } + } + } if (pressed.isEmpty()) { val velocity = fling.finish(event.changes.maxOf { it.uptimeMillis }) if (velocity != Velocity.Zero) { val lock = scrollLock + scrollLock = null flingJob = scope.launch(start = CoroutineStart.UNDISPATCHED) { lock?.cancelAndJoin() flingWorkspace(horizontal, vertical, horizontalFling, verticalFling, velocity) } } + if (singlePanStarted && !navigation.hadMultiplePointers) { + val singleVelocity = singleFling.finish(finalEvent.changes.maxOf { it.uptimeMillis }) + if (singleVelocity != Velocity.Zero && + (singleSupplementHorizontal || singleSupplementVertical)) { + val velocity = Velocity( + x = if (singleSupplementHorizontal) singleVelocity.x else 0f, + y = if (singleSupplementVertical) singleVelocity.y else 0f + ) + flingJob = scope.launch(start = CoroutineStart.UNDISPATCHED) { + flingWorkspaceAxes( + horizontal, + vertical, + horizontalFling, + verticalFling, + velocity, + singleSupplementHorizontal, + singleSupplementVertical + ) + } + } + } break } } @@ -152,7 +406,44 @@ internal fun Modifier.workspaceTouchNavigation( internal suspend fun flingWorkspace( horizontal: ScrollState, vertical: ScrollState, horizontalFling: FlingBehavior, verticalFling: FlingBehavior, velocity: Velocity +) = flingWorkspaceAxes(horizontal, vertical, horizontalFling, verticalFling, velocity, true, true) + +/** Fling only the axes supplemented by the diagonal one-finger fallback. */ +internal suspend fun flingWorkspaceAxes( + horizontal: ScrollState, + vertical: ScrollState, + horizontalFling: FlingBehavior, + verticalFling: FlingBehavior, + velocity: Velocity, + flingHorizontal: Boolean, + flingVertical: Boolean ) = coroutineScope { - launch { horizontal.scroll { with(horizontalFling) { performFling(velocity.x) } } } - launch { vertical.scroll { with(verticalFling) { performFling(velocity.y) } } } + if (flingHorizontal) launch { horizontal.scroll { with(horizontalFling) { performFling(velocity.x) } } } + if (flingVertical) launch { vertical.scroll { with(verticalFling) { performFling(velocity.y) } } } +} + +/** Velocity tracker for the axes completed by workspace diagonal scrolling. */ +private class OneFingerFling(private val maximumVelocity: Float) { + private val tracker = VelocityTracker() + private var lastSampleTime: Long? = null + private var cancelled = false + + fun add(time: Long, position: Offset) { + if (cancelled) return + tracker.addPosition(time, position) + lastSampleTime = time + } + + fun cancel() { cancelled = true } + + fun finish(time: Long): Velocity { + val last = lastSampleTime ?: return Velocity.Zero + if (cancelled || time < last || time - last > 100L) return Velocity.Zero + val limit = maximumVelocity.coerceAtLeast(0f) + val velocity = tracker.calculateVelocity(Velocity(limit, limit)) + return Velocity( + x = velocity.x.takeIf { it.isFinite() }?.let { -it } ?: 0f, + y = velocity.y.takeIf { it.isFinite() }?.let { -it } ?: 0f + ) + } } diff --git a/Android/app/src/main/java/com/dct/app/presentation/project/NewProjectDialog.kt b/Android/app/src/main/java/com/dct/app/presentation/project/NewProjectDialog.kt index b8e4177..67428eb 100644 --- a/Android/app/src/main/java/com/dct/app/presentation/project/NewProjectDialog.kt +++ b/Android/app/src/main/java/com/dct/app/presentation/project/NewProjectDialog.kt @@ -16,8 +16,12 @@ import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color +import androidx.compose.ui.semantics.Role import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.compose.ui.window.Dialog @@ -44,6 +48,8 @@ fun NewProjectDialog( onProjectChange: (DpcProject) -> Unit = {}, onDismiss: () -> Unit, onConfirm: (DpcProject) -> Unit, + onApply: (DpcProject) -> Unit = {}, + projectFileNameReadOnly: Boolean = false, onPickProjectFile: () -> Unit = {}, onPickMdbFile: () -> Unit = {}, onPickA2lFile: () -> Unit = {}, @@ -52,6 +58,15 @@ fun NewProjectDialog( ) { var showSmSubDialog by remember { mutableStateOf(false) } var showDidSubDialog by remember { mutableStateOf(false) } + var pendingWarning by remember { mutableStateOf(null) } + var pendingAction by remember { mutableStateOf<(() -> Unit)?>(null) } + val isPropertyDialog = dialogTitle == "Property for project" + fun confirmSourceChange(kind: String, existing: Boolean, action: () -> Unit) { + if (isPropertyDialog && existing) { + pendingWarning = "현재 $kind 파일을 닫고 변경하시겠습니까?" + pendingAction = action + } else action() + } Dialog( onDismissRequest = onDismiss, @@ -155,6 +170,7 @@ fun NewProjectDialog( onProjectChange(project.copy(projectFileName = input)) }, placeholder = "", + readOnly = projectFileNameReadOnly, modifier = Modifier.weight(1f) ) Button( @@ -186,7 +202,13 @@ fun NewProjectDialog( path = project.mdbFileName, placeholder = "", enabled = !isA2lSelected, - onPick = onPickMdbFile + onPick = { confirmSourceChange("MDB", isMdbSelected, onPickMdbFile) }, + onRemove = if (isPropertyDialog && isMdbSelected) ({ + pendingWarning = "MDB 파일을 프로젝트에서 제거하시겠습니까?" + pendingAction = { onProjectChange(project.copy( + mdbFileName = "", mdbFileUri = null, localMdbPath = null + )) } + }) else null ) } @@ -196,7 +218,13 @@ fun NewProjectDialog( path = project.a2lFileName, placeholder = "", enabled = !isMdbSelected, - onPick = onPickA2lFile + onPick = { confirmSourceChange("A2L", isA2lSelected, onPickA2lFile) }, + onRemove = if (isPropertyDialog && isA2lSelected) ({ + pendingWarning = "A2L 파일을 프로젝트에서 제거하시겠습니까?" + pendingAction = { onProjectChange(project.copy( + a2lFileName = "", a2lFileUri = null, localA2lPath = null + )) } + }) else null ) } @@ -205,7 +233,13 @@ fun NewProjectDialog( DialogPathField( path = project.canDbcFileName, placeholder = "", - onPick = onPickDbcFile + onPick = { confirmSourceChange("DBC", project.canDbcFileName.isNotBlank(), onPickDbcFile) }, + onRemove = if (isPropertyDialog && project.canDbcFileName.isNotBlank()) ({ + pendingWarning = "DBC 파일을 프로젝트에서 제거하시겠습니까?" + pendingAction = { onProjectChange(project.copy( + canDbcFileName = "", canDbcFileUri = null, localCanDbcPath = null + )) } + }) else null ) } @@ -214,7 +248,7 @@ fun NewProjectDialog( val defaultMeasured = remember { com.dct.app.data.repository.DpcProjectManager.getDefaultMeasuredFolder() } val displayFolder = project.measuredFilesFolder.ifEmpty { defaultMeasured } DialogPathField( - path = displayFolder, + path = readableMeasuredFolder(displayFolder), placeholder = "", onPick = onPickFolder ) @@ -370,7 +404,18 @@ fun NewProjectDialog( (project.a2lFileUri != null || project.a2lFileName.isNotBlank()) || (project.canDbcFileUri != null || project.canDbcFileName.isNotBlank()) val hasProjectName = project.projectFileUri != null || project.projectFileName.isNotBlank() - val isOkEnabled = project.vehicleCode.isNotBlank() && hasProjectName && hasDefinitionFile + val isOkEnabled = project.vehicleCode.isNotBlank() && hasProjectName && + (hasDefinitionFile || isPropertyDialog) + + if (isPropertyDialog) { + OutlinedButton( + onClick = { if (isOkEnabled) onApply(project) }, + enabled = isOkEnabled, + modifier = Modifier.width(84.dp).height(32.dp), + contentPadding = PaddingValues(0.dp) + ) { Text("Apply", fontSize = 13.sp) } + Spacer(modifier = Modifier.width(12.dp)) + } Button( onClick = { @@ -403,6 +448,25 @@ fun NewProjectDialog( } } + if (pendingWarning != null) { + AlertDialog( + onDismissRequest = { pendingWarning = null; pendingAction = null }, + title = { Text("파일 변경 확인") }, + text = { Text(pendingWarning.orEmpty()) }, + confirmButton = { + TextButton(onClick = { + val action = pendingAction + pendingWarning = null + pendingAction = null + action?.invoke() + }) { Text("확인") } + }, + dismissButton = { + TextButton(onClick = { pendingWarning = null; pendingAction = null }) { Text("취소") } + } + ) + } + if (showSmSubDialog) { SensorModuleConfigDialog( initialModules = project.sensorModules, @@ -666,26 +730,80 @@ private fun CustomDialogInputField( ) } +/** Presentation only: retain the original path/URI for saving and folder access. */ +private fun readableMeasuredFolder(path: String): String { + for (root in listOf("/storage/emulated/0", "/storage/self/primary", "/sdcard")) { + if (path == root || path.startsWith("$root/")) { + return "내장 저장공간" + path.removePrefix(root) + } + } + // OpenDocumentTree returns an external-storage document ID such as primary:Documents/Data. + if (path.startsWith("/tree/primary:")) { + val relative = path.removePrefix("/tree/primary:").trimStart('/') + return "내장 저장공간" + if (relative.isEmpty()) "" else "/$relative" + } + return path +} + @Composable private fun DialogPathField( path: String, placeholder: String, enabled: Boolean = true, - onPick: () -> Unit + onPick: () -> Unit, + onRemove: (() -> Unit)? = null ) { Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth() ) { - CustomDialogInputField( - value = path, - onValueChange = {}, - readOnly = true, - enabled = enabled, - placeholder = placeholder, - modifier = Modifier.weight(1f) - ) + val fieldColor = if (enabled) Color.White else Color(0xFFE9ECEF) + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .weight(1f) + .height(36.dp) + .clip(RoundedCornerShape(4.dp)) + .background(fieldColor) + .border(1.dp, if (enabled) ColorOutline else ColorOutline.copy(alpha = 0.5f), RoundedCornerShape(4.dp)) + .padding(start = 10.dp, end = 4.dp) + ) { + Box(modifier = Modifier.weight(1f).height(28.dp), contentAlignment = Alignment.CenterStart) { + Text( + text = path.ifEmpty { placeholder }, + fontSize = 13.sp, + fontWeight = FontWeight.Medium, + color = if (enabled) ColorTextPrimary else ColorTextSecondary.copy(alpha = 0.5f), + maxLines = 1, + softWrap = false, + overflow = TextOverflow.Clip, + modifier = Modifier.fillMaxWidth() + ) + if (onRemove != null) { + // 페이드는 파일명 영역 안에서만 그린다. X와 입력칸 테두리는 가리지 않는다. + Box( + modifier = Modifier + .align(Alignment.CenterEnd) + .width(24.dp) + .fillMaxHeight() + .background(Brush.horizontalGradient(listOf(Color.Transparent, fieldColor))) + ) + } + } + if (onRemove != null) { + Box( + modifier = Modifier + .size(28.dp) + .background(fieldColor) + .clickable(role = Role.Button, onClick = onRemove), + contentAlignment = Alignment.Center + ) { + Icon(Icons.Default.Close, contentDescription = "Remove file", + tint = ColorTextSecondary, modifier = Modifier.size(18.dp)) + } + } + } Button( onClick = onPick, diff --git a/Android/app/src/main/java/com/dct/app/presentation/signaltree/A2lSignalTableView.kt b/Android/app/src/main/java/com/dct/app/presentation/signaltree/A2lSignalTableView.kt index 7fa16e7..0e1412d 100644 --- a/Android/app/src/main/java/com/dct/app/presentation/signaltree/A2lSignalTableView.kt +++ b/Android/app/src/main/java/com/dct/app/presentation/signaltree/A2lSignalTableView.kt @@ -7,6 +7,7 @@ import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape @@ -15,6 +16,7 @@ import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Color import androidx.compose.ui.input.pointer.pointerInput @@ -23,7 +25,6 @@ import androidx.compose.ui.layout.positionInRoot import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.dct.app.data.parser.A2lParser.A2lCharacteristic @@ -48,6 +49,7 @@ fun WindowsClassicA2lSignalTableWindow( onDescriptionChange: (SignalDefinition, String) -> Unit = { _, _ -> }, modifier: Modifier = Modifier ) { + val rowHeight = signalTableRowHeight(LocalDensity.current) var searchQuery by rememberWindowString("search") var isExpanded by rememberWindowBoolean("expanded") var selectedSignalKey by rememberWindowNullableString("selected_signal") @@ -69,14 +71,8 @@ fun WindowsClassicA2lSignalTableWindow( val widthDataType = customWidths["dataType"] ?: 60.dp val widthHex = customWidths["hex"] ?: 75.dp - fun toggleAutoFit(key: String, defaultW: Dp, items: List, extraPadding: Dp = 16.dp) { - val autoW = calculateAutoFitWidth(key, items, defaultW, extraPadding = extraPadding) - if (customWidths.containsKey(key)) { - customWidths.remove(key) - } else { - customWidths[key] = autoW - } - } + val nameAutoFitWidth = rememberAutoFitWidth("Name", filteredSignals, 110.dp) { it.name } + val descAutoFitWidth = rememberAutoFitWidth("Description", filteredSignals, 160.dp, bodyStyle = SignalTableDetailTextStyle) { it.description } Column( modifier = modifier @@ -90,10 +86,12 @@ fun WindowsClassicA2lSignalTableWindow( onExpandClick = { isExpanded = !isExpanded } ) + val tableListState = rememberLazyListState() Box( modifier = Modifier .weight(1f) .fillMaxWidth() + .slimLazyListScrollbar(tableListState) .horizontalScroll(rememberScrollState()) ) { val totalWidth = widthName + widthValue + widthUnit + widthColor + widthDesc + @@ -104,27 +102,27 @@ fun WindowsClassicA2lSignalTableWindow( Row( modifier = Modifier .fillMaxWidth() - .height(24.dp) + .height(rowHeight) .background(Color(0xFFE9ECEF)) .border(1.dp, Color(0xFFCED4DA)), verticalAlignment = Alignment.CenterVertically ) { - ResizableTableHeaderCell("Name", widthName, onWidthChange = { customWidths["name"] = it }, onDoubleTap = { toggleAutoFit("name", 110.dp, filteredSignals.map { it.name }) }) - ResizableTableHeaderCell("Value", widthValue, onWidthChange = { customWidths["value"] = it }, onDoubleTap = { toggleAutoFit("value", 65.dp, filteredSignals.map { sig -> val v = liveTelemetryValues["a2l_${sig.adl}"] ?: liveTelemetryValues[sig.name]; if (v != null) String.format(Locale.US, "%.3f", v) else "0.0" }) }) - ResizableTableHeaderCell("Unit", widthUnit, onWidthChange = { customWidths["unit"] = it }, onDoubleTap = { toggleAutoFit("unit", 45.dp, filteredSignals.map { it.unit }) }) - ResizableTableHeaderCell("Color", widthColor, onWidthChange = { customWidths["color"] = it }, onDoubleTap = { toggleAutoFit("color", 55.dp, emptyList()) }) - ResizableTableHeaderCell("Description", widthDesc, onWidthChange = { customWidths["desc"] = it }, onDoubleTap = { toggleAutoFit("desc", 160.dp, filteredSignals.map { it.description }) }, showDivider = isExpanded) + ResizableTableHeaderCell("Name", widthName, onWidthChange = { customWidths["name"] = it }, onAutoFitClick = { customWidths.toggleAutoFit("name", 110.dp, nameAutoFitWidth) }, isAutoFit = customWidths.isAutoFit("name", nameAutoFitWidth), showAutoFit = true) + ResizableTableHeaderCell("Value", widthValue, onWidthChange = { customWidths["value"] = it }) + ResizableTableHeaderCell("Unit", widthUnit, onWidthChange = { customWidths["unit"] = it }) + ResizableTableHeaderCell("Color", widthColor, onWidthChange = { customWidths["color"] = it }) + ResizableTableHeaderCell("Description", widthDesc, onWidthChange = { customWidths["desc"] = it }, onAutoFitClick = { customWidths.toggleAutoFit("desc", 160.dp, descAutoFitWidth) }, showDivider = isExpanded, isAutoFit = customWidths.isAutoFit("desc", descAutoFitWidth), showAutoFit = true) if (isExpanded) { - ResizableTableHeaderCell("Min", widthMin, onWidthChange = { customWidths["min"] = it }, onDoubleTap = { toggleAutoFit("min", 55.dp, filteredSignals.map { String.format(Locale.US, "%.3f", it.min) }) }) - ResizableTableHeaderCell("Max", widthMax, onWidthChange = { customWidths["max"] = it }, onDoubleTap = { toggleAutoFit("max", 55.dp, filteredSignals.map { String.format(Locale.US, "%.3f", it.max) }) }) - ResizableTableHeaderCell("Address", widthAddr, onWidthChange = { customWidths["addr"] = it }, onDoubleTap = { toggleAutoFit("addr", 75.dp, filteredSignals.map { String.format(Locale.US, "0x%08X", it.adl.toLong() and 0xFFFFFFFFL) }) }) - ResizableTableHeaderCell("Data type", widthDataType, onWidthChange = { customWidths["dataType"] = it }, onDoubleTap = { toggleAutoFit("dataType", 60.dp, filteredSignals.map { it.attr.name.replace("ATTR_", "") }) }) - ResizableTableHeaderCell("Value (Hex)", widthHex, onWidthChange = { customWidths["hex"] = it }, onDoubleTap = { toggleAutoFit("hex", 75.dp, filteredSignals.map { sig -> val v = liveTelemetryValues["a2l_${sig.adl}"] ?: liveTelemetryValues[sig.name]; val raw = v?.let { sig.calculateRawValue(it).toLong() } ?: 0L; String.format(Locale.US, "0x%08X", raw and 0xFFFFFFFFL) }) }, showDivider = false) + ResizableTableHeaderCell("Min", widthMin, onWidthChange = { customWidths["min"] = it }) + ResizableTableHeaderCell("Max", widthMax, onWidthChange = { customWidths["max"] = it }) + ResizableTableHeaderCell("Address", widthAddr, onWidthChange = { customWidths["addr"] = it }) + ResizableTableHeaderCell("Data type", widthDataType, onWidthChange = { customWidths["dataType"] = it }) + ResizableTableHeaderCell("Value (Hex)", widthHex, onWidthChange = { customWidths["hex"] = it }, showDivider = false) } } // Table Rows - LazyColumn(modifier = Modifier.fillMaxSize()) { + LazyColumn(state = tableListState, modifier = Modifier.fillMaxSize()) { itemsIndexed(filteredSignals, key = { _, s -> "a2l_${s.adl}_${s.name}" }) { index, sig -> val sigKey = "a2l_${sig.adl}" val isSelected = (sigKey == selectedSignalKey) @@ -138,7 +136,7 @@ fun WindowsClassicA2lSignalTableWindow( Row( modifier = Modifier .fillMaxWidth() - .height(24.dp) + .height(rowHeight) .background(if (isSelected) Color(0xFFCCE8FF) else if (index % 2 == 0) Color.White else Color(0xFFF8F9FA)) .border(0.5.dp, Color(0xFFE5E5E5)) .onGloballyPositioned { coordinates -> @@ -179,11 +177,11 @@ fun WindowsClassicA2lSignalTableWindow( }, verticalAlignment = Alignment.CenterVertically ) { - Text(sig.name, fontSize = 11.sp, color = Color(0xFF212529), maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.width(widthName).padding(horizontal = 4.dp)) + Text(sig.name, style = SignalTableTextStyle, color = Color(0xFF212529), maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.width(widthName).padding(horizontal = 4.dp)) Box(modifier = Modifier.width(0.5.dp).fillMaxHeight().background(Color(0xFFE5E5E5))) - Text(displayVal, fontSize = 11.sp, color = if (liveVal != null) Color(0xFF212529) else Color(0xFF6C757D), maxLines = 1, modifier = Modifier.width(widthValue).padding(horizontal = 4.dp)) + Text(displayVal, style = SignalTableTextStyle, color = if (liveVal != null) Color(0xFF212529) else Color(0xFF6C757D), maxLines = 1, overflow = TextOverflow.Ellipsis, softWrap = false, modifier = Modifier.width(widthValue).padding(horizontal = 4.dp)) Box(modifier = Modifier.width(0.5.dp).fillMaxHeight().background(Color(0xFFE5E5E5))) - Text(sig.unit, fontSize = 11.sp, color = Color(0xFF212529), maxLines = 1, modifier = Modifier.width(widthUnit).padding(horizontal = 4.dp)) + Text(sig.unit, style = SignalTableTextStyle, color = Color(0xFF212529), maxLines = 1, overflow = TextOverflow.Ellipsis, softWrap = false, modifier = Modifier.width(widthUnit).padding(horizontal = 4.dp)) Box(modifier = Modifier.width(0.5.dp).fillMaxHeight().background(Color(0xFFE5E5E5))) EditableTableColorCell( colorValue = sig.color, @@ -198,15 +196,15 @@ fun WindowsClassicA2lSignalTableWindow( ) if (isExpanded) { Box(modifier = Modifier.width(0.5.dp).fillMaxHeight().background(Color(0xFFE5E5E5))) - Text(String.format(Locale.US, "%.3f", sig.min), fontSize = 10.sp, color = Color(0xFF6C757D), maxLines = 1, modifier = Modifier.width(widthMin).padding(horizontal = 4.dp)) + Text(String.format(Locale.US, "%.3f", sig.min), style = SignalTableDetailTextStyle, color = Color(0xFF6C757D), maxLines = 1, overflow = TextOverflow.Ellipsis, softWrap = false, modifier = Modifier.width(widthMin).padding(horizontal = 4.dp)) Box(modifier = Modifier.width(0.5.dp).fillMaxHeight().background(Color(0xFFE5E5E5))) - Text(String.format(Locale.US, "%.3f", sig.max), fontSize = 10.sp, color = Color(0xFF6C757D), maxLines = 1, modifier = Modifier.width(widthMax).padding(horizontal = 4.dp)) + Text(String.format(Locale.US, "%.3f", sig.max), style = SignalTableDetailTextStyle, color = Color(0xFF6C757D), maxLines = 1, overflow = TextOverflow.Ellipsis, softWrap = false, modifier = Modifier.width(widthMax).padding(horizontal = 4.dp)) Box(modifier = Modifier.width(0.5.dp).fillMaxHeight().background(Color(0xFFE5E5E5))) - Text(String.format(Locale.US, "0x%08X", sig.adl.toLong() and 0xFFFFFFFFL), fontSize = 10.sp, fontFamily = FontFamily.Monospace, color = Color(0xFF6C757D), maxLines = 1, modifier = Modifier.width(widthAddr).padding(horizontal = 4.dp)) + Text(String.format(Locale.US, "0x%08X", sig.adl.toLong() and 0xFFFFFFFFL), style = SignalTableDetailTextStyle, fontFamily = FontFamily.Monospace, color = Color(0xFF6C757D), maxLines = 1, overflow = TextOverflow.Ellipsis, softWrap = false, modifier = Modifier.width(widthAddr).padding(horizontal = 4.dp)) Box(modifier = Modifier.width(0.5.dp).fillMaxHeight().background(Color(0xFFE5E5E5))) - Text(sig.attr.name.replace("ATTR_", ""), fontSize = 10.sp, color = Color(0xFF6C757D), maxLines = 1, modifier = Modifier.width(widthDataType).padding(horizontal = 4.dp)) + Text(sig.attr.name.replace("ATTR_", ""), style = SignalTableDetailTextStyle, color = Color(0xFF6C757D), maxLines = 1, overflow = TextOverflow.Ellipsis, softWrap = false, modifier = Modifier.width(widthDataType).padding(horizontal = 4.dp)) Box(modifier = Modifier.width(0.5.dp).fillMaxHeight().background(Color(0xFFE5E5E5))) - Text(hexVal, fontSize = 10.sp, fontFamily = FontFamily.Monospace, color = Color(0xFF6C757D), maxLines = 1, modifier = Modifier.width(widthHex).padding(horizontal = 4.dp)) + Text(hexVal, style = SignalTableDetailTextStyle, fontFamily = FontFamily.Monospace, color = Color(0xFF6C757D), maxLines = 1, overflow = TextOverflow.Ellipsis, softWrap = false, modifier = Modifier.width(widthHex).padding(horizontal = 4.dp)) } } } @@ -261,14 +259,9 @@ fun WindowsClassicA2lCharacteristicTableWindow( val widthMax = customWidths["max"] ?: 55.dp val widthAddr = customWidths["addr"] ?: 75.dp - fun toggleAutoFit(key: String, defaultW: Dp, items: List, extraPadding: Dp = 16.dp) { - val autoW = calculateAutoFitWidth(key, items, defaultW, extraPadding = extraPadding) - if (customWidths.containsKey(key)) { - customWidths.remove(key) - } else { - customWidths[key] = autoW - } - } + val nameAutoFitWidth = rememberAutoFitWidth("Name", filtered, 130.dp, + bodyStyle = LocalTextStyle.current.copy(fontSize = 11.sp), bodyPadding = 30.dp) { it.name } + val descAutoFitWidth = rememberAutoFitWidth("Description", filtered, 140.dp, bodyStyle = SignalTableDetailTextStyle, bodyPadding = 18.dp) { it.description } Column( modifier = modifier @@ -371,12 +364,14 @@ fun WindowsClassicA2lCharacteristicTableWindow( } // 테이블 영역 + val tableListState = rememberLazyListState() Box( modifier = Modifier .weight(1f) .fillMaxWidth() .background(Color.White) .border(1.dp, Color(0xFF7F9DB9)) + .slimLazyListScrollbar(tableListState) .horizontalScroll(rememberScrollState()) ) { val totalTableWidth = widthName + widthValue + widthDesc + @@ -392,25 +387,25 @@ fun WindowsClassicA2lCharacteristicTableWindow( .border(1.dp, Color(0xFFC0C0C0)), verticalAlignment = Alignment.CenterVertically ) { - ResizableTableHeaderCell("Name", widthName, onWidthChange = { customWidths["name"] = it }, onDoubleTap = { toggleAutoFit("name", 130.dp, filtered.map { it.name }, extraPadding = 36.dp) }, textColor = Color.Black, dividerColor = Color(0xFFC0C0C0)) - ResizableTableHeaderCell("Value", widthValue, onWidthChange = { customWidths["value"] = it }, onDoubleTap = { toggleAutoFit("value", 70.dp, filtered.map { if (it.value == it.value.toLong().toDouble()) String.format(Locale.US, "%.0f", it.value) else String.format(Locale.US, "%.3f", it.value) }) }, textColor = Color(0xFF0052CC), dividerColor = Color(0xFFC0C0C0)) - ResizableTableHeaderCell("Description", widthDesc, onWidthChange = { customWidths["desc"] = it }, onDoubleTap = { toggleAutoFit("desc", 140.dp, filtered.map { it.description }) }, textColor = Color.Black, dividerColor = Color(0xFFC0C0C0), showDivider = isExpanded) + ResizableTableHeaderCell("Name", widthName, onWidthChange = { customWidths["name"] = it }, onAutoFitClick = { customWidths.toggleAutoFit("name", 130.dp, nameAutoFitWidth) }, textColor = Color.Black, dividerColor = Color(0xFFC0C0C0), isAutoFit = customWidths.isAutoFit("name", nameAutoFitWidth), showAutoFit = true) + ResizableTableHeaderCell("Value", widthValue, onWidthChange = { customWidths["value"] = it }, textColor = Color(0xFF0052CC), dividerColor = Color(0xFFC0C0C0)) + ResizableTableHeaderCell("Description", widthDesc, onWidthChange = { customWidths["desc"] = it }, onAutoFitClick = { customWidths.toggleAutoFit("desc", 140.dp, descAutoFitWidth) }, textColor = Color.Black, dividerColor = Color(0xFFC0C0C0), showDivider = isExpanded, isAutoFit = customWidths.isAutoFit("desc", descAutoFitWidth), showAutoFit = true) if (isExpanded) { - ResizableTableHeaderCell("Min", widthMin, onWidthChange = { customWidths["min"] = it }, onDoubleTap = { toggleAutoFit("min", 55.dp, filtered.map { if (it.lowerLimit == it.lowerLimit.toLong().toDouble()) String.format(Locale.US, "%.0f", it.lowerLimit) else String.format(Locale.US, "%.3f", it.lowerLimit) }) }, textColor = Color.Black, dividerColor = Color(0xFFC0C0C0)) - ResizableTableHeaderCell("Max", widthMax, onWidthChange = { customWidths["max"] = it }, onDoubleTap = { toggleAutoFit("max", 55.dp, filtered.map { if (it.upperLimit == it.upperLimit.toLong().toDouble()) String.format(Locale.US, "%.0f", it.upperLimit) else String.format(Locale.US, "%.3f", it.upperLimit) }) }, textColor = Color.Black, dividerColor = Color(0xFFC0C0C0)) - ResizableTableHeaderCell("Address", widthAddr, onWidthChange = { customWidths["addr"] = it }, onDoubleTap = { toggleAutoFit("addr", 75.dp, filtered.map { String.format(Locale.US, "0x%08X", it.ecuAddress) }) }, textColor = Color.Black, dividerColor = Color(0xFFC0C0C0), showDivider = false) + ResizableTableHeaderCell("Min", widthMin, onWidthChange = { customWidths["min"] = it }, textColor = Color.Black, dividerColor = Color(0xFFC0C0C0)) + ResizableTableHeaderCell("Max", widthMax, onWidthChange = { customWidths["max"] = it }, textColor = Color.Black, dividerColor = Color(0xFFC0C0C0)) + ResizableTableHeaderCell("Address", widthAddr, onWidthChange = { customWidths["addr"] = it }, textColor = Color.Black, dividerColor = Color(0xFFC0C0C0), showDivider = false) } } // Table Rows - LazyColumn(modifier = Modifier.fillMaxSize()) { + LazyColumn(state = tableListState, modifier = Modifier.fillMaxSize()) { itemsIndexed(filtered, key = { _, char -> "a2l_char_${char.ecuAddress}_${char.name}" }) { index, char -> val isChecked = char.ecuAddress in checkedAddresses Row( modifier = Modifier .fillMaxWidth() - .height(20.dp) + .height(24.dp) .background(if (index % 2 == 0) Color.White else Color(0xFFF8F9FA)) .border(0.5.dp, Color(0xFFE5E5E5)) .clickable { @@ -454,7 +449,7 @@ fun WindowsClassicA2lCharacteristicTableWindow( // Value Box(modifier = Modifier.width(widthValue).fillMaxHeight().padding(horizontal = 4.dp), contentAlignment = Alignment.CenterStart) { val valStr = if (char.value == char.value.toLong().toDouble()) String.format(Locale.US, "%.0f", char.value) else String.format(Locale.US, "%.3f", char.value) - Text(valStr, fontSize = 11.sp, fontWeight = FontWeight.Bold, color = Color(0xFF0052CC), maxLines = 1, softWrap = false) + Text(valStr, fontSize = 11.sp, fontWeight = FontWeight.Bold, color = Color(0xFF0052CC), maxLines = 1, overflow = TextOverflow.Ellipsis, softWrap = false) } Box(modifier = Modifier.width(0.5.dp).fillMaxHeight().background(Color(0xFFE5E5E5))) @@ -469,16 +464,16 @@ fun WindowsClassicA2lCharacteristicTableWindow( Box(modifier = Modifier.width(0.5.dp).fillMaxHeight().background(Color(0xFFE5E5E5))) Box(modifier = Modifier.width(widthMin).fillMaxHeight().padding(horizontal = 4.dp), contentAlignment = Alignment.CenterStart) { val minStr = if (char.lowerLimit == char.lowerLimit.toLong().toDouble()) String.format(Locale.US, "%.0f", char.lowerLimit) else String.format(Locale.US, "%.3f", char.lowerLimit) - Text(minStr, fontSize = 11.sp, color = Color.Black, maxLines = 1, softWrap = false) + Text(minStr, fontSize = 11.sp, color = Color.Black, maxLines = 1, overflow = TextOverflow.Ellipsis, softWrap = false) } Box(modifier = Modifier.width(0.5.dp).fillMaxHeight().background(Color(0xFFE5E5E5))) Box(modifier = Modifier.width(widthMax).fillMaxHeight().padding(horizontal = 4.dp), contentAlignment = Alignment.CenterStart) { val maxStr = if (char.upperLimit == char.upperLimit.toLong().toDouble()) String.format(Locale.US, "%.0f", char.upperLimit) else String.format(Locale.US, "%.3f", char.upperLimit) - Text(maxStr, fontSize = 11.sp, color = Color.Black, maxLines = 1, softWrap = false) + Text(maxStr, fontSize = 11.sp, color = Color.Black, maxLines = 1, overflow = TextOverflow.Ellipsis, softWrap = false) } Box(modifier = Modifier.width(0.5.dp).fillMaxHeight().background(Color(0xFFE5E5E5))) Box(modifier = Modifier.width(widthAddr).fillMaxHeight().padding(horizontal = 4.dp), contentAlignment = Alignment.CenterStart) { - Text(String.format(Locale.US, "0x%08X", char.ecuAddress), fontSize = 11.sp, fontFamily = FontFamily.Monospace, color = Color.Black, maxLines = 1, softWrap = false) + Text(String.format(Locale.US, "0x%08X", char.ecuAddress), fontSize = 11.sp, fontFamily = FontFamily.Monospace, color = Color.Black, maxLines = 1, overflow = TextOverflow.Ellipsis, softWrap = false) } } } @@ -560,7 +555,7 @@ fun WindowsClassicA2lCharacteristicTableWindow( } /** - * 7. A2L CharacteristicsArray (배열 특성치) 전용 데이터 테이블 창 (PC CA2LCharacteristicArrayFormView 1:1 일치) + * 7. A2L CharacteristicsArray (배열 특성치) 전용 테이블. PC와 같은 목록/행·열 구성을 터치 UI로 제공한다. * - 상단: 전체 배열 목록 테이블 (Name, Description, Min, Max, Address) * - 중간: Characteristics array name : 서브타이틀 바 * - 하단: 2D 동적 매트릭스 그리드 (Array | 00 | 01 | 02 ... XSize-1) 및 셀 인라인 편집 @@ -630,26 +625,24 @@ fun WindowsClassicA2lCharacteristicArrayTableWindow( val widthName = customWidths["name"] ?: 130.dp val widthDesc = customWidths["desc"] ?: 140.dp - val widthMin = customWidths["min"] ?: 55.dp - val widthMax = customWidths["max"] ?: 55.dp + // 부호와 소수부가 있는 제한값도 기본 상태에서 잘리지 않도록 여유를 둔다. + val widthMin = customWidths["min"] ?: 80.dp + val widthMax = customWidths["max"] ?: 80.dp val widthAddr = customWidths["addr"] ?: 75.dp - fun toggleAutoFit(key: String, defaultW: Dp, items: List, extraPadding: Dp = 16.dp) { - val autoW = calculateAutoFitWidth(key, items, defaultW, extraPadding = extraPadding) - if (customWidths.containsKey(key)) { - customWidths.remove(key) - } else { - customWidths[key] = autoW - } - } + val nameAutoFitWidth = rememberAutoFitWidth("Name", filtered, 130.dp, + bodyStyle = LocalTextStyle.current.copy(fontSize = 11.sp, fontWeight = FontWeight.Bold)) { it.name } + val descAutoFitWidth = rememberAutoFitWidth("Description", filtered, 140.dp, bodyStyle = SignalTableDetailTextStyle, bodyPadding = 18.dp) { it.description } // 2. 상단 배열 목록 테이블 (PC m_ChartListA 1:1 일치) + val arrayListState = rememberLazyListState() Box( modifier = Modifier .fillMaxWidth() .height(130.dp) .background(Color.White) .border(1.dp, Color(0xFF7F9DB9)) + .slimLazyListScrollbar(arrayListState) .horizontalScroll(rememberScrollState()) ) { val totalWidth = widthName + widthDesc + widthMin + widthMax + widthAddr + 10.dp @@ -663,15 +656,15 @@ fun WindowsClassicA2lCharacteristicArrayTableWindow( .border(1.dp, Color(0xFFC0C0C0)), verticalAlignment = Alignment.CenterVertically ) { - ResizableTableHeaderCell("Name", widthName, onWidthChange = { customWidths["name"] = it }, onDoubleTap = { toggleAutoFit("name", 130.dp, filtered.map { it.name }) }, textColor = Color.Black, dividerColor = Color(0xFFC0C0C0)) - ResizableTableHeaderCell("Description", widthDesc, onWidthChange = { customWidths["desc"] = it }, onDoubleTap = { toggleAutoFit("desc", 140.dp, filtered.map { it.description }) }, textColor = Color.Black, dividerColor = Color(0xFFC0C0C0)) - ResizableTableHeaderCell("Min", widthMin, onWidthChange = { customWidths["min"] = it }, onDoubleTap = { toggleAutoFit("min", 55.dp, filtered.map { if (it.lowerLimit == it.lowerLimit.toLong().toDouble()) String.format(Locale.US, "%.0f", it.lowerLimit) else String.format(Locale.US, "%.3f", it.lowerLimit) }) }, textColor = Color.Black, dividerColor = Color(0xFFC0C0C0)) - ResizableTableHeaderCell("Max", widthMax, onWidthChange = { customWidths["max"] = it }, onDoubleTap = { toggleAutoFit("max", 55.dp, filtered.map { if (it.upperLimit == it.upperLimit.toLong().toDouble()) String.format(Locale.US, "%.0f", it.upperLimit) else String.format(Locale.US, "%.3f", it.upperLimit) }) }, textColor = Color.Black, dividerColor = Color(0xFFC0C0C0)) - ResizableTableHeaderCell("Address", widthAddr, onWidthChange = { customWidths["addr"] = it }, onDoubleTap = { toggleAutoFit("addr", 75.dp, filtered.map { String.format("0x%08X", it.ecuAddress) }) }, textColor = Color.Black, dividerColor = Color(0xFFC0C0C0), showDivider = false) + ResizableTableHeaderCell("Name", widthName, onWidthChange = { customWidths["name"] = it }, onAutoFitClick = { customWidths.toggleAutoFit("name", 130.dp, nameAutoFitWidth) }, textColor = Color.Black, dividerColor = Color(0xFFC0C0C0), isAutoFit = customWidths.isAutoFit("name", nameAutoFitWidth), showAutoFit = true) + ResizableTableHeaderCell("Description", widthDesc, onWidthChange = { customWidths["desc"] = it }, onAutoFitClick = { customWidths.toggleAutoFit("desc", 140.dp, descAutoFitWidth) }, textColor = Color.Black, dividerColor = Color(0xFFC0C0C0), isAutoFit = customWidths.isAutoFit("desc", descAutoFitWidth), showAutoFit = true) + ResizableTableHeaderCell("Min", widthMin, onWidthChange = { customWidths["min"] = it }, textColor = Color.Black, dividerColor = Color(0xFFC0C0C0)) + ResizableTableHeaderCell("Max", widthMax, onWidthChange = { customWidths["max"] = it }, textColor = Color.Black, dividerColor = Color(0xFFC0C0C0)) + ResizableTableHeaderCell("Address", widthAddr, onWidthChange = { customWidths["addr"] = it }, textColor = Color.Black, dividerColor = Color(0xFFC0C0C0), showDivider = false) } // Rows - LazyColumn(modifier = Modifier.fillMaxSize()) { + LazyColumn(state = arrayListState, modifier = Modifier.fillMaxSize()) { itemsIndexed(filtered, key = { _, arr -> "a2l_arr_${arr.ecuAddress}_${arr.name}" }) { index, arr -> val isSelected = (index == selectedIndex) val grayCellBg = if (isSelected) Color(0xFFB0C4DE) else Color(0xFFDCDCDC) @@ -680,7 +673,7 @@ fun WindowsClassicA2lCharacteristicArrayTableWindow( Row( modifier = Modifier .fillMaxWidth() - .height(20.dp) + .height(24.dp) .border(0.5.dp, Color(0xFFE5E5E5)) .clickable { selectedIndex = index }, verticalAlignment = Alignment.CenterVertically @@ -695,15 +688,15 @@ fun WindowsClassicA2lCharacteristicArrayTableWindow( Box(modifier = Modifier.width(0.5.dp).fillMaxHeight().background(Color(0xFFE5E5E5))) Box(modifier = Modifier.width(widthMin).fillMaxHeight().background(whiteCellBg).padding(horizontal = 4.dp), contentAlignment = Alignment.CenterStart) { val minStr = if (arr.lowerLimit == arr.lowerLimit.toLong().toDouble()) String.format(Locale.US, "%.0f", arr.lowerLimit) else String.format(Locale.US, "%.3f", arr.lowerLimit) - Text(minStr, fontSize = 11.sp, color = Color.Black, maxLines = 1, softWrap = false) + Text(minStr, fontSize = 11.sp, color = Color.Black, maxLines = 1, overflow = TextOverflow.Ellipsis, softWrap = false) } Box(modifier = Modifier.width(0.5.dp).fillMaxHeight().background(Color(0xFFE5E5E5))) Box(modifier = Modifier.width(widthMax).fillMaxHeight().background(whiteCellBg).padding(horizontal = 4.dp), contentAlignment = Alignment.CenterStart) { val maxStr = if (arr.upperLimit == arr.upperLimit.toLong().toDouble()) String.format(Locale.US, "%.0f", arr.upperLimit) else String.format(Locale.US, "%.3f", arr.upperLimit) - Text(maxStr, fontSize = 11.sp, color = Color.Black, maxLines = 1, softWrap = false) + Text(maxStr, fontSize = 11.sp, color = Color.Black, maxLines = 1, overflow = TextOverflow.Ellipsis, softWrap = false) } - Box(modifier = Modifier.width(0.5.dp).fillMaxHeight().background(whiteCellBg).padding(horizontal = 4.dp), contentAlignment = Alignment.CenterStart) { - Text(String.format(Locale.US, "0x%08X", arr.ecuAddress), fontSize = 11.sp, fontFamily = FontFamily.Monospace, color = Color.Black, maxLines = 1, softWrap = false) + Box(modifier = Modifier.width(widthAddr).fillMaxHeight().background(whiteCellBg).padding(horizontal = 4.dp), contentAlignment = Alignment.CenterStart) { + Text(String.format(Locale.US, "0x%08X", arr.ecuAddress), fontSize = 11.sp, fontFamily = FontFamily.Monospace, color = Color.Black, maxLines = 1, overflow = TextOverflow.Ellipsis, softWrap = false) } } } @@ -726,6 +719,9 @@ fun WindowsClassicA2lCharacteristicArrayTableWindow( fontSize = 12.sp, fontWeight = FontWeight.Normal, color = Color.Black, + maxLines = 1, + softWrap = false, + overflow = TextOverflow.Ellipsis, modifier = Modifier.padding(start = 2.dp, bottom = 4.dp) ) @@ -743,9 +739,11 @@ fun WindowsClassicA2lCharacteristicArrayTableWindow( val totalCols = xSize val matrixTableWidth = (55 + totalCols * 45).dp + val matrixListState = rememberLazyListState() Box( modifier = Modifier .fillMaxSize() + .slimLazyListScrollbar(matrixListState) .horizontalScroll(rememberScrollState()) ) { Column(modifier = Modifier.width(matrixTableWidth)) { @@ -759,13 +757,13 @@ fun WindowsClassicA2lCharacteristicArrayTableWindow( verticalAlignment = Alignment.CenterVertically ) { Box(modifier = Modifier.width(55.dp).fillMaxHeight().background(Color(0xFFDCDCDC)), contentAlignment = Alignment.Center) { - Text("Array", fontSize = 11.sp, fontWeight = FontWeight.Bold, color = Color.Black, maxLines = 1) + Text("Array", fontSize = 11.sp, fontWeight = FontWeight.Bold, color = Color.Black, maxLines = 1, overflow = TextOverflow.Ellipsis, softWrap = false) } Box(modifier = Modifier.width(1.dp).fillMaxHeight().background(Color(0xFFC0C0C0))) for (x in 0 until xSize) { Box(modifier = Modifier.width(45.dp).fillMaxHeight(), contentAlignment = Alignment.Center) { - Text(String.format(Locale.US, "%02d", x), fontSize = 11.sp, fontWeight = FontWeight.Bold, color = Color.Black, maxLines = 1) + Text(String.format(Locale.US, "%02d", x), fontSize = 11.sp, fontWeight = FontWeight.Bold, color = Color.Black, maxLines = 1, overflow = TextOverflow.Ellipsis, softWrap = false) } if (x < xSize - 1) { Box(modifier = Modifier.width(1.dp).fillMaxHeight().background(Color(0xFFC0C0C0))) @@ -774,12 +772,12 @@ fun WindowsClassicA2lCharacteristicArrayTableWindow( } // Rows (y: 0 until ySize) - LazyColumn(modifier = Modifier.fillMaxSize()) { + LazyColumn(state = matrixListState, modifier = Modifier.fillMaxSize()) { items(ySize) { y -> Row( modifier = Modifier .fillMaxWidth() - .height(20.dp) + .height(24.dp) .border(0.5.dp, Color(0xFFE5E5E5)), verticalAlignment = Alignment.CenterVertically ) { @@ -791,12 +789,13 @@ fun WindowsClassicA2lCharacteristicArrayTableWindow( .background(Color(0xFFDCDCDC)), contentAlignment = Alignment.Center ) { - Text(String.format(Locale.US, "%02d", y), fontSize = 11.sp, fontWeight = FontWeight.Bold, color = Color.Black, maxLines = 1) + Text(String.format(Locale.US, "%02d", y), fontSize = 11.sp, fontWeight = FontWeight.Bold, color = Color.Black, maxLines = 1, overflow = TextOverflow.Ellipsis, softWrap = false) } Box(modifier = Modifier.width(0.5.dp).fillMaxHeight().background(Color(0xFFE5E5E5))) // Data Cells for this row for (x in 0 until xSize) { + // 표시 값은 행 순서. 읽기/쓰기 주소는 모델이 Record Layout 순서로 변환한다. val cellIdx = y * xSize + x val cellVal = selectedArr.values.getOrNull(cellIdx) ?: 0.0 @@ -813,7 +812,7 @@ fun WindowsClassicA2lCharacteristicArrayTableWindow( contentAlignment = Alignment.Center ) { val valStr = if (cellVal == cellVal.toLong().toDouble()) String.format(Locale.US, "%.0f", cellVal) else String.format(Locale.US, "%.2f", cellVal) - Text(valStr, fontSize = 11.sp, color = Color.Black, maxLines = 1) + Text(valStr, fontSize = 11.sp, color = Color.Black, maxLines = 1, overflow = TextOverflow.Ellipsis, softWrap = false) } if (x < xSize - 1) { diff --git a/Android/app/src/main/java/com/dct/app/presentation/signaltree/CanSignalSorting.kt b/Android/app/src/main/java/com/dct/app/presentation/signaltree/CanSignalSorting.kt index 8a330b6..e2ab6aa 100644 --- a/Android/app/src/main/java/com/dct/app/presentation/signaltree/CanSignalSorting.kt +++ b/Android/app/src/main/java/com/dct/app/presentation/signaltree/CanSignalSorting.kt @@ -4,9 +4,13 @@ import com.dct.app.domain.model.mdb.SignalDefinition import java.util.Locale internal fun sortCanSignals(signals: List, column: String, descending: Boolean, values: Map): List { - if (column.isBlank()) return signals + // Unit is a display-only field in the PC table and is intentionally not sortable. + // Treat a value persisted by an older app version as unsorted as well. + if (column.isBlank() || column == "unit") return signals val numeric: (SignalDefinition) -> Double? = { s -> when (column) { - "value" -> (values["can_${s.idx}"] ?: values[s.name])?.takeIf { it.isFinite() } + "value" -> (values[com.dct.app.domain.model.mdb.CanSignalKey.stable(s)] + ?: values[com.dct.app.domain.model.mdb.CanSignalKey.legacy(s)] + ?: values[s.name])?.takeIf { it.isFinite() } "msgId" -> s.messageId.toDouble() "min" -> s.min; "max" -> s.max; "startBit" -> s.startBit.toDouble() "length" -> s.bitLength.toDouble(); "factor" -> s.cvsA; "offset" -> s.cvsB @@ -14,7 +18,7 @@ internal fun sortCanSignals(signals: List, column: String, des else -> null } } val text: (SignalDefinition) -> String = { s -> when(column) { - "unit" -> s.unit; "desc" -> s.description; "msg" -> s.messageName; else -> s.name + "desc" -> s.description; "msg" -> s.messageName; else -> s.name }.lowercase(Locale.ROOT) } val isNumber = column in setOf("value", "msgId", "min", "max", "startBit", "length", "factor", "offset", "color") return signals.sortedWith { a, b -> diff --git a/Android/app/src/main/java/com/dct/app/presentation/signaltree/CanSignalTableView.kt b/Android/app/src/main/java/com/dct/app/presentation/signaltree/CanSignalTableView.kt index 5e863d7..27d3fd8 100644 --- a/Android/app/src/main/java/com/dct/app/presentation/signaltree/CanSignalTableView.kt +++ b/Android/app/src/main/java/com/dct/app/presentation/signaltree/CanSignalTableView.kt @@ -7,12 +7,14 @@ import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.rememberScrollState import androidx.compose.material3.Text import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Color import androidx.compose.ui.input.pointer.pointerInput @@ -20,9 +22,7 @@ import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.layout.positionInRoot import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp import com.dct.app.domain.model.mdb.SignalDefinition import com.dct.app.presentation.chart.DraggedSignal import com.dct.app.presentation.chart.SignalDragDropManager @@ -43,6 +43,7 @@ fun WindowsClassicCanSignalTableWindow( onDescriptionChange: (SignalDefinition, String) -> Unit = { _, _ -> }, modifier: Modifier = Modifier ) { + val rowHeight = signalTableRowHeight(LocalDensity.current) var searchQuery by rememberWindowString("search") var isExpanded by rememberWindowBoolean("expanded") var selectedSignalKey by rememberWindowNullableString("selected_signal") @@ -50,6 +51,8 @@ fun WindowsClassicCanSignalTableWindow( var sortColumn by rememberWindowString("sort_column") var sortDescending by rememberWindowBoolean("sort_descending") fun sortByColumn(column: String) { + // Unit is informational only; PC does not expose sorting for it. + if (column == "unit") return sortDescending = if (sortColumn == column) !sortDescending else false sortColumn = column } @@ -77,14 +80,13 @@ fun WindowsClassicCanSignalTableWindow( val widthFactor = customWidths["factor"] ?: 55.dp val widthOffset = customWidths["offset"] ?: 55.dp - fun toggleAutoFit(key: String, defaultW: Dp, items: List, extraPadding: Dp = 16.dp) { - val autoW = calculateAutoFitWidth(key, items, defaultW, extraPadding = extraPadding) - if (customWidths.containsKey(key)) { - customWidths.remove(key) - } else { - customWidths[key] = autoW - } + // Numeric sorting changes on packets; auto-fit metadata does not. + val autoFitSignals = remember(canSignals, searchQuery) { + canSignals.filter { searchQuery.isBlank() || it.name.contains(searchQuery, true) } } + val nameAutoFitWidth = rememberAutoFitWidth(columnTitle("name", "DBC name"), autoFitSignals, 110.dp) { it.name } + val descAutoFitWidth = rememberAutoFitWidth(columnTitle("desc", "Description"), autoFitSignals, 160.dp, bodyStyle = SignalTableDetailTextStyle) { it.description } + val msgAutoFitWidth = rememberAutoFitWidth(columnTitle("msg", "Message"), autoFitSignals, 110.dp, bodyStyle = SignalTableDetailTextStyle) { it.messageName.ifEmpty { "Msg_${it.idx}" } } Column( modifier = modifier @@ -98,10 +100,12 @@ fun WindowsClassicCanSignalTableWindow( onExpandClick = { isExpanded = !isExpanded } ) + val tableListState = rememberLazyListState() Box( modifier = Modifier .weight(1f) .fillMaxWidth() + .slimLazyListScrollbar(tableListState) .horizontalScroll(rememberScrollState()) ) { val totalWidth = widthName + widthValue + widthUnit + widthColor + widthDesc + @@ -112,34 +116,39 @@ fun WindowsClassicCanSignalTableWindow( Row( modifier = Modifier .fillMaxWidth() - .height(24.dp) + .height(rowHeight) .background(Color(0xFFE9ECEF)) .border(1.dp, Color(0xFFCED4DA)), verticalAlignment = Alignment.CenterVertically ) { - ResizableTableHeaderCell(columnTitle("name", "DBC name"), widthName, onClick = { sortByColumn("name") }, onWidthChange = { customWidths["name"] = it }, onDoubleTap = { toggleAutoFit("name", 110.dp, filteredSignals.map { it.name }) }) - ResizableTableHeaderCell(columnTitle("value", "Value"), widthValue, onClick = { sortByColumn("value") }, onWidthChange = { customWidths["value"] = it }, onDoubleTap = { toggleAutoFit("value", 65.dp, filteredSignals.map { sig -> val v = liveTelemetryValues["can_${sig.idx}"] ?: liveTelemetryValues[sig.name]; sig.formatDisplayValue(v) }) }) - ResizableTableHeaderCell(columnTitle("unit", "Unit"), widthUnit, onClick = { sortByColumn("unit") }, onWidthChange = { customWidths["unit"] = it }, onDoubleTap = { toggleAutoFit("unit", 45.dp, filteredSignals.map { it.unit }) }) - ResizableTableHeaderCell(columnTitle("color", "Color"), widthColor, onClick = { sortByColumn("color") }, onWidthChange = { customWidths["color"] = it }, onDoubleTap = { toggleAutoFit("color", 55.dp, emptyList()) }) - ResizableTableHeaderCell(columnTitle("desc", "Description"), widthDesc, onClick = { sortByColumn("desc") }, onWidthChange = { customWidths["desc"] = it }, onDoubleTap = { toggleAutoFit("desc", 160.dp, filteredSignals.map { it.description }) }, showDivider = isExpanded) + ResizableTableHeaderCell(columnTitle("name", "DBC name"), widthName, onClick = { sortByColumn("name") }, onWidthChange = { customWidths["name"] = it }, onAutoFitClick = { customWidths.toggleAutoFit("name", 110.dp, nameAutoFitWidth) }, isAutoFit = customWidths.isAutoFit("name", nameAutoFitWidth), showAutoFit = true) + ResizableTableHeaderCell(columnTitle("value", "Value"), widthValue, onClick = { sortByColumn("value") }, onWidthChange = { customWidths["value"] = it }) + ResizableTableHeaderCell("Unit", widthUnit, onWidthChange = { customWidths["unit"] = it }) + ResizableTableHeaderCell(columnTitle("color", "Color"), widthColor, onClick = { sortByColumn("color") }, onWidthChange = { customWidths["color"] = it }) + ResizableTableHeaderCell(columnTitle("desc", "Description"), widthDesc, onClick = { sortByColumn("desc") }, onWidthChange = { customWidths["desc"] = it }, onAutoFitClick = { customWidths.toggleAutoFit("desc", 160.dp, descAutoFitWidth) }, showDivider = isExpanded, isAutoFit = customWidths.isAutoFit("desc", descAutoFitWidth), showAutoFit = true) if (isExpanded) { - ResizableTableHeaderCell(columnTitle("msg", "Message"), widthMsg, onClick = { sortByColumn("msg") }, onWidthChange = { customWidths["msg"] = it }, onDoubleTap = { toggleAutoFit("msg", 110.dp, filteredSignals.map { it.messageName.ifEmpty { "Msg_${it.idx}" } }) }) - ResizableTableHeaderCell(columnTitle("msgId", "Message ID"), widthMsgId, onClick = { sortByColumn("msgId") }, onWidthChange = { customWidths["msgId"] = it }, onDoubleTap = { toggleAutoFit("msgId", 60.dp, filteredSignals.map { String.format(Locale.US, "0x%04X", it.messageId) }) }) - ResizableTableHeaderCell(columnTitle("min", "Min"), widthMin, onClick = { sortByColumn("min") }, onWidthChange = { customWidths["min"] = it }, onDoubleTap = { toggleAutoFit("min", 55.dp, filteredSignals.map { String.format(Locale.US, "%.3f", it.min) }) }) - ResizableTableHeaderCell(columnTitle("max", "Max"), widthMax, onClick = { sortByColumn("max") }, onWidthChange = { customWidths["max"] = it }, onDoubleTap = { toggleAutoFit("max", 55.dp, filteredSignals.map { String.format(Locale.US, "%.3f", it.max) }) }) - ResizableTableHeaderCell(columnTitle("startBit", "StartBit"), widthStartBit, onClick = { sortByColumn("startBit") }, onWidthChange = { customWidths["startBit"] = it }, onDoubleTap = { toggleAutoFit("startBit", 55.dp, filteredSignals.map { it.startBit.toString() }) }) - ResizableTableHeaderCell(columnTitle("length", "Length"), widthLength, onClick = { sortByColumn("length") }, onWidthChange = { customWidths["length"] = it }, onDoubleTap = { toggleAutoFit("length", 55.dp, filteredSignals.map { it.bitLength.toString() }) }) - ResizableTableHeaderCell(columnTitle("factor", "Factor"), widthFactor, onClick = { sortByColumn("factor") }, onWidthChange = { customWidths["factor"] = it }, onDoubleTap = { toggleAutoFit("factor", 55.dp, filteredSignals.map { String.format(Locale.US, "%.3f", it.cvsA) }) }) - ResizableTableHeaderCell(columnTitle("offset", "Offset"), widthOffset, onClick = { sortByColumn("offset") }, onWidthChange = { customWidths["offset"] = it }, onDoubleTap = { toggleAutoFit("offset", 55.dp, filteredSignals.map { String.format(Locale.US, "%.3f", it.cvsB) }) }, showDivider = false) + ResizableTableHeaderCell(columnTitle("msg", "Message"), widthMsg, onClick = { sortByColumn("msg") }, onWidthChange = { customWidths["msg"] = it }, onAutoFitClick = { customWidths.toggleAutoFit("msg", 110.dp, msgAutoFitWidth) }, isAutoFit = customWidths.isAutoFit("msg", msgAutoFitWidth), showAutoFit = true) + ResizableTableHeaderCell(columnTitle("msgId", "Message ID"), widthMsgId, onClick = { sortByColumn("msgId") }, onWidthChange = { customWidths["msgId"] = it }) + ResizableTableHeaderCell(columnTitle("min", "Min"), widthMin, onClick = { sortByColumn("min") }, onWidthChange = { customWidths["min"] = it }) + ResizableTableHeaderCell(columnTitle("max", "Max"), widthMax, onClick = { sortByColumn("max") }, onWidthChange = { customWidths["max"] = it }) + ResizableTableHeaderCell(columnTitle("startBit", "StartBit"), widthStartBit, onClick = { sortByColumn("startBit") }, onWidthChange = { customWidths["startBit"] = it }) + ResizableTableHeaderCell(columnTitle("length", "Length"), widthLength, onClick = { sortByColumn("length") }, onWidthChange = { customWidths["length"] = it }) + ResizableTableHeaderCell(columnTitle("factor", "Factor"), widthFactor, onClick = { sortByColumn("factor") }, onWidthChange = { customWidths["factor"] = it }) + ResizableTableHeaderCell(columnTitle("offset", "Offset"), widthOffset, onClick = { sortByColumn("offset") }, onWidthChange = { customWidths["offset"] = it }, showDivider = false) } } // Table Rows - LazyColumn(modifier = Modifier.fillMaxSize()) { - itemsIndexed(filteredSignals, key = { _, s -> "can_${s.idx}_${s.name}" }) { index, sig -> - val sigKey = "can_${sig.idx}" + LazyColumn( + state = tableListState, + modifier = Modifier.fillMaxSize() + ) { + itemsIndexed(filteredSignals, key = { _, s -> com.dct.app.domain.model.mdb.CanSignalKey.stable(s) }) { index, sig -> + val sigKey = com.dct.app.domain.model.mdb.CanSignalKey.stable(sig) val isSelected = (sigKey == selectedSignalKey) - val liveVal = liveTelemetryValues[sigKey] ?: liveTelemetryValues[sig.name] + val liveVal = liveTelemetryValues[sigKey] + ?: liveTelemetryValues[com.dct.app.domain.model.mdb.CanSignalKey.legacy(sig)] + ?: liveTelemetryValues[sig.name] val displayVal = sig.formatDisplayValue(liveVal) var rowPosInRoot by remember { mutableStateOf(Offset.Zero) } @@ -147,7 +156,7 @@ fun WindowsClassicCanSignalTableWindow( Row( modifier = Modifier .fillMaxWidth() - .height(24.dp) + .height(rowHeight) .background(if (isSelected) Color(0xFFCCE8FF) else if (index % 2 == 0) Color.White else Color(0xFFF8F9FA)) .border(0.5.dp, Color(0xFFE5E5E5)) .onGloballyPositioned { coordinates -> @@ -188,11 +197,11 @@ fun WindowsClassicCanSignalTableWindow( }, verticalAlignment = Alignment.CenterVertically ) { - Text(sig.name, fontSize = 11.sp, color = Color(0xFF212529), maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.width(widthName).padding(horizontal = 4.dp)) + Text(sig.name, style = SignalTableTextStyle, color = Color(0xFF212529), maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.width(widthName).padding(horizontal = 4.dp)) Box(modifier = Modifier.width(0.5.dp).fillMaxHeight().background(Color(0xFFE5E5E5))) - Text(displayVal, fontSize = 11.sp, color = if (liveVal != null) Color(0xFF212529) else Color(0xFF6C757D), maxLines = 1, modifier = Modifier.width(widthValue).padding(horizontal = 4.dp)) + Text(displayVal, style = SignalTableTextStyle, color = if (liveVal != null) Color(0xFF212529) else Color(0xFF6C757D), maxLines = 1, overflow = TextOverflow.Ellipsis, softWrap = false, modifier = Modifier.width(widthValue).padding(horizontal = 4.dp)) Box(modifier = Modifier.width(0.5.dp).fillMaxHeight().background(Color(0xFFE5E5E5))) - Text(sig.unit, fontSize = 11.sp, color = Color(0xFF212529), maxLines = 1, modifier = Modifier.width(widthUnit).padding(horizontal = 4.dp)) + Text(sig.unit, style = SignalTableTextStyle, color = Color(0xFF212529), maxLines = 1, overflow = TextOverflow.Ellipsis, softWrap = false, modifier = Modifier.width(widthUnit).padding(horizontal = 4.dp)) Box(modifier = Modifier.width(0.5.dp).fillMaxHeight().background(Color(0xFFE5E5E5))) EditableTableColorCell( colorValue = sig.color, @@ -207,21 +216,21 @@ fun WindowsClassicCanSignalTableWindow( ) if (isExpanded) { Box(modifier = Modifier.width(0.5.dp).fillMaxHeight().background(Color(0xFFE5E5E5))) - Text(sig.messageName.ifEmpty { "Msg_${sig.idx}" }, fontSize = 10.sp, color = Color(0xFF212529), maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.width(widthMsg).padding(horizontal = 4.dp)) + Text(sig.messageName.ifEmpty { "Msg_${sig.idx}" }, style = SignalTableDetailTextStyle, color = Color(0xFF212529), maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.width(widthMsg).padding(horizontal = 4.dp)) Box(modifier = Modifier.width(0.5.dp).fillMaxHeight().background(Color(0xFFE5E5E5))) - Text(String.format(Locale.US, "0x%04X", sig.messageId), fontSize = 10.sp, fontFamily = FontFamily.Monospace, color = Color(0xFF6C757D), maxLines = 1, modifier = Modifier.width(widthMsgId).padding(horizontal = 4.dp)) + Text(String.format(Locale.US, "0x%04X", sig.messageId), style = SignalTableDetailTextStyle, fontFamily = FontFamily.Monospace, color = Color(0xFF6C757D), maxLines = 1, overflow = TextOverflow.Ellipsis, softWrap = false, modifier = Modifier.width(widthMsgId).padding(horizontal = 4.dp)) Box(modifier = Modifier.width(0.5.dp).fillMaxHeight().background(Color(0xFFE5E5E5))) - Text(String.format(Locale.US, "%.3f", sig.min), fontSize = 10.sp, color = Color(0xFF6C757D), maxLines = 1, modifier = Modifier.width(widthMin).padding(horizontal = 4.dp)) + Text(String.format(Locale.US, "%.3f", sig.min), style = SignalTableDetailTextStyle, color = Color(0xFF6C757D), maxLines = 1, overflow = TextOverflow.Ellipsis, softWrap = false, modifier = Modifier.width(widthMin).padding(horizontal = 4.dp)) Box(modifier = Modifier.width(0.5.dp).fillMaxHeight().background(Color(0xFFE5E5E5))) - Text(String.format(Locale.US, "%.3f", sig.max), fontSize = 10.sp, color = Color(0xFF6C757D), maxLines = 1, modifier = Modifier.width(widthMax).padding(horizontal = 4.dp)) + Text(String.format(Locale.US, "%.3f", sig.max), style = SignalTableDetailTextStyle, color = Color(0xFF6C757D), maxLines = 1, overflow = TextOverflow.Ellipsis, softWrap = false, modifier = Modifier.width(widthMax).padding(horizontal = 4.dp)) Box(modifier = Modifier.width(0.5.dp).fillMaxHeight().background(Color(0xFFE5E5E5))) - Text("${sig.startBit}", fontSize = 10.sp, color = Color(0xFF6C757D), maxLines = 1, modifier = Modifier.width(widthStartBit).padding(horizontal = 4.dp)) + Text("${sig.startBit}", style = SignalTableDetailTextStyle, color = Color(0xFF6C757D), maxLines = 1, overflow = TextOverflow.Ellipsis, softWrap = false, modifier = Modifier.width(widthStartBit).padding(horizontal = 4.dp)) Box(modifier = Modifier.width(0.5.dp).fillMaxHeight().background(Color(0xFFE5E5E5))) - Text("${sig.bitLength}", fontSize = 10.sp, color = Color(0xFF6C757D), maxLines = 1, modifier = Modifier.width(widthLength).padding(horizontal = 4.dp)) + Text("${sig.bitLength}", style = SignalTableDetailTextStyle, color = Color(0xFF6C757D), maxLines = 1, overflow = TextOverflow.Ellipsis, softWrap = false, modifier = Modifier.width(widthLength).padding(horizontal = 4.dp)) Box(modifier = Modifier.width(0.5.dp).fillMaxHeight().background(Color(0xFFE5E5E5))) - Text(String.format(Locale.US, "%.3f", sig.cvsA), fontSize = 10.sp, color = Color(0xFF6C757D), maxLines = 1, modifier = Modifier.width(widthFactor).padding(horizontal = 4.dp)) + Text(String.format(Locale.US, "%.3f", sig.cvsA), style = SignalTableDetailTextStyle, color = Color(0xFF6C757D), maxLines = 1, overflow = TextOverflow.Ellipsis, softWrap = false, modifier = Modifier.width(widthFactor).padding(horizontal = 4.dp)) Box(modifier = Modifier.width(0.5.dp).fillMaxHeight().background(Color(0xFFE5E5E5))) - Text(String.format(Locale.US, "%.3f", sig.cvsB), fontSize = 10.sp, color = Color(0xFF6C757D), maxLines = 1, modifier = Modifier.width(widthOffset).padding(horizontal = 4.dp)) + Text(String.format(Locale.US, "%.3f", sig.cvsB), style = SignalTableDetailTextStyle, color = Color(0xFF6C757D), maxLines = 1, overflow = TextOverflow.Ellipsis, softWrap = false, modifier = Modifier.width(widthOffset).padding(horizontal = 4.dp)) } } } diff --git a/Android/app/src/main/java/com/dct/app/presentation/signaltree/CompensationMapView.kt b/Android/app/src/main/java/com/dct/app/presentation/signaltree/CompensationMapView.kt index f4ffcdd..08455ab 100644 --- a/Android/app/src/main/java/com/dct/app/presentation/signaltree/CompensationMapView.kt +++ b/Android/app/src/main/java/com/dct/app/presentation/signaltree/CompensationMapView.kt @@ -37,7 +37,7 @@ import java.util.Locale /** * 5. Compensation Type A & B 전용 데이터 테이블 창 (PC 캡처 화면 1:1 완벽 일치) - * [ 상단 Map 목록 테이블 | 중간 제어 바 (Compensation A/B : + Read/Write X/Y 2열 2행) | 하단: 좌측 2D 그래프 (~68%) + 우측 데이터 테이블 (~32%) ] + * [ 상단 Map 목록 | 중간 Read/Write 제어 | 하단: 가변 폭 그래프 + 우측 300dp 포인트 표 ] */ @Composable fun WindowsClassicCompensationTableWindow( @@ -137,7 +137,7 @@ fun WindowsClassicCompensationTableWindow( columns = remember(isTypeA) { compensationMapColumns(isTypeA) }, rows = filtered, statePrefix = "comp:map", - modifier = Modifier.fillMaxWidth().height(135.dp), + modifier = Modifier.fillMaxWidth().weight(1f), selectedIndex = selectedIndex, onRowClick = { index, _ -> selectedIndex = index }, rowKey = { "comp_${it.idx}_${it.yName}" }, @@ -290,16 +290,20 @@ fun WindowsClassicCompensationTableWindow( Spacer(modifier = Modifier.height(6.dp)) - // 5. 하단 영역: [ 좌측: 2D 보정 곡선 그래프 (~68%) | 우측: 데이터 포인트 표 (~32%) ] + // Graph and table share the same capped height, including font scaling. + val pointTableHeight = compensationRowHeight() * ((selectedMap?.points?.size ?: 0) + 1) Row( modifier = Modifier .weight(1f) + .wrapContentHeight(Alignment.Top) + .heightIn(max = pointTableHeight) + .fillMaxHeight() .fillMaxWidth() ) { // 5.1 좌측 2D 그래프 (PC CChartCtrl 스타일) Box( modifier = Modifier - .weight(0.68f) + .weight(1f) .fillMaxHeight() .background(Color.White) .border(1.dp, Color(0xFF7F9DB9)) @@ -324,7 +328,8 @@ fun WindowsClassicCompensationTableWindow( columns = remember(isTypeA) { compensationPointColumns(isTypeA) }, rows = selectedMap?.points ?: emptyList(), statePrefix = "comp:point", - modifier = Modifier.weight(0.32f).fillMaxHeight(), + modifier = Modifier.width(300.dp).fillMaxHeight(), + fitRows = true, onRowClick = { _, pt -> fun rawText(value: Int, unsigned: Boolean) = if (unsigned) (value.toLong() and 0xFFFFFFFFL).toString() else value.toString() diff --git a/Android/app/src/main/java/com/dct/app/presentation/signaltree/CompensationTable.kt b/Android/app/src/main/java/com/dct/app/presentation/signaltree/CompensationTable.kt index de18f0d..68b1a0f 100644 --- a/Android/app/src/main/java/com/dct/app/presentation/signaltree/CompensationTable.kt +++ b/Android/app/src/main/java/com/dct/app/presentation/signaltree/CompensationTable.kt @@ -6,6 +6,7 @@ import androidx.compose.foundation.clickable import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.rememberScrollState import androidx.compose.material3.Text @@ -25,6 +26,16 @@ import com.dct.app.domain.model.mdb.CompensationMap import com.dct.app.domain.model.mdb.MapPoint import java.util.Locale +/** Shared by the point table and its adjacent graph so their bottom edges match. */ +@Composable +internal fun compensationRowHeight(): androidx.compose.ui.unit.Dp { + val density = LocalDensity.current + val measurer = rememberTextMeasurer() + val style = TextStyle(fontSize = 11.sp, lineHeight = 14.sp, fontWeight = FontWeight.Bold, + platformStyle = androidx.compose.ui.text.PlatformTextStyle(includeFontPadding = false)) + return with(density) { maxOf(24.dp, measurer.measure("Ag", style).size.height.toDp() + 8.dp) } +} + internal data class CompensationColumn( val title: String, val defaultWidth: Float, @@ -69,6 +80,7 @@ internal fun CompensationTable( statePrefix: String, modifier: Modifier = Modifier, selectedIndex: Int = -1, + fitRows: Boolean = false, onRowClick: (Int, T) -> Unit, rowKey: ((T) -> Any)? = null, onDescriptionChange: ((T, String) -> Unit)? = null @@ -82,38 +94,59 @@ internal fun CompensationTable( fun measuredWidth(text: String) = with(density) { measurer.measure(text, headerStyle, maxLines = 1, softWrap = false).size.width.toDp() } - // Includes the 8dp text padding and the 12dp resize grip of the common header. - val minima = columns.map { maxOf(32.dp, measuredWidth(it.title) + 20.dp) } + // Long name/description columns reserve room for the auto-fit glyph. Other + // columns only need the text padding and resize grip. + val minima = columns.map { column -> + val headerExtra = if (column.title.endsWith("NAME") || column.title == "DESCRIPTION") 35.dp else 20.dp + maxOf(32.dp, measuredWidth(column.title) + headerExtra) + } val columnWidths = columns.mapIndexed { index, column -> maxOf(minima[index], widths["$statePrefix:${column.title}"] ?: column.defaultWidth.dp) } - val rowHeight = with(density) { - maxOf(24.dp, measurer.measure("Ag", headerStyle).size.height.toDp() + 8.dp) - } + val rowHeight = compensationRowHeight() - BoxWithConstraints(modifier.background(Color.White).border(1.dp, Color(0xFF7F9DB9))) { + BoxWithConstraints(modifier) { val totalWidth = maxOf(maxWidth, columnWidths.sumOf { it.value.toDouble() }.toFloat().dp) val displayedWidths = columnWidths.toMutableList().also { it[it.lastIndex] += totalWidth - columnWidths.sumOf { width -> width.value.toDouble() }.toFloat().dp } - Box(Modifier.fillMaxSize().horizontalScroll(rememberScrollState())) { + val tableListState = rememberLazyListState() + // Cap the painted table (including its border) at the last row. Shorter + // viewports still use the same lazy scrolling, including large font sizes. + val contentHeight = rowHeight * (rows.size + 1) + Box(Modifier + .then(if (fitRows) Modifier.heightIn(max = contentHeight) else Modifier) + .fillMaxSize() + .background(Color.White).border(1.dp, Color(0xFF7F9DB9)) + .slimLazyListScrollbar(tableListState).horizontalScroll(rememberScrollState())) { Column(Modifier.width(totalWidth).fillMaxHeight()) { Row(Modifier.fillMaxWidth().height(rowHeight).background(Color(0xFFE2E2E2))) { columns.forEachIndexed { index, column -> val key = "$statePrefix:${column.title}" + val collapsedWidth = maxOf(minima[index], column.defaultWidth.dp) + val canAutoFit = column.title.endsWith("NAME") || column.title == "DESCRIPTION" + val editableDescription = column.title == "DESCRIPTION" && onDescriptionChange != null + val fittedWidth = if (canAutoFit) rememberAutoFitWidth( + column.title, rows, collapsedWidth, + bodyStyle = if (editableDescription) SignalTableDetailTextStyle else headerStyle, + headerStyle = headerStyle, bodyPadding = if (editableDescription) 18.dp else 8.dp, + text = column.text + ) else collapsedWidth ResizableTableHeaderCell( title = column.title, width = displayedWidths[index], textStyle = headerStyle, modifier = Modifier.testTag("$statePrefix:header:${column.title}"), - onDoubleTap = { - val contentWidth = rows.maxOfOrNull { measuredWidth(column.text(it)).value } ?: 0f - widths[key] = maxOf(minima[index], (contentWidth + 20f).coerceAtMost(1200f).dp) - }, - onWidthChange = { widths[key] = maxOf(minima[index], it) } + onAutoFitClick = { widths.toggleAutoFit(key, collapsedWidth, fittedWidth, displayedWidths[index]) }, + onWidthChange = { widths[key] = maxOf(minima[index], it) }, + isAutoFit = widths.isAutoFit(key, fittedWidth, displayedWidths[index]), + showAutoFit = canAutoFit ) } } - LazyColumn(Modifier.fillMaxWidth().weight(1f)) { + LazyColumn( + state = tableListState, + modifier = Modifier.fillMaxWidth().weight(1f) + ) { itemsIndexed(rows, key = { index, row -> rowKey?.invoke(row) ?: index }) { index, row -> val selected = selectedIndex == index Row(Modifier.fillMaxWidth().height(rowHeight).clickable { onRowClick(index, row) }) { diff --git a/Android/app/src/main/java/com/dct/app/presentation/signaltree/ConstantTableView.kt b/Android/app/src/main/java/com/dct/app/presentation/signaltree/ConstantTableView.kt index 41f9c30..e4ec785 100644 --- a/Android/app/src/main/java/com/dct/app/presentation/signaltree/ConstantTableView.kt +++ b/Android/app/src/main/java/com/dct/app/presentation/signaltree/ConstantTableView.kt @@ -5,6 +5,7 @@ import androidx.compose.foundation.clickable import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.text.KeyboardOptions @@ -76,7 +77,12 @@ fun WindowsClassicConstantTableWindow( val style = TextStyle(fontSize = 11.sp, lineHeight = 14.sp, platformStyle = PlatformTextStyle(includeFontPadding = false)) val bold = style.copy(fontWeight = FontWeight.Bold) fun textWidth(text: String) = with(density) { measurer.measure(text, bold, maxLines = 1, softWrap = false).size.width.toDp() } - val minimumWidths = columns.map { maxOf(32.dp, textWidth(it.title) + 20.dp) } + // Reserve room for the resize grip; the auto-fit glyph is only shown for + // the potentially long Name/Description columns below. + val minimumWidths = columns.map { column -> + val headerExtra = if (column.key == "name" || column.key == "desc") 35.dp else 20.dp + maxOf(32.dp, textWidth(column.title) + headerExtra) + } val actualWidths = columns.mapIndexed { i, col -> maxOf(minimumWidths[i], widths[col.key] ?: col.defaultWidth.dp) } val rowHeight = with(density) { maxOf(32.dp, measurer.measure("Ag", bold).size.height.toDp() + 8.dp) } @@ -92,20 +98,32 @@ fun WindowsClassicConstantTableWindow( val baseWidth = actualWidths.sumOf { it.value.toDouble() }.toFloat().dp + 40.dp val tableWidth = maxOf(maxWidth, baseWidth) val displayWidths = actualWidths.toMutableList().also { it[it.lastIndex] += tableWidth - baseWidth } - Box(Modifier.fillMaxSize().horizontalScroll(rememberScrollState())) { + val tableListState = rememberLazyListState() + Box(Modifier.fillMaxSize().slimLazyListScrollbar(tableListState).horizontalScroll(rememberScrollState())) { Column(Modifier.width(tableWidth).fillMaxHeight()) { Row(Modifier.fillMaxWidth().height(rowHeight).background(Color(0xFFE9ECEF))) { Box(Modifier.width(40.dp).fillMaxHeight()) columns.forEachIndexed { i, col -> + val canAutoFit = col.key == "name" || col.key == "desc" + val collapsedWidth = maxOf(minimumWidths[i], col.defaultWidth.dp) + val fittedWidth = if (canAutoFit) rememberAutoFitWidth( + col.title, filtered, collapsedWidth, + bodyStyle = if (col.key == "desc") SignalTableDetailTextStyle else style, + headerStyle = bold, bodyPadding = if (col.key == "desc") 18.dp else 8.dp, + text = col.value + ) else collapsedWidth ResizableTableHeaderCell(col.title, displayWidths[i], modifier = Modifier.testTag("constant:header:${col.key}"), textStyle = bold, - onDoubleTap = { - val content = filtered.maxOfOrNull { textWidth(col.value(it)).value } ?: 0f - widths[col.key] = maxOf(minimumWidths[i], (content + 20f).coerceAtMost(1200f).dp) - }, onWidthChange = { widths[col.key] = maxOf(minimumWidths[i], it) }) + onAutoFitClick = { widths.toggleAutoFit(col.key, collapsedWidth, fittedWidth, displayWidths[i]) }, + onWidthChange = { widths[col.key] = maxOf(minimumWidths[i], it) }, + isAutoFit = widths.isAutoFit(col.key, fittedWidth, displayWidths[i]), + showAutoFit = canAutoFit) } } - LazyColumn(Modifier.weight(1f).fillMaxWidth()) { + LazyColumn( + state = tableListState, + modifier = Modifier.weight(1f).fillMaxWidth() + ) { itemsIndexed(filtered, key = { _, con -> identity(con) }) { index, con -> Row(Modifier.fillMaxWidth().height(rowHeight) .background(if (index % 2 == 0) Color.White else Color(0xFFF8F9FA))) { diff --git a/Android/app/src/main/java/com/dct/app/presentation/signaltree/MdbSignalTableView.kt b/Android/app/src/main/java/com/dct/app/presentation/signaltree/MdbSignalTableView.kt index 3b987d7..032baa8 100644 --- a/Android/app/src/main/java/com/dct/app/presentation/signaltree/MdbSignalTableView.kt +++ b/Android/app/src/main/java/com/dct/app/presentation/signaltree/MdbSignalTableView.kt @@ -7,12 +7,14 @@ import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.rememberScrollState import androidx.compose.material3.Text import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Color import androidx.compose.ui.input.pointer.pointerInput @@ -20,9 +22,7 @@ import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.layout.positionInRoot import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp import com.dct.app.domain.model.mdb.MdbDatabase import com.dct.app.domain.model.mdb.SignalDefinition import com.dct.app.presentation.chart.DraggedSignal @@ -44,10 +44,11 @@ fun WindowsClassicMdbSignalTableWindow( onDescriptionChange: (SignalDefinition, String) -> Unit = { _, _ -> }, modifier: Modifier = Modifier ) { + val rowHeight = signalTableRowHeight(LocalDensity.current) var searchQuery by rememberWindowString("search") var isExpanded by rememberWindowBoolean("expanded") var selectedSignalKey by rememberWindowNullableString("selected_signal") - val signals = database?.allSignals ?: emptyList() + val signals = remember(database) { database?.allSignals ?: emptyList() } val filteredSignals = remember(signals, searchQuery) { if (searchQuery.isBlank()) signals else signals.filter { it.name.contains(searchQuery, true) } @@ -55,14 +56,8 @@ fun WindowsClassicMdbSignalTableWindow( val customWidths = rememberWindowColumnWidths() - fun toggleAutoFit(key: String, defaultW: Dp, items: List, extraPadding: Dp = 16.dp) { - val autoW = calculateAutoFitWidth(key, items, defaultW, extraPadding = extraPadding) - if (customWidths.containsKey(key)) { - customWidths.remove(key) - } else { - customWidths[key] = autoW - } - } + val nameAutoFitWidth = rememberAutoFitWidth("Signal name", filteredSignals, 110.dp) { it.name } + val descAutoFitWidth = rememberAutoFitWidth("Description", filteredSignals, 160.dp, bodyStyle = SignalTableDetailTextStyle) { it.description } val widthName = customWidths["name"] ?: 110.dp val widthValue = customWidths["value"] ?: 65.dp @@ -88,10 +83,12 @@ fun WindowsClassicMdbSignalTableWindow( ) // 가로 스크롤 가능한 테이블 컨테이너 + val tableListState = rememberLazyListState() Box( modifier = Modifier .weight(1f) .fillMaxWidth() + .slimLazyListScrollbar(tableListState) .horizontalScroll(rememberScrollState()) ) { val totalWidth = widthName + widthValue + widthUnit + widthColor + widthDesc + @@ -102,27 +99,30 @@ fun WindowsClassicMdbSignalTableWindow( Row( modifier = Modifier .fillMaxWidth() - .height(24.dp) + .height(rowHeight) .background(Color(0xFFE9ECEF)) .border(1.dp, Color(0xFFCED4DA)), verticalAlignment = Alignment.CenterVertically ) { - ResizableTableHeaderCell("Signal name", widthName, onWidthChange = { customWidths["name"] = it }, onDoubleTap = { toggleAutoFit("name", 110.dp, filteredSignals.map { it.name }) }) - ResizableTableHeaderCell("Value", widthValue, onWidthChange = { customWidths["value"] = it }, onDoubleTap = { toggleAutoFit("value", 65.dp, filteredSignals.map { sig -> val v = liveTelemetryValues["sig_${sig.reclid}_${sig.idx}"] ?: liveTelemetryValues[sig.name]; sig.formatDisplayValue(v) }) }) - ResizableTableHeaderCell("Unit", widthUnit, onWidthChange = { customWidths["unit"] = it }, onDoubleTap = { toggleAutoFit("unit", 45.dp, filteredSignals.map { it.unit }) }) - ResizableTableHeaderCell("Color", widthColor, onWidthChange = { customWidths["color"] = it }, onDoubleTap = { toggleAutoFit("color", 55.dp, emptyList()) }) - ResizableTableHeaderCell("Description", widthDesc, onWidthChange = { customWidths["desc"] = it }, onDoubleTap = { toggleAutoFit("desc", 160.dp, filteredSignals.map { it.description }) }, showDivider = isExpanded) + ResizableTableHeaderCell("Signal name", widthName, onWidthChange = { customWidths["name"] = it }, onAutoFitClick = { customWidths.toggleAutoFit("name", 110.dp, nameAutoFitWidth) }, isAutoFit = customWidths.isAutoFit("name", nameAutoFitWidth), showAutoFit = true) + ResizableTableHeaderCell("Value", widthValue, onWidthChange = { customWidths["value"] = it }) + ResizableTableHeaderCell("Unit", widthUnit, onWidthChange = { customWidths["unit"] = it }) + ResizableTableHeaderCell("Color", widthColor, onWidthChange = { customWidths["color"] = it }) + ResizableTableHeaderCell("Description", widthDesc, onWidthChange = { customWidths["desc"] = it }, onAutoFitClick = { customWidths.toggleAutoFit("desc", 160.dp, descAutoFitWidth) }, showDivider = isExpanded, isAutoFit = customWidths.isAutoFit("desc", descAutoFitWidth), showAutoFit = true) if (isExpanded) { - ResizableTableHeaderCell("Min", widthMin, onWidthChange = { customWidths["min"] = it }, onDoubleTap = { toggleAutoFit("min", 55.dp, filteredSignals.map { String.format(Locale.US, "%.3f", it.min) }) }) - ResizableTableHeaderCell("Max", widthMax, onWidthChange = { customWidths["max"] = it }, onDoubleTap = { toggleAutoFit("max", 55.dp, filteredSignals.map { String.format(Locale.US, "%.3f", it.max) }) }) - ResizableTableHeaderCell("ReclID", widthReclid, onWidthChange = { customWidths["reclid"] = it }, onDoubleTap = { toggleAutoFit("reclid", 50.dp, filteredSignals.map { it.reclid.toString() }) }) - ResizableTableHeaderCell("Index", widthIdx, onWidthChange = { customWidths["idx"] = it }, onDoubleTap = { toggleAutoFit("idx", 50.dp, filteredSignals.map { it.idx.toString() }) }) - ResizableTableHeaderCell("Value (Hex)", widthHex, onWidthChange = { customWidths["hex"] = it }, onDoubleTap = { toggleAutoFit("hex", 60.dp, filteredSignals.map { sig -> val v = liveTelemetryValues["sig_${sig.reclid}_${sig.idx}"] ?: liveTelemetryValues[sig.name]; sig.formatRawHex(liveTelemetryValues["raw_sig_${sig.reclid}_${sig.idx}"]?.toLong()) }) }, showDivider = false) + ResizableTableHeaderCell("Min", widthMin, onWidthChange = { customWidths["min"] = it }) + ResizableTableHeaderCell("Max", widthMax, onWidthChange = { customWidths["max"] = it }) + ResizableTableHeaderCell("ReclID", widthReclid, onWidthChange = { customWidths["reclid"] = it }) + ResizableTableHeaderCell("Index", widthIdx, onWidthChange = { customWidths["idx"] = it }) + ResizableTableHeaderCell("Value (Hex)", widthHex, onWidthChange = { customWidths["hex"] = it }, showDivider = false) } } // Table Rows - LazyColumn(modifier = Modifier.fillMaxSize()) { + LazyColumn( + state = tableListState, + modifier = Modifier.fillMaxSize() + ) { itemsIndexed(filteredSignals, key = { _, s -> "mdb_${s.reclid}_${s.idx}_${s.name}" }) { index, sig -> val sigKey = "sig_${sig.reclid}_${sig.idx}" val isSelected = (sigKey == selectedSignalKey) @@ -135,7 +135,7 @@ fun WindowsClassicMdbSignalTableWindow( Row( modifier = Modifier .fillMaxWidth() - .height(24.dp) + .height(rowHeight) .background(if (isSelected) Color(0xFFCCE8FF) else if (index % 2 == 0) Color.White else Color(0xFFF8F9FA)) .border(0.5.dp, Color(0xFFE5E5E5)) .onGloballyPositioned { coordinates -> @@ -176,11 +176,11 @@ fun WindowsClassicMdbSignalTableWindow( }, verticalAlignment = Alignment.CenterVertically ) { - Text(sig.name, fontSize = 11.sp, color = Color(0xFF212529), maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.width(widthName).padding(horizontal = 4.dp)) + Text(sig.name, style = SignalTableTextStyle, color = Color(0xFF212529), maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.width(widthName).padding(horizontal = 4.dp)) Box(modifier = Modifier.width(0.5.dp).fillMaxHeight().background(Color(0xFFE5E5E5))) - Text(displayVal, fontSize = 11.sp, color = Color(0xFF212529), maxLines = 1, modifier = Modifier.width(widthValue).padding(horizontal = 4.dp)) + Text(displayVal, style = SignalTableTextStyle, color = Color(0xFF212529), maxLines = 1, overflow = TextOverflow.Ellipsis, softWrap = false, modifier = Modifier.width(widthValue).padding(horizontal = 4.dp)) Box(modifier = Modifier.width(0.5.dp).fillMaxHeight().background(Color(0xFFE5E5E5))) - Text(sig.unit, fontSize = 11.sp, color = Color(0xFF212529), maxLines = 1, modifier = Modifier.width(widthUnit).padding(horizontal = 4.dp)) + Text(sig.unit, style = SignalTableTextStyle, color = Color(0xFF212529), maxLines = 1, overflow = TextOverflow.Ellipsis, softWrap = false, modifier = Modifier.width(widthUnit).padding(horizontal = 4.dp)) Box(modifier = Modifier.width(0.5.dp).fillMaxHeight().background(Color(0xFFE5E5E5))) EditableTableColorCell( colorValue = sig.color, @@ -195,15 +195,15 @@ fun WindowsClassicMdbSignalTableWindow( ) if (isExpanded) { Box(modifier = Modifier.width(0.5.dp).fillMaxHeight().background(Color(0xFFE5E5E5))) - Text(String.format(Locale.US, "%.3f", sig.min), fontSize = 10.sp, color = Color(0xFF6C757D), maxLines = 1, modifier = Modifier.width(widthMin).padding(horizontal = 4.dp)) + Text(String.format(Locale.US, "%.3f", sig.min), style = SignalTableDetailTextStyle, color = Color(0xFF6C757D), maxLines = 1, overflow = TextOverflow.Ellipsis, softWrap = false, modifier = Modifier.width(widthMin).padding(horizontal = 4.dp)) Box(modifier = Modifier.width(0.5.dp).fillMaxHeight().background(Color(0xFFE5E5E5))) - Text(String.format(Locale.US, "%.3f", sig.max), fontSize = 10.sp, color = Color(0xFF6C757D), maxLines = 1, modifier = Modifier.width(widthMax).padding(horizontal = 4.dp)) + Text(String.format(Locale.US, "%.3f", sig.max), style = SignalTableDetailTextStyle, color = Color(0xFF6C757D), maxLines = 1, overflow = TextOverflow.Ellipsis, softWrap = false, modifier = Modifier.width(widthMax).padding(horizontal = 4.dp)) Box(modifier = Modifier.width(0.5.dp).fillMaxHeight().background(Color(0xFFE5E5E5))) - Text(String.format(Locale.US, "0x%02X", sig.reclid), fontSize = 10.sp, fontFamily = FontFamily.Monospace, color = Color(0xFF6C757D), maxLines = 1, modifier = Modifier.width(widthReclid).padding(horizontal = 4.dp)) + Text(String.format(Locale.US, "0x%02X", sig.reclid), style = SignalTableDetailTextStyle, fontFamily = FontFamily.Monospace, color = Color(0xFF6C757D), maxLines = 1, overflow = TextOverflow.Ellipsis, softWrap = false, modifier = Modifier.width(widthReclid).padding(horizontal = 4.dp)) Box(modifier = Modifier.width(0.5.dp).fillMaxHeight().background(Color(0xFFE5E5E5))) - Text(String.format(Locale.US, "0x%02X", sig.idx), fontSize = 10.sp, fontFamily = FontFamily.Monospace, color = Color(0xFF6C757D), maxLines = 1, modifier = Modifier.width(widthIdx).padding(horizontal = 4.dp)) + Text(String.format(Locale.US, "0x%02X", sig.idx), style = SignalTableDetailTextStyle, fontFamily = FontFamily.Monospace, color = Color(0xFF6C757D), maxLines = 1, overflow = TextOverflow.Ellipsis, softWrap = false, modifier = Modifier.width(widthIdx).padding(horizontal = 4.dp)) Box(modifier = Modifier.width(0.5.dp).fillMaxHeight().background(Color(0xFFE5E5E5))) - Text(hexVal, fontSize = 10.sp, fontFamily = FontFamily.Monospace, color = Color(0xFF6C757D), maxLines = 1, modifier = Modifier.width(widthHex).padding(horizontal = 4.dp)) + Text(hexVal, style = SignalTableDetailTextStyle, fontFamily = FontFamily.Monospace, color = Color(0xFF6C757D), maxLines = 1, overflow = TextOverflow.Ellipsis, softWrap = false, modifier = Modifier.width(widthHex).padding(horizontal = 4.dp)) } } } diff --git a/Android/app/src/main/java/com/dct/app/presentation/signaltree/SignalTableTextStyles.kt b/Android/app/src/main/java/com/dct/app/presentation/signaltree/SignalTableTextStyles.kt new file mode 100644 index 0000000..6f73b05 --- /dev/null +++ b/Android/app/src/main/java/com/dct/app/presentation/signaltree/SignalTableTextStyles.kt @@ -0,0 +1,25 @@ +package com.dct.app.presentation.signaltree + +import androidx.compose.ui.text.PlatformTextStyle +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.style.LineHeightStyle +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp + +// Do not inherit Material bodyLarge's 24sp line height in compact signal rows. +internal val SignalTableTextStyle = TextStyle( + fontSize = 11.sp, + lineHeight = 14.sp, + platformStyle = PlatformTextStyle(includeFontPadding = false), + lineHeightStyle = LineHeightStyle(LineHeightStyle.Alignment.Center, LineHeightStyle.Trim.Both) +) + +internal val SignalTableDetailTextStyle = SignalTableTextStyle.copy(fontSize = 10.sp, lineHeight = 13.sp) +internal val SignalTableColorTextStyle = SignalTableTextStyle.copy(fontSize = 9.sp, lineHeight = 12.sp) + +// Keep the normal compact height, but reserve vertical padding for larger system fonts. +internal fun signalTableRowHeight(density: Density): Dp = with(density) { + maxOf(24.dp, SignalTableTextStyle.lineHeight.toDp() + 4.dp) +} diff --git a/Android/app/src/main/java/com/dct/app/presentation/signaltree/SignalTreeModels.kt b/Android/app/src/main/java/com/dct/app/presentation/signaltree/SignalTreeModels.kt index 1b4329e..bf9a457 100644 --- a/Android/app/src/main/java/com/dct/app/presentation/signaltree/SignalTreeModels.kt +++ b/Android/app/src/main/java/com/dct/app/presentation/signaltree/SignalTreeModels.kt @@ -1,6 +1,7 @@ package com.dct.app.presentation.signaltree import com.dct.app.domain.model.chart.AxisPosition +import com.dct.app.core.theme.PcColorRef import com.dct.app.domain.model.mdb.CompensationMap import com.dct.app.domain.model.mdb.MdbAttrType import com.dct.app.domain.model.mdb.MdbConstant @@ -42,7 +43,7 @@ data class SignalTreeNode( val cvsB: Double = 0.0, val min: Double = 0.0, val max: Double = 100.0, - val color: Long = 0xFF0052CCL, + val color: Long = PcColorRef.DEFAULT_ACCENT, val currentValue: Double = 0.0, val groupReclid: Int? = null, val parentId: String? = null, diff --git a/Android/app/src/main/java/com/dct/app/presentation/signaltree/TableAutoFit.kt b/Android/app/src/main/java/com/dct/app/presentation/signaltree/TableAutoFit.kt new file mode 100644 index 0000000..d758717 --- /dev/null +++ b/Android/app/src/main/java/com/dct/app/presentation/signaltree/TableAutoFit.kt @@ -0,0 +1,48 @@ +package com.dct.app.presentation.signaltree + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.rememberTextMeasurer +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp + +internal fun calculateAutoFitWidth( + defaultWidth: Dp, + headerWidth: Dp, + contentWidth: Dp, + bodyPadding: Dp = 8.dp +): Dp = maxOf( + defaultWidth, + // Header text padding + toggle + resize grip. One extra dp prevents edge clipping. + headerWidth + 35.dp + 1.dp, + contentWidth + bodyPadding + 1.dp +).coerceIn(32.dp, 1200.dp) + +/** Measure immutable display metadata, not packet values. The extractor must depend only on the item. */ +@Composable +internal fun rememberAutoFitWidth( + title: String, + items: List, + defaultWidth: Dp, + bodyStyle: TextStyle = SignalTableTextStyle, + headerStyle: TextStyle = SignalTableTextStyle, + bodyPadding: Dp = 8.dp, + text: (T) -> String +): Dp { + val measurer = rememberTextMeasurer() + val density = LocalDensity.current + val header = headerStyle.copy(fontSize = 11.sp, fontWeight = FontWeight.Bold) + return remember(title, items, defaultWidth, bodyStyle, header, bodyPadding, measurer, density) { + with(density) { + val headerWidth = measurer.measure(title, header, maxLines = 1, softWrap = false).size.width.toDp() + val contentWidth = items.maxOfOrNull { + measurer.measure(text(it), bodyStyle, maxLines = 1, softWrap = false).size.width + }?.toDp() ?: 0.dp + calculateAutoFitWidth(defaultWidth, headerWidth, contentWidth, bodyPadding) + } + } +} diff --git a/Android/app/src/main/java/com/dct/app/presentation/signaltree/TableCommonComponents.kt b/Android/app/src/main/java/com/dct/app/presentation/signaltree/TableCommonComponents.kt index 5152456..0d736e8 100644 --- a/Android/app/src/main/java/com/dct/app/presentation/signaltree/TableCommonComponents.kt +++ b/Android/app/src/main/java/com/dct/app/presentation/signaltree/TableCommonComponents.kt @@ -1,20 +1,32 @@ package com.dct.app.presentation.signaltree +import androidx.activity.compose.BackHandler import androidx.compose.foundation.Canvas import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.gestures.detectDragGestures import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.gestures.awaitEachGesture +import androidx.compose.foundation.gestures.awaitFirstDown import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.BasicTextField import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.LastPage +import androidx.compose.material.icons.filled.FirstPage import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.composed +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.drawWithContent import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.onFocusChanged @@ -22,9 +34,25 @@ import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.luminance +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.input.key.KeyEventType +import androidx.compose.ui.input.key.key +import androidx.compose.ui.input.key.onKeyEvent +import androidx.compose.ui.input.key.type import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.input.pointer.PointerEventPass +import androidx.compose.ui.input.pointer.positionChange +import android.graphics.Rect +import android.view.ViewTreeObserver +import androidx.core.view.ViewCompat +import androidx.core.view.WindowInsetsCompat import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.platform.LocalView +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.ImeAction @@ -33,64 +61,154 @@ import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.dct.app.core.theme.toComposeColor +import com.dct.app.core.theme.SignalColorPresets +import com.dct.app.core.theme.toColorRef +import com.dct.app.presentation.monitor.components.LocalWorkspaceTouchNavigation +import kotlinx.coroutines.launch import java.util.Locale import kotlin.math.atan2 import kotlin.math.cos import kotlin.math.sin import kotlin.math.sqrt -/** - * 텍스트의 실제 글꼴 너비(11sp 기준)를 문자 단위로 정확하게 측정하는 함수 - */ -fun measureTextVisualWidthDp(text: String): Float { - var width = 0f - for (ch in text) { - if (ch.code < 128) { - width += when (ch) { - 'i', 'l', 'I', '.', ',', ':', ';', '!', '|', '\'', '`' -> 4.0f - '1', 't', 'f', 'j', 'r', '(', ')', '[', ']', '{', '}', '/', '\\', ' ' -> 5.2f - '-', '_' -> 5.8f - 'w', 'm', 'W', 'M', '@', '%', '&', '#' -> 10.2f - 'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'J', 'K', 'L', 'N', 'O', 'P', 'Q', 'R', 'S', 'T', 'U', 'V', 'X', 'Y', 'Z' -> 8.2f - else -> 6.8f // 0..9 digits and standard lowercase English letters +/** Compact, draggable scrollbar shared by every table/list MDI window. */ +@Composable +fun SlimLazyListScrollbar( + state: LazyListState, + modifier: Modifier = Modifier +) { + val workspaceNavigation = LocalWorkspaceTouchNavigation.current + val info = state.layoutInfo + val total = info.totalItemsCount + val visible = info.visibleItemsInfo + if (total == 0 || visible.isEmpty() || visible.size >= total) return + + val scope = rememberCoroutineScope() + BoxWithConstraints( + modifier + .fillMaxHeight() + .width(7.dp) + .padding(vertical = 2.dp, horizontal = 1.dp) + ) { + val density = LocalDensity.current + val trackPx = constraints.maxHeight.toFloat().coerceAtLeast(1f) + val thumbPx = (visible.size.toFloat() / total * trackPx) + .coerceIn(with(density) { 20.dp.toPx() }.coerceAtMost(trackPx), trackPx) + val travelPx = (trackPx - thumbPx).coerceAtLeast(0f) + val maxIndex = (total - visible.size).coerceAtLeast(1) + val offsetPx = state.firstVisibleItemIndex.toFloat() / maxIndex * travelPx + + Box( + Modifier + .offset { androidx.compose.ui.unit.IntOffset(0, offsetPx.toInt()) } + .fillMaxWidth() + .height(with(density) { thumbPx.toDp() }) + .clip(RoundedCornerShape(3.dp)) + .background(Color(0xFF8B949E)) + .pointerInput(total, visible.size, workspaceNavigation) { + detectDragGestures(onDragStart = { workspaceNavigation?.claimSinglePointerDrag() }) { change, drag -> + change.consume() + if (travelPx > 0f) { + val target = (state.firstVisibleItemIndex + + drag.y / travelPx * maxIndex).toInt().coerceIn(0, total - 1) + scope.launch { state.scrollToItem(target) } + } + } + } + ) + } +} + +internal data class SlimScrollbarMetrics( + val thumbHeightPx: Float, + val thumbTopPx: Float, + val travelPx: Float, + val maxScrollPx: Float +) + +internal fun slimScrollbarMetrics( + itemCount: Int, + averageItemPx: Float, + viewportPx: Float, + trackPx: Float, + scrollPx: Float, + minThumbPx: Float +): SlimScrollbarMetrics? { + if (itemCount <= 0 || averageItemPx <= 0f || viewportPx <= 0f || trackPx <= 0f) return null + val contentPx = itemCount * averageItemPx + val maxScrollPx = (contentPx - viewportPx).coerceAtLeast(0f) + if (maxScrollPx <= 0f) return null + val thumbHeightPx = (trackPx * viewportPx / contentPx).coerceIn(minThumbPx.coerceAtMost(trackPx), trackPx) + val travelPx = (trackPx - thumbHeightPx).coerceAtLeast(0f) + val thumbTopPx = scrollPx.coerceIn(0f, maxScrollPx) / maxScrollPx * travelPx + return SlimScrollbarMetrics(thumbHeightPx, thumbTopPx, travelPx, maxScrollPx) +} + +/** Paint and drag the narrow thumb without intercepting touches in the table body. */ +fun Modifier.slimLazyListScrollbar(state: LazyListState): Modifier = composed { + val density = LocalDensity.current + val workspaceNavigation = LocalWorkspaceTouchNavigation.current + fun metrics(trackPx: Float): SlimScrollbarMetrics? { + val info = state.layoutInfo + val visible = info.visibleItemsInfo + if (visible.isEmpty()) return null + val averageItemPx = visible.sumOf { it.size }.toFloat() / visible.size + val viewportPx = (info.viewportEndOffset - info.viewportStartOffset).toFloat() + val scrollPx = state.firstVisibleItemIndex * averageItemPx + state.firstVisibleItemScrollOffset + return slimScrollbarMetrics(info.totalItemsCount, averageItemPx, viewportPx, trackPx, + scrollPx, with(density) { 20.dp.toPx() }) + } + drawWithContent { + drawContent() + val geometry = metrics(size.height) ?: return@drawWithContent + drawRoundRect( + color = Color(0xFF8B949E), + topLeft = Offset(size.width - 5.dp.toPx(), geometry.thumbTopPx), + size = androidx.compose.ui.geometry.Size(4.dp.toPx(), geometry.thumbHeightPx), + cornerRadius = androidx.compose.ui.geometry.CornerRadius(2.dp.toPx()) + ) + }.pointerInput(state, workspaceNavigation) { + val grabWidthPx = with(density) { 18.dp.toPx() } + val thumbSlopPx = with(density) { 8.dp.toPx() } + awaitEachGesture { + val down = awaitFirstDown(requireUnconsumed = false, pass = PointerEventPass.Initial) + val geometry = metrics(size.height.toFloat()) + if (geometry == null || geometry.travelPx <= 0f || + down.position.x < size.width - grabWidthPx || + down.position.y < geometry.thumbTopPx - thumbSlopPx || + down.position.y > geometry.thumbTopPx + geometry.thumbHeightPx + thumbSlopPx + ) return@awaitEachGesture + + workspaceNavigation?.claimSinglePointerDrag() + down.consume() + while (true) { + val event = awaitPointerEvent(PointerEventPass.Initial) + val change = event.changes.firstOrNull { it.id == down.id } ?: break + if (!change.pressed || event.changes.count { it.pressed } > 1) break + val dy = change.positionChange().y + val current = metrics(size.height.toFloat()) + if (dy != 0f && current != null && current.travelPx > 0f) { + state.dispatchRawDelta(dy / current.travelPx * current.maxScrollPx) + } + change.consume() } - } else { - width += 12.0f // Hangul / CJK full-width } } - return width } -/** - * 텍스트의 글꼴 및 문자별 너비를 정밀 계산하여 가장 긴 텍스트가 잘림 없이 온전히 보이도록 열 너비를 산출하는 헬퍼 함수 - */ -fun calculateAutoFitWidth( - headerTitle: String, - items: List, - defaultWidth: Dp, - extraPadding: Dp = 16.dp, - minWidth: Dp = 45.dp, - maxWidth: Dp = 600.dp -): Dp { - val validItems = items.filter { it.isNotBlank() } - val allTexts = if (validItems.isEmpty()) listOf(headerTitle) else validItems + headerTitle - // 헤더는 Bold이므로 1.15배 가중치 적용 - val maxVisualWidth = allTexts.maxOfOrNull { text -> - val visualW = measureTextVisualWidthDp(text) - if (text == headerTitle) visualW * 1.15f else visualW - } ?: (measureTextVisualWidthDp(headerTitle) * 1.15f) - val exactWidth = (maxVisualWidth + extraPadding.value).dp - return exactWidth.coerceIn(minWidth, maxWidth) -} /** - * 더블 탭으로 열 너비 자동 맞춤(Auto-Fit)을 지원하는 공통 테이블 헤더 셀 + * 열 너비를 직접 조절하고, 헤더 옆의 작은 토글로 Auto-Fit 할 수 있는 공통 헤더 셀. + * + * `onAutoFitClick`은 헤더 오른쪽의 열기/닫기 아이콘을 눌렀을 때만 호출된다. + * 기존 호출부와 프로젝트 호환을 위해 `onDoubleTap` 별칭도 남겨 두지만, 더 이상 + * 더블 탭 제스처를 등록하지 않는다. */ @Composable fun ResizableTableHeaderCell( title: String, width: Dp, - onDoubleTap: () -> Unit, + onAutoFitClick: (() -> Unit)? = null, modifier: Modifier = Modifier, textColor: Color = Color(0xFF212529), fontWeight: FontWeight = FontWeight.Bold, @@ -99,31 +217,36 @@ fun ResizableTableHeaderCell( contentAlignment: Alignment = Alignment.CenterStart, onWidthChange: ((Dp) -> Unit)? = null, onClick: (() -> Unit)? = null, - textStyle: androidx.compose.ui.text.TextStyle = androidx.compose.ui.text.TextStyle( - platformStyle = androidx.compose.ui.text.PlatformTextStyle(includeFontPadding = false) - ) + textStyle: androidx.compose.ui.text.TextStyle = SignalTableTextStyle, + /** True when this column currently uses its expanded/auto-fit width. */ + isAutoFit: Boolean = false, + /** Show the auto-fit toggle only for columns whose text can be truncated. */ + showAutoFit: Boolean = false, + /** @deprecated Use [onAutoFitClick]. This callback is also icon-only. */ + @Suppress("UNUSED_PARAMETER") onDoubleTap: (() -> Unit)? = null ) { val density = androidx.compose.ui.platform.LocalDensity.current.density val currentWidth by rememberUpdatedState(width) val currentResize by rememberUpdatedState(onWidthChange) + val workspaceNavigation = LocalWorkspaceTouchNavigation.current val currentClick by rememberUpdatedState(onClick) - val currentDoubleTap by rememberUpdatedState(onDoubleTap) + val currentAutoFit by rememberUpdatedState( + if (showAutoFit) onAutoFitClick ?: onDoubleTap else null + ) Row( modifier = modifier .width(width) - .fillMaxHeight() - .pointerInput(title) { - detectTapGestures( - onDoubleTap = { currentDoubleTap() }, - onTap = { currentClick?.invoke() } - ) - }, + .fillMaxHeight(), verticalAlignment = Alignment.CenterVertically ) { Box( modifier = Modifier .weight(1f) .fillMaxHeight() + .then( + if (currentClick != null) Modifier.clickable { currentClick?.invoke() } + else Modifier + ) .padding(horizontal = 4.dp), contentAlignment = contentAlignment ) { @@ -137,13 +260,41 @@ fun ResizableTableHeaderCell( style = textStyle ) } + // Auto-Fit is an explicit single-tap action. Keep the glyph subtle so it does + // not compete with the column title, while retaining a reliable touch target. + if (currentAutoFit != null) { + val autoFitDescription = if (isAutoFit) "열 너비 닫기" else "열 너비 열기" + Box( + modifier = Modifier + .width(15.dp) + .fillMaxHeight() + .clickable { currentAutoFit?.invoke() } + .semantics { contentDescription = autoFitDescription }, + contentAlignment = Alignment.Center + ) { + // Material's FirstPage/LastPage vectors provide the same + // boundary-and-arrow visual cue without drawing a text token. + Icon( + // Keep the glyph aligned with the current column state: + // a collapsed column shows the expand/open cue, while an + // expanded column shows the collapse/close cue. + imageVector = if (isAutoFit) Icons.Default.FirstPage else Icons.AutoMirrored.Filled.LastPage, + contentDescription = null, + tint = textColor.copy(alpha = 0.45f), + modifier = Modifier.size(13.dp) + ) + } + } if (showDivider || onWidthChange != null) { Box( modifier = Modifier.width(if (onWidthChange != null) 12.dp else 1.dp).fillMaxHeight() - .pointerInput(title, onWidthChange != null) { + .pointerInput(title, onWidthChange != null, workspaceNavigation) { var dragWidth = 0f detectDragGestures( - onDragStart = { dragWidth = currentWidth.value }, + onDragStart = { + if (currentResize != null) workspaceNavigation?.claimSinglePointerDrag() + dragWidth = currentWidth.value + }, onDrag = { change, delta -> if (currentResize != null) { change.consume() @@ -262,7 +413,7 @@ fun TableColorCell( ) { val composeColor = colorValue.toComposeColor() val hexStr = remember(colorValue) { - val rgb = (colorValue and 0xFFFFFFL) + val rgb = colorValue.toColorRef() String.format(Locale.US, "%06X", rgb) } val textColor = if (composeColor.luminance() > 0.5f) Color.Black else Color.White @@ -276,16 +427,13 @@ fun TableColorCell( ) { Text( text = hexStr, - fontSize = 9.sp, fontWeight = FontWeight.Bold, fontFamily = FontFamily.Monospace, color = textColor, maxLines = 1, - style = androidx.compose.ui.text.TextStyle( - platformStyle = androidx.compose.ui.text.PlatformTextStyle( - includeFontPadding = false - ) - ) + overflow = TextOverflow.Ellipsis, + softWrap = false, + style = SignalTableColorTextStyle ) } } @@ -320,7 +468,33 @@ fun EditableTableColorCell( } @Composable -private fun SignalColorPickerDialog( +internal fun SignalColorPresetGrid( + selectedColor: Long, + onSelect: (Long) -> Unit, + tagPrefix: String = "SignalColorPreset" +) { + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + SignalColorPresets.colors.chunked(6).forEach { row -> + Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) { + row.forEach { color -> + Box( + modifier = Modifier.size(26.dp) + .background(color.toComposeColor(), RoundedCornerShape(4.dp)) + .border(if (selectedColor.toColorRef() == color) 2.dp else 1.dp, + if (selectedColor.toColorRef() == color) Color.Black else Color(0xFF94A3B8), + RoundedCornerShape(4.dp)) + .testTag("${tagPrefix}_${color.toString(16).padStart(6, '0')}") + .semantics { contentDescription = "Color ${colorRefToRawHex(color)}" } + .clickable { onSelect(color) } + ) + } + } + } + } +} + +@Composable +internal fun SignalColorPickerDialog( initialColor: Long, onConfirm: (Long) -> Unit, onDismiss: () -> Unit @@ -330,7 +504,7 @@ private fun SignalColorPickerDialog( var currentSat by remember(initialColor) { mutableFloatStateOf(initialHsv[1]) } var currentVal by remember(initialColor) { mutableFloatStateOf(initialHsv[2]) } - var selectedColor by remember(initialColor) { mutableLongStateOf(initialColor and 0xFFFFFFL) } + var selectedColor by remember(initialColor) { mutableLongStateOf(initialColor.toColorRef()) } var colorHex by remember(initialColor) { mutableStateOf(colorRefToRawHex(initialColor)) } val parsedColor = remember(colorHex) { rawHexToColorRefOrNull(colorHex) } @@ -350,8 +524,16 @@ private fun SignalColorPickerDialog( Column( verticalArrangement = Arrangement.spacedBy(14.dp), horizontalAlignment = Alignment.CenterHorizontally, - modifier = Modifier.fillMaxWidth() + modifier = Modifier.fillMaxWidth().verticalScroll(rememberScrollState()) ) { + SignalColorPresetGrid(selectedColor, onSelect = { color -> + selectedColor = color + colorHex = colorRefToRawHex(color) + val hsv = colorRefToHsv(color) + currentHue = hsv[0] + currentSat = hsv[1] + currentVal = hsv[2] + }) // 1. 상단 색상 미리보기 및 Hex 입력 OutlinedTextField( value = colorHex, @@ -573,7 +755,7 @@ private fun CircularColorWheel( } private fun colorRefToHsv(colorRef: Long): FloatArray { - val value = colorRef and 0xFFFFFFL + val value = colorRef.toColorRef() val r = (value and 0xFFL).toInt() val g = ((value shr 8) and 0xFFL).toInt() val b = ((value shr 16) and 0xFFL).toInt() @@ -591,16 +773,59 @@ private fun hsvToColorRef(hsv: FloatArray): Long { } private fun colorRefToRawHex(colorRef: Long): String { - val value = colorRef and 0xFFFFFFL + val value = colorRef.toColorRef() return String.format(Locale.US, "%06X", value) } private fun rawHexToColorRefOrNull(input: String): Long? { val normalized = input.trim().removePrefix("0x").removePrefix("0X").removePrefix("#") if (!normalized.matches(Regex("[0-9A-Fa-f]{1,6}"))) return null - return normalized.toLongOrNull(16)?.and(0xFFFFFFL) + return normalized.toLongOrNull(16)?.toColorRef() } +/** + * Keeps a single inline Description editor active across the workspace. + * + * Each table row is composed independently, so a per-cell `isEditing` flag + * alone can leave more than one BasicTextField (and caret) on screen when a + * user moves directly to another Description cell. + */ +@Stable +class TableDescriptionEditSession { + var activeCell: Any? by mutableStateOf(null) + private set + + fun start(cell: Any) { + activeCell = cell + } + + fun finish(cell: Any) { + if (activeCell === cell) activeCell = null + } + + fun finishActive() { + activeCell = null + } + + fun isEditing(cell: Any): Boolean = activeCell === cell +} + +private val LocalTableDescriptionEditSession = + staticCompositionLocalOf { null } + +/** Supplies one edit session to every Description cell shown in the monitor workspace. */ +@Composable +fun ProvideTableDescriptionEditSession(content: @Composable () -> Unit) { + val parentSession = LocalTableDescriptionEditSession.current + if (parentSession != null) { + content() + } else { + val session = remember { TableDescriptionEditSession() } + CompositionLocalProvider(LocalTableDescriptionEditSession provides session, content = content) + } +} + +@OptIn(ExperimentalLayoutApi::class) @Composable fun TableEditableDescriptionCell( description: String, @@ -609,20 +834,98 @@ fun TableEditableDescriptionCell( commitOnChange: Boolean = false, enabled: Boolean = true ) { - var isEditing by remember { mutableStateOf(false) } - var textValue by remember(description) { mutableStateOf(description) } + val editSession = LocalTableDescriptionEditSession.current + val cellToken = remember { Any() } + var localIsEditing by remember { mutableStateOf(false) } + val isEditing = editSession?.isEditing(cellToken) ?: localIsEditing + var textValue by remember { mutableStateOf(description) } + var lastCommittedText by remember { mutableStateOf(description) } var hasGainedFocus by remember { mutableStateOf(false) } + val currentDescription by rememberUpdatedState(description) + val currentOnDescriptionChanged by rememberUpdatedState(onDescriptionChanged) val focusManager = LocalFocusManager.current val keyboardController = LocalSoftwareKeyboardController.current val focusRequester = remember { FocusRequester() } + + fun commitIfNeeded() { + if (!commitOnChange && lastCommittedText != textValue) { + lastCommittedText = textValue + currentOnDescriptionChanged(textValue) + } + } + + fun finishEditing(clearFocus: Boolean) { + if (isEditing) { + commitIfNeeded() + hasGainedFocus = false + if (editSession != null) { + editSession.finish(cellToken) + } else { + localIsEditing = false + } + // Do not clear/hide focus when another Description cell is taking + // it; doing so can cancel that cell's focus request and leave a + // stale caret behind. + if (clearFocus) { + focusManager.clearFocus(force = true) + keyboardController?.hide() + } + } + } + + val view = LocalView.current + + BackHandler(enabled = isEditing) { + finishEditing(clearFocus = true) + } + + DisposableEffect(view, isEditing) { + if (!isEditing) return@DisposableEffect onDispose {} + + var wasImeOpen = false + val rect = Rect() + + val listener = ViewTreeObserver.OnGlobalLayoutListener { + view.getWindowVisibleDisplayFrame(rect) + val screenHeight = view.rootView.height + val keypadHeight = screenHeight - rect.bottom + val insets = ViewCompat.getRootWindowInsets(view) + val isImeShowing = (keypadHeight > screenHeight * 0.15) || + (insets?.isVisible(WindowInsetsCompat.Type.ime()) == true) + + if (isImeShowing) { + wasImeOpen = true + } else if (wasImeOpen) { + // 키보드가 열려있었다가 닫히는 즉시 편집을 안전하게 종료하고 포인터를 해제 + finishEditing(clearFocus = true) + } + } + + view.viewTreeObserver.addOnGlobalLayoutListener(listener) + onDispose { + view.viewTreeObserver.removeOnGlobalLayoutListener(listener) + } + } + DisposableEffect(isEditing) { onDispose { - if (!commitOnChange && isEditing && textValue != description) { - onDescriptionChanged(textValue) + if (isEditing) { + commitIfNeeded() + // A disposed/filtered row must not retain the shared edit token. + editSession?.finish(cellToken) } } } + LaunchedEffect(description, isEditing) { + // External model changes update an idle cell but never overwrite an + // in-progress edit before it has been committed. + if (!isEditing) { + textValue = description + lastCommittedText = description + } + } + LaunchedEffect(isEditing) { if (isEditing) { hasGainedFocus = false @@ -633,25 +936,18 @@ fun TableEditableDescriptionCell( } } - fun finishEditing() { - if (isEditing) { - isEditing = false - hasGainedFocus = false - focusManager.clearFocus() - keyboardController?.hide() - if (textValue != description) { - onDescriptionChanged(textValue) - } - } - } - Box( modifier = modifier .fillMaxHeight() .clickable(enabled = enabled) { if (!isEditing) { - textValue = description - isEditing = true + textValue = currentDescription + lastCommittedText = currentDescription + if (editSession != null) { + editSession.start(cellToken) + } else { + localIsEditing = true + } } } .padding(horizontal = 4.dp), @@ -663,43 +959,47 @@ fun TableEditableDescriptionCell( value = textValue, onValueChange = { textValue = it - if (commitOnChange) onDescriptionChanged(it) + if (commitOnChange && lastCommittedText != it) { + lastCommittedText = it + currentOnDescriptionChanged(it) + } }, singleLine = true, keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), - keyboardActions = KeyboardActions(onDone = { finishEditing() }), - textStyle = androidx.compose.ui.text.TextStyle( - fontSize = 10.sp, + keyboardActions = KeyboardActions(onDone = { finishEditing(clearFocus = true) }), + textStyle = SignalTableDetailTextStyle.copy( color = Color(0xFF111827), - fontFamily = FontFamily.SansSerif, - platformStyle = androidx.compose.ui.text.PlatformTextStyle( - includeFontPadding = false - ) + fontFamily = FontFamily.SansSerif ), modifier = Modifier .fillMaxWidth() .padding(horizontal = 2.dp) .focusRequester(focusRequester) + .onKeyEvent { keyEvent -> + if (keyEvent.type == KeyEventType.KeyUp && (keyEvent.key == Key.Enter || keyEvent.key == Key.NumPadEnter)) { + finishEditing(clearFocus = true) + true + } else { + false + } + } .onFocusChanged { focusState -> if (focusState.isFocused) { hasGainedFocus = true } else if (hasGainedFocus && isEditing) { - finishEditing() + // Focus already moved elsewhere. Clearing global + // focus here would steal it back from the new cell. + finishEditing(clearFocus = false) } } ) } else { Text( text = description, - fontSize = 10.sp, color = Color(0xFF212529), maxLines = 1, overflow = TextOverflow.Ellipsis, - style = androidx.compose.ui.text.TextStyle( - platformStyle = androidx.compose.ui.text.PlatformTextStyle( - includeFontPadding = false - ) - ) + style = SignalTableDetailTextStyle ) } } diff --git a/Android/app/src/main/java/com/dct/app/presentation/signaltree/WindowUiState.kt b/Android/app/src/main/java/com/dct/app/presentation/signaltree/WindowUiState.kt index f88565d..c1ba6d7 100644 --- a/Android/app/src/main/java/com/dct/app/presentation/signaltree/WindowUiState.kt +++ b/Android/app/src/main/java/com/dct/app/presentation/signaltree/WindowUiState.kt @@ -9,7 +9,12 @@ class WindowUiStore(initial: Map, private val changed: (Map) { + val next = values.toMutableMap().apply { + changes.forEach { (key, value) -> if (value == null) remove(key) else put(key, value) } + } if (next != values) { values = next; changed(next) } } } @@ -50,9 +55,26 @@ private fun rememberWindowValue(key: String, initial: T, encode: (T) -> Stri class WindowColumnWidths(private val store: WindowUiStore) { operator fun get(key: String): Dp? = store.values["width:$key"]?.toFloatOrNull()?.takeIf { it.isFinite() }?.coerceIn(32f, 1200f)?.dp - operator fun set(key: String, value: Dp) { if (value.value.isFinite()) store.put("width:$key", value.value.coerceIn(32f, 1200f).toString()) } + operator fun set(key: String, value: Dp) { + if (value.value.isFinite()) store.putAll(mapOf( + "width:$key" to value.value.coerceIn(32f, 1200f).toString(), "autoFit:$key" to null + )) + } fun containsKey(key: String) = get(key) != null - fun remove(key: String) = store.put("width:$key", null) + fun remove(key: String) = store.putAll(mapOf("width:$key" to null, "autoFit:$key" to null)) + fun isAutoFit(key: String, requiredWidth: Dp, actualWidth: Dp = get(key) ?: 0.dp): Boolean = + get(key) != null && store.values["autoFit:$key"] == "true" && actualWidth >= requiredWidth + + fun toggleAutoFit(key: String, defaultWidth: Dp, requiredWidth: Dp, actualWidth: Dp = get(key) ?: defaultWidth) { + if (!requiredWidth.value.isFinite() || !actualWidth.value.isFinite()) return + if (isAutoFit(key, requiredWidth, actualWidth)) { + remove(key) + } else { + // Opening a manual or stale fit must never make it narrower. + val fitted = maxOf(defaultWidth, requiredWidth, actualWidth).value.coerceIn(32f, 1200f) + store.putAll(mapOf("width:$key" to fitted.toString(), "autoFit:$key" to "true")) + } + } } @Composable fun rememberWindowColumnWidths(): WindowColumnWidths { val parent = LocalWindowUiStore.current diff --git a/Android/app/src/main/java/com/dct/app/presentation/signaltree/WindowsClassicTreeView.kt b/Android/app/src/main/java/com/dct/app/presentation/signaltree/WindowsClassicTreeView.kt index 7f9f6c4..a35b426 100644 --- a/Android/app/src/main/java/com/dct/app/presentation/signaltree/WindowsClassicTreeView.kt +++ b/Android/app/src/main/java/com/dct/app/presentation/signaltree/WindowsClassicTreeView.kt @@ -4,21 +4,28 @@ import androidx.compose.foundation.Canvas import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable +import androidx.compose.foundation.gestures.detectDragGestures import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Text import androidx.compose.runtime.* import androidx.compose.ui.Alignment +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Path import androidx.compose.ui.graphics.PathEffect import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.dct.app.domain.model.mdb.CompensationMap @@ -103,31 +110,125 @@ fun WindowsClassicSignalTreeView( list } - LazyColumn( + val listState = rememberLazyListState() + + Box( modifier = modifier .fillMaxSize() .background(Color.White) ) { - items(flattenedList, key = { it.node.id }) { item -> - ClassicTreeRowItem( - item = item, - isSelected = selectedNodeId == item.node.id, - onToggleExpand = { - val cur = expandedNodes[item.node.id] ?: false - expandedNodes[item.node.id] = !cur - }, - onClick = { - if (item.hasChildren) { + LazyColumn( + state = listState, + modifier = Modifier + .fillMaxSize() + .padding(end = 7.dp) + ) { + items(flattenedList, key = { it.node.id }) { item -> + ClassicTreeRowItem( + item = item, + isSelected = selectedNodeId == item.node.id, + onToggleExpand = { val cur = expandedNodes[item.node.id] ?: false expandedNodes[item.node.id] = !cur + }, + onClick = { + if (item.hasChildren) { + val cur = expandedNodes[item.node.id] ?: false + expandedNodes[item.node.id] = !cur + } + onCategoryClick(item.node.id) } - onCategoryClick(item.node.id) - } - ) + ) + } + } + + SignalTreeVerticalScrollbar( + listState = listState, + itemCount = flattenedList.size, + modifier = Modifier.align(Alignment.CenterEnd) + ) + } +} + +/** 내용이 창 높이를 넘을 때만 표시되는 시그널 트리 전용 슬림 스크롤바. */ +@Composable +private fun SignalTreeVerticalScrollbar( + listState: androidx.compose.foundation.lazy.LazyListState, + itemCount: Int, + modifier: Modifier = Modifier +) { + val density = LocalDensity.current + val rowHeightPx = with(density) { SIGNAL_TREE_ROW_HEIGHT.toPx() } + val layoutInfo = listState.layoutInfo + val viewportHeightPx = (layoutInfo.viewportEndOffset - layoutInfo.viewportStartOffset) + .toFloat() + .coerceAtLeast(0f) + val contentHeightPx = itemCount * rowHeightPx + val maxScrollPx = (contentHeightPx - viewportHeightPx).coerceAtLeast(0f) + + if (viewportHeightPx <= 0f || maxScrollPx <= 0f) return + + var isDragging by remember { mutableStateOf(false) } + val minimumThumbHeightPx = with(density) { 20.dp.toPx() } + + BoxWithConstraints( + modifier = modifier + .fillMaxHeight() + .width(7.dp) + .padding(vertical = 2.dp, horizontal = 1.dp) + ) { + val trackHeightPx = constraints.maxHeight.toFloat() + val thumbHeightPx = (viewportHeightPx / contentHeightPx * trackHeightPx) + .coerceIn(minimumThumbHeightPx.coerceAtMost(trackHeightPx), trackHeightPx) + val availableTrackPx = (trackHeightPx - thumbHeightPx).coerceAtLeast(0f) + val currentScrollPx = ( + listState.firstVisibleItemIndex * rowHeightPx + + listState.firstVisibleItemScrollOffset + ).coerceIn(0f, maxScrollPx) + val thumbOffsetPx = if (maxScrollPx > 0f) { + currentScrollPx / maxScrollPx * availableTrackPx + } else { + 0f } + + Box( + modifier = Modifier + .align(Alignment.Center) + .fillMaxHeight() + .width(3.dp) + .clip(RoundedCornerShape(100.dp)) + .background(Color(0x1F64748B)) + ) + + Box( + modifier = Modifier + .offset { IntOffset(0, thumbOffsetPx.toInt()) } + .align(Alignment.TopCenter) + .height(with(density) { thumbHeightPx.toDp() }) + .fillMaxWidth() + .clip(RoundedCornerShape(100.dp)) + .background(if (isDragging) Color(0xFF2563EB) else Color(0xFF94A3B8)) + .pointerInput(maxScrollPx, availableTrackPx) { + detectDragGestures( + onDragStart = { isDragging = true }, + onDrag = { change, dragAmount -> + change.consume() + if (availableTrackPx > 0f) { + listState.dispatchRawDelta( + dragAmount.y / availableTrackPx * maxScrollPx + ) + } + }, + onDragEnd = { isDragging = false }, + onDragCancel = { isDragging = false } + ) + } + ) } } +private val SIGNAL_TREE_ROW_HEIGHT = 24.dp + /** * 평탄화된 트리 아이템 (렌더링용) */ @@ -154,7 +255,7 @@ private fun ClassicTreeRowItem( verticalAlignment = Alignment.CenterVertically, modifier = Modifier .fillMaxWidth() - .height(22.dp) + .height(SIGNAL_TREE_ROW_HEIGHT) .background(if (isSelected) Color(0xFFCCE8FF) else Color.Transparent) .clickable { onClick() } ) { @@ -253,10 +354,13 @@ private fun ClassicTreeRowItem( Text( text = item.node.label, fontSize = 12.sp, + lineHeight = 16.sp, fontFamily = FontFamily.SansSerif, fontWeight = if (item.node.isFolder && item.level == 0) FontWeight.Medium else FontWeight.Normal, color = Color(0xFF191C1E), - maxLines = 1 + maxLines = 1, + overflow = TextOverflow.Ellipsis, + softWrap = false ) } } diff --git a/Android/app/src/main/res/drawable/ic_measurement_notification.xml b/Android/app/src/main/res/drawable/ic_measurement_notification.xml new file mode 100644 index 0000000..b5be497 --- /dev/null +++ b/Android/app/src/main/res/drawable/ic_measurement_notification.xml @@ -0,0 +1,5 @@ + + + diff --git a/Android/app/src/test/java/com/dct/app/data/maintenance/HeartbeatAndOtaParityTest.kt b/Android/app/src/test/java/com/dct/app/data/maintenance/HeartbeatAndOtaParityTest.kt new file mode 100644 index 0000000..631a43e --- /dev/null +++ b/Android/app/src/test/java/com/dct/app/data/maintenance/HeartbeatAndOtaParityTest.kt @@ -0,0 +1,243 @@ +package com.dct.app.data.maintenance + +import com.dct.app.core.common.DctResult +import com.dct.app.data.communication.WifiSocketManager +import com.dct.app.data.protocol.DctProtocolRequests +import com.dct.app.data.repository.PacketRepositoryImpl +import com.dct.app.domain.model.protocol.DctCommand +import com.dct.app.domain.model.protocol.DctPacket +import com.dct.app.domain.repository.DeviceConnectionState +import com.dct.app.domain.repository.PacketRepository +import io.mockk.* +import kotlinx.coroutines.async +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test + +@DisplayName("Heartbeat 주기 루프 및 OTA 유지보수 단위 테스트") +class HeartbeatAndOtaParityTest { + + @Test + fun `removeFirmware는 슬롯 인덱스 검증과 0x06 삭제 응답을 정확히 처리한다`() = runTest { + val repo = mockk() + every { repo.connectionState } returns MutableStateFlow(DeviceConnectionState.READY) + + // 슬롯 1 삭제 성공 (0x06, 0x01) + coEvery { repo.sendCommand(match { it.payload[0] == 0x06.toByte() && it.payload[1] == 1.toByte() }) } returns + DctResult.Success(DctPacket.create(DctCommand.RES_FW_INFO, byteArrayOf(0x06, 0x01))) + + val service = MaintenanceService(repo) + val success = service.removeFirmware(1) + assertTrue(success.isSuccess) + + // 단말 삭제 거절 (0x06, 0x02) + coEvery { repo.sendCommand(match { it.payload[0] == 0x06.toByte() && it.payload[1] == 2.toByte() }) } returns + DctResult.Success(DctPacket.create(DctCommand.RES_FW_INFO, byteArrayOf(0x06, 0x02))) + val failed = service.removeFirmware(2) + assertTrue(failed.isFailure) + + // 슬롯 범위 초과 + assertTrue(service.removeFirmware(-1).isFailure) + assertTrue(service.removeFirmware(256).isFailure) + + // 비READY 상태에서 요청 시 거절 + every { repo.connectionState } returns MutableStateFlow(DeviceConnectionState.MEASURING) + assertTrue(service.removeFirmware(1).isFailure) + } + + @Test + fun `triggerFirmwareUpdate는 0x07 발송 후 0x08 진행 및 완료 패킷을 수신하고 상태별 ACK를 회신한다`() = runTest { + val repo = mockk() + every { repo.connectionState } returns MutableStateFlow(DeviceConnectionState.READY) + val stream = MutableSharedFlow(replay = 10, extraBufferCapacity = 16) + every { repo.packetStream } returns stream + + val sentCommands = mutableListOf() + coEvery { repo.sendCommand(capture(sentCommands)) } returns + DctResult.Success(DctPacket.create(DctCommand.RES_FW_INFO, byteArrayOf(0x07, 0x01))) + + val service = MaintenanceService(repo) + val job = async { service.triggerFirmwareUpdate(0) } + + // 단말의 0x08 진행 보고 방출 (30%, 75%) + stream.emit(DctPacket.create(DctCommand.RES_FW_INFO, byteArrayOf(0x08, 0x01, 30))) + stream.emit(DctPacket.create(DctCommand.RES_FW_INFO, byteArrayOf(0x08, 0x01, 75))) + // 단말의 0x08 완료 보고 방출 + stream.emit(DctPacket.create(DctCommand.RES_FW_INFO, byteArrayOf(0x08, 0x02))) + + val result = job.await() + assertTrue(result.isSuccess) + + // 0x07 요청, 0x08 ACK(0x01), 0x08 ACK(0x01), 0x08 ACK(0x02) 전송 확인 + val fwInfoCommands = sentCommands.filter { it.command == DctCommand.CMD_FW_INFO.code } + assertTrue(fwInfoCommands.any { it.payload[0] == 0x07.toByte() }) + assertTrue(fwInfoCommands.any { it.payload[0] == 0x08.toByte() && it.payload[1] == 0x01.toByte() }) + assertTrue(fwInfoCommands.any { it.payload[0] == 0x08.toByte() && it.payload[1] == 0x02.toByte() }) + } + + @Test + fun `Heartbeat와 다른 수신이 모두 누락되어도 활성 TCP 소켓은 유지한다`() = runTest { + val wifi = mockk(relaxed = true) + val packets = MutableSharedFlow(extraBufferCapacity = 16) + val isConnected = MutableStateFlow(true) + every { wifi.isConnected } returns isConnected + every { wifi.packetStream } returns packets + every { wifi.currentSessionId } returns 1L + coEvery { wifi.disconnectSuspend() } coAnswers { isConnected.value = false } + + val sentList = mutableListOf() + coEvery { wifi.sendCommand(capture(sentList)) } returns true + + val testDispatcher = StandardTestDispatcher(testScheduler) + val repository = PacketRepositoryImpl( + wifiSocketManager = wifi, + ioDispatcher = testDispatcher, + heartbeatDispatcher = testDispatcher, + heartbeatIntervalMs = 1000L, + heartbeatTimeoutMs = 5000L, + enableHeartbeat = true + ) + + // 핸드셰이크 통과 설정 + coEvery { wifi.sendCommand(match { it.command == 0x00.toByte() }) } coAnswers { + packets.emit(DctPacket(command = 0x80.toByte(), payload = byteArrayOf(1))) + true + } + coEvery { wifi.sendCommand(match { it.command == 0x01.toByte() }) } coAnswers { + packets.emit(DctPacket(command = 0x81.toByte(), payload = byteArrayOf(4, 68, 67, 84, 0))) + true + } + coEvery { wifi.sendCommand(match { it.command == 0x09.toByte() }) } coAnswers { + packets.emit(DctPacket(command = 0x89.toByte(), payload = byteArrayOf(1))) + true + } + coEvery { wifi.sendCommand(match { it.command == 0x03.toByte() }) } coAnswers { + packets.emit(DctPacket(command = 0x83.toByte(), payload = ByteArray(6))) + true + } + coEvery { wifi.sendCommand(match { it.command == 0x04.toByte() }) } coAnswers { + packets.emit(DctPacket(command = 0x84.toByte(), payload = byteArrayOf(1))) + true + } + + assertTrue(repository.connect("") is DctResult.Success) + assertEquals(DeviceConnectionState.READY, repository.connectionState.value) + + // 1초 후 첫 번째 Heartbeat(0x05) 발송 시점 + advanceTimeBy(1001L) + val heartbeat1 = sentList.find { it.command == DctCommand.CMD_POSSIBLE_TO_CONNECT.code } + assertNotNull(heartbeat1) + + // 첫 누락만으로는 일시 지연일 수 있으므로 연결을 유지한다. + advanceTimeBy(5001L) + assertEquals(DeviceConnectionState.READY, repository.connectionState.value) + assertTrue(isConnected.value) + + // 다음 probe도 0x85와 기타 정상 패킷이 모두 없지만 Heartbeat만으로 + // 활성 TCP 연결을 종료해서는 안 된다. + advanceTimeBy(6001L) + + assertEquals(DeviceConnectionState.READY, repository.connectionState.value) + assertTrue(isConnected.value) + coVerify(exactly = 0) { wifi.disconnectSuspend() } + + // 로그에서 재현된 것처럼 늦은 정상 응답이 도착해도 그대로 수신하고 + // 이후 Heartbeat 감시를 계속할 수 있어야 한다. + packets.emit(DctPacket( + command = DctCommand.RES_START_TRANSMIT_DATA.code, + payload = byteArrayOf(1, 1), + transportSessionId = 1L + )) + advanceTimeBy(1L) + assertEquals(DeviceConnectionState.READY, repository.connectionState.value) + assertTrue(isConnected.value) + + // 제품 동작 검증 후 모의 전송 계층을 내려 반복 Heartbeat 작업을 정리한다. + isConnected.value = false + advanceTimeBy(6001L) + } + + @Test + fun `계측 중에도 Heartbeat 루프가 데이터 스트림을 차단하지 않고 공존한다`() = runTest { + val wifi = mockk(relaxed = true) + val packets = MutableSharedFlow(extraBufferCapacity = 64) + val isConnected = MutableStateFlow(true) + every { wifi.isConnected } returns isConnected + every { wifi.packetStream } returns packets + every { wifi.currentSessionId } returns 2L + every { wifi.offerCommand(any()) } returns true + + val sentList = mutableListOf() + coEvery { wifi.sendCommand(capture(sentList)) } returns true + + val testDispatcher = StandardTestDispatcher(testScheduler) + val repository = PacketRepositoryImpl( + wifiSocketManager = wifi, + ioDispatcher = testDispatcher, + heartbeatDispatcher = testDispatcher, + heartbeatIntervalMs = 2000L, + heartbeatTimeoutMs = 5000L, + enableHeartbeat = true + ) + + // 핸드셰이크 통과 + coEvery { wifi.sendCommand(match { it.command == 0x00.toByte() }) } coAnswers { + packets.emit(DctPacket(command = 0x80.toByte(), payload = byteArrayOf(1))) + true + } + coEvery { wifi.sendCommand(match { it.command == 0x01.toByte() }) } coAnswers { + packets.emit(DctPacket(command = 0x81.toByte(), payload = byteArrayOf(4, 68, 67, 84, 0))) + true + } + coEvery { wifi.sendCommand(match { it.command == 0x09.toByte() }) } coAnswers { + packets.emit(DctPacket(command = 0x89.toByte(), payload = byteArrayOf(1))) + true + } + coEvery { wifi.sendCommand(match { it.command == 0x03.toByte() }) } coAnswers { + packets.emit(DctPacket(command = 0x83.toByte(), payload = ByteArray(6))) + true + } + coEvery { wifi.sendCommand(match { it.command == 0x04.toByte() }) } coAnswers { + packets.emit(DctPacket(command = 0x84.toByte(), payload = byteArrayOf(1))) + true + } + coEvery { wifi.sendCommand(match { it.command == DctCommand.CMD_START_TRANSMIT_DATA.code }) } coAnswers { + packets.emit(DctPacket(command = DctCommand.RES_START_TRANSMIT_DATA.code, payload = byteArrayOf(1, 1))) + true + } + + assertTrue(repository.connect("") is DctResult.Success) + assertTrue(repository.startStreaming(30) is DctResult.Success) + assertEquals(DeviceConnectionState.MEASURING, repository.connectionState.value) + + // 계측 중 대량의 CAN 텔레메트리 스트림 수신 (A3 패킷들) + repeat(5) { + packets.emit(DctPacket( + command = DctCommand.SEND_CAN_FRAME.code, + payload = byteArrayOf(0, 8, 1, 2, 3, 4, 5, 6, 7, 8), + transportSessionId = 2L + )) + } + + // Heartbeat 주기(2000ms) 전진 + advanceTimeBy(2001L) + + // 0x85 대신 계측 데이터가 들어와도 PC와 같이 장비 생존으로 판단한다. + packets.emit(DctPacket( + command = DctCommand.SEND_CAN_FRAME.code, + payload = byteArrayOf(0, 8, 1, 2, 3, 4, 5, 6, 7, 8), + transportSessionId = 2L + )) + advanceTimeBy(5001L) + + assertEquals(DeviceConnectionState.MEASURING, repository.connectionState.value) + assertTrue(sentList.any { it.command == DctCommand.CMD_POSSIBLE_TO_CONNECT.code }) + + repository.disconnect() + } +} diff --git a/Android/app/src/test/java/com/dct/app/data/protocol/DctOtaProtocolTest.kt b/Android/app/src/test/java/com/dct/app/data/protocol/DctOtaProtocolTest.kt new file mode 100644 index 0000000..366d5f2 --- /dev/null +++ b/Android/app/src/test/java/com/dct/app/data/protocol/DctOtaProtocolTest.kt @@ -0,0 +1,81 @@ +package com.dct.app.data.protocol + +import com.dct.app.domain.model.protocol.DctCommand +import com.google.common.truth.Truth.assertThat +import org.junit.jupiter.api.Assertions.assertThrows +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test + +@DisplayName("DctProtocolRequests OTA 및 Alive Check 바이트 규격 검증") +class DctOtaProtocolTest { + private val codec = DctPacketCodec() + + @Test + fun `buildRemoveFirmwareRequest는 0x31 서브타입 0x06의 유효한 바이너리 프레임을 생성한다`() { + val frame = DctProtocolRequests.buildRemoveFirmwareRequest(3) + // Header: 0xA1, Length: 0x00 0x03, Cmd: 0x31, Payload: 0x06 0x03, Checksum, Footer: 0x7E + assertThat(frame.size).isEqualTo(8) + assertThat(frame[0]).isEqualTo(0xA1.toByte()) + assertThat(frame[1]).isEqualTo(0x00.toByte()) + assertThat(frame[2]).isEqualTo(0x03.toByte()) + assertThat(frame[3]).isEqualTo(0x31.toByte()) + assertThat(frame[4]).isEqualTo(0x06.toByte()) + assertThat(frame[5]).isEqualTo(0x03.toByte()) + assertThat(frame[7]).isEqualTo(0x7E.toByte()) + + val decoded = codec.decode(frame) + assertThat(decoded).isNotNull() + assertThat(decoded!!.command).isEqualTo(DctCommand.CMD_FW_INFO.code) + assertThat(decoded.payload).isEqualTo(byteArrayOf(0x06, 0x03)) + } + + @Test + fun `buildTriggerFirmwareUpdateRequest는 0x31 서브타입 0x07의 유효한 바이너리 프레임을 생성한다`() { + val frame = DctProtocolRequests.buildTriggerFirmwareUpdateRequest() + assertThat(frame.size).isEqualTo(8) + assertThat(frame[0]).isEqualTo(0xA1.toByte()) + assertThat(frame[1]).isEqualTo(0x00.toByte()) + assertThat(frame[2]).isEqualTo(0x03.toByte()) + assertThat(frame[3]).isEqualTo(0x31.toByte()) + assertThat(frame[4]).isEqualTo(0x07.toByte()) + assertThat(frame[5]).isEqualTo(0x00.toByte()) + assertThat(frame[7]).isEqualTo(0x7E.toByte()) + + val decoded = codec.decode(frame) + assertThat(decoded).isNotNull() + assertThat(decoded!!.command).isEqualTo(DctCommand.CMD_FW_INFO.code) + assertThat(decoded.payload).isEqualTo(byteArrayOf(0x07, 0x00)) + } + + @Test + fun `firmwareUpdateResponse는 0x31 서브타입 0x08 상태 ACK 패킷을 생성한다`() { + val progressAck = DctProtocolRequests.firmwareUpdateResponse(0x01) + assertThat(progressAck.command).isEqualTo(0x31.toByte()) + assertThat(progressAck.payload).isEqualTo(byteArrayOf(0x08, 0x01)) + + val completeAck = DctProtocolRequests.firmwareUpdateResponse(0x02) + assertThat(completeAck.command).isEqualTo(0x31.toByte()) + assertThat(completeAck.payload).isEqualTo(byteArrayOf(0x08, 0x02)) + + assertThrows(IllegalArgumentException::class.java) { + DctProtocolRequests.firmwareUpdateResponse(256) + } + } + + @Test + fun `possibleToConnect는 0x05 커맨드 패킷을 생성한다`() { + val packet = DctProtocolRequests.possibleToConnect() + assertThat(packet.command).isEqualTo(DctCommand.CMD_POSSIBLE_TO_CONNECT.code) + assertThat(packet.payload).isEmpty() + } + + @Test + fun `슬롯 범위 초과 시 예외가 발생한다`() { + assertThrows(IllegalArgumentException::class.java) { + DctProtocolRequests.buildRemoveFirmwareRequest(-1) + } + assertThrows(IllegalArgumentException::class.java) { + DctProtocolRequests.buildRemoveFirmwareRequest(256) + } + } +} diff --git a/Android/app/src/test/java/com/dct/app/presentation/maintenance/MaintenanceUiParityTest.kt b/Android/app/src/test/java/com/dct/app/presentation/maintenance/MaintenanceUiParityTest.kt new file mode 100644 index 0000000..6c657aa --- /dev/null +++ b/Android/app/src/test/java/com/dct/app/presentation/maintenance/MaintenanceUiParityTest.kt @@ -0,0 +1,292 @@ +package com.dct.app.presentation.maintenance + +import com.dct.app.core.common.DctResult +import com.dct.app.data.maintenance.DeviceMaintenanceProtocol +import com.dct.app.data.maintenance.MaintenanceService +import com.dct.app.data.protocol.DctProtocolRequests +import com.dct.app.domain.model.maintenance.RemoteLogFile +import com.dct.app.domain.model.protocol.DctCommand +import com.dct.app.domain.model.protocol.DctPacket +import com.dct.app.domain.repository.DeviceConnectionState +import com.dct.app.domain.repository.PacketRepository +import io.mockk.coEvery +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Assertions.assertArrayEquals +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNotNull +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +/** + * M3 UI/UX & Maintenance Dialog Parity 단위 테스트. + * + * 1. [ISSUE-R3-01] WorkspaceRibbonBar 유지보수 활성화 및 표준 DID 7종 정의/비트마스크 사양 + * 2. [ISSUE-R3-02] UDS DID 개별/전체 읽기/쓰기 및 차종/DB 제어 프로토콜 + * 3. [ISSUE-R3-03] 단말 로그 삭제(0x26), 전체 선택, 내림차순(rbegin) 삭제 순서 + */ +class MaintenanceUiParityTest { + + @Test + fun testStandardDidDefinitionsParity() { + // PC CDIDInformationDlg.cpp 와 1:1 일치하는 표준 7종 DID 정의 검증 + assertEquals(7, STANDARD_DIDS.size) + + val f187 = STANDARD_DIDS.find { it.did == 0xF187 } + assertNotNull(f187) + assertEquals("Part Number", f187!!.name) + assertEquals(0x40, f187.bitmask) + assertEquals(10, f187.maxChars) + + val f1a1 = STANDARD_DIDS.find { it.did == 0xF1A1 } + assertNotNull(f1a1) + assertEquals("Supplier Code", f1a1!!.name) + assertEquals(0x20, f1a1.bitmask) + assertEquals(4, f1a1.maxChars) + + val f18b = STANDARD_DIDS.find { it.did == 0xF18B } + assertNotNull(f18b) + assertEquals("Product Date", f18b!!.name) + assertEquals(0x10, f18b.bitmask) + assertEquals(8, f18b.maxChars) + + val f197 = STANDARD_DIDS.find { it.did == 0xF197 } + assertNotNull(f197) + assertEquals("System Name", f197!!.name) + assertEquals(0x08, f197.bitmask) + assertEquals(4, f197.maxChars) + + val f191 = STANDARD_DIDS.find { it.did == 0xF191 } + assertNotNull(f191) + assertEquals("HW Version", f191!!.name) + assertEquals(0x04, f191.bitmask) + assertEquals(4, f191.maxChars) + + val f193 = STANDARD_DIDS.find { it.did == 0xF193 } + assertNotNull(f193) + assertEquals("Supplier HW Version", f193!!.name) + assertEquals(0x02, f193.bitmask) + assertEquals(4, f193.maxChars) + + val f100 = STANDARD_DIDS.find { it.did == 0xF100 } + assertNotNull(f100) + assertEquals("Part Name 2 / SW Version", f100!!.name) + assertEquals(0x01, f100.bitmask) + assertEquals(25, f100.maxChars) + + // 전체 선택 시 비트마스크 합: 0x40 | 0x20 | 0x10 | 0x08 | 0x04 | 0x02 | 0x01 = 0x7F (127) + val fullMask = STANDARD_DIDS.fold(0) { acc, def -> acc or def.bitmask } + assertEquals(0x7F, fullMask) + + // 부분 선택 마스크 조합 계산 검증 (F187 + F100 = 0x41) + val partialMask = f187.bitmask or f100.bitmask + assertEquals(0x41, partialMask) + } + + @Test + fun testDidProtocolRequestsAndResponses() = runTest { + val repo = mockk() + every { repo.connectionState } returns MutableStateFlow(DeviceConnectionState.READY) + + // 1. 개별 DID 읽기 (0x15 -> 0x95) + coEvery { + repo.sendCommand(match { it.command == DctCommand.CMD_REQ_DID_INFO.code }) + } returns DctResult.Success( + DctPacket.create( + DctCommand.RES_REQ_DID_INFO, + byteArrayOf(0xF1.toByte(), 0x87.toByte(), 0x01, 0x04, 'D'.code.toByte(), 'C'.code.toByte(), 'T'.code.toByte(), '1'.code.toByte()) + ) + ) + + val service = MaintenanceService(repo) + val readBytes = service.readDid(0xF187) + assertEquals("DCT1", readBytes.toString(Charsets.US_ASCII)) + + // 2. 개별 DID 쓰기 (0x16 -> 0x96) + coEvery { + repo.sendCommand(match { it.command == DctCommand.CMD_DID_INFO.code }) + } returns DctResult.Success( + DctPacket.create( + DctCommand.RES_DID_INFO, + byteArrayOf(0xF1.toByte(), 0x87.toByte(), 0x01, 0x04, 'D'.code.toByte(), 'C'.code.toByte(), 'T'.code.toByte(), '1'.code.toByte()) + ) + ) + service.writeDid(0xF187, "DCT1".toByteArray(Charsets.US_ASCII)) + + // 3. 전체 DID 읽기 (0x18 -> 0x98) + val allDidPayload = byteArrayOf( + 0x01, // status success + 0xF1.toByte(), 0x87.toByte(), 0x02, 'P'.code.toByte(), 'N'.code.toByte(), // F187 + 0xF1.toByte(), 0x00.toByte(), 0x03, 'S'.code.toByte(), 'W'.code.toByte(), '1'.code.toByte() // F100 + ) + coEvery { + repo.sendCommand(match { it.command == DctCommand.CMD_REQ_ALL_DID_INFO.code }) + } returns DctResult.Success( + DctPacket.create(DctCommand.RES_REQ_ALL_DID_INFO, allDidPayload) + ) + val allResults = service.readAllDid(0x41) + assertEquals(2, allResults.size) + assertEquals("PN", allResults[0xF187]?.toString(Charsets.US_ASCII)) + assertEquals("SW1", allResults[0xF100]?.toString(Charsets.US_ASCII)) + + // 4. Setup DID 프리셋 저장 (0x17 -> 0x97) + coEvery { + repo.sendCommand(match { it.command == DctCommand.CMD_SETUP_DID_INFO.code }) + } returns DctResult.Success( + DctPacket.create(DctCommand.RES_SETUP_DID_INFO, byteArrayOf(0x01)) + ) + service.setupDid(0x7F) + } + + @Test + fun testSavedLogRemoveProtocolAndService() = runTest { + // [ISSUE-R3-03] 0x26 단말 로그 삭제 패킷 빌더 검증 + val packet = DctProtocolRequests.savedLogRemove(0x0102) + assertEquals(DctCommand.CMD_SAVED_LOGFILE_COPY.code, packet.command) + assertArrayEquals(byteArrayOf(0x03, 0x01, 0x02), packet.payload) + + val repo = mockk() + every { repo.connectionState } returns MutableStateFlow(DeviceConnectionState.READY) + + // 성공 응답 (0x03, 0x01) + coEvery { + repo.sendCommand(match { it.command == DctCommand.CMD_SAVED_LOGFILE_COPY.code && it.payload[0] == 0x03.toByte() }) + } returns DctResult.Success( + DctPacket.create(DctCommand.RES_SAVED_LOGFILE_COPY, byteArrayOf(0x03, 0x01)) + ) + + val service = MaintenanceService(repo) + service.deleteLogFile(0x0102) + + // 장비 거절 응답 (0x03, 0x02) + coEvery { + repo.sendCommand(match { it.command == DctCommand.CMD_SAVED_LOGFILE_COPY.code && it.payload[0] == 0x03.toByte() }) + } returns DctResult.Success( + DctPacket.create(DctCommand.RES_SAVED_LOGFILE_COPY, byteArrayOf(0x03, 0x02)) + ) + assertTrue(runCatching { service.deleteLogFile(0x0102) }.isFailure) + + // 유효하지 않은 인덱스 (16비트 초과) 방어 + assertTrue(runCatching { service.deleteLogFile(0x10000) }.isFailure) + } + + @Test + fun testSavedLogDescendingDeleteOrder() { + // PC CLogfileDownloadDlg.cpp m_listDeleteItem.rbegin() 과 동등한 역순(내림차순) 정렬 검증 + val selectedIndices = setOf(2, 10, 5, 1, 8) + val sortedForDelete = selectedIndices.sortedDescending() + + assertEquals(listOf(10, 8, 5, 2, 1), sortedForDelete) + + // RemoteLogFile 필터링 및 정렬 동작 검증 + val files = listOf( + RemoteLogFile(1, "log_01.csv"), + RemoteLogFile(2, "log_02.csv"), + RemoteLogFile(5, "log_05.csv") + ) + val selectedFiles = files.filter { it.index in setOf(1, 5) }.map { it.index }.sortedDescending() + assertEquals(listOf(5, 1), selectedFiles) + } + + @Test + fun testCarTypeAndDatabaseMaintenanceParity() = runTest { + val repo = mockk() + every { repo.connectionState } returns MutableStateFlow(DeviceConnectionState.READY) + + // 1. 차종 목록 조회 (0x07 / 0x08) + coEvery { + repo.sendCommand(match { it.command == DctCommand.CMD_CARTYPE_CONTROL.code && it.payload[0] == 0x00.toByte() }) + } returns DctResult.Success( + DctPacket.create(DctCommand.RES_CARTYPE_CONTROL, byteArrayOf(0x00, 0x01, 0x01, 0x02)) // 2개 차종 + ) + coEvery { + repo.sendCommand(match { it.command == DctCommand.CMD_CARTYPE_CONTROL.code && it.payload[0] == 0x01.toByte() && it.payload[2] == 0.toByte() }) + } returns DctResult.Success( + DctPacket.create(DctCommand.RES_CARTYPE_CONTROL, byteArrayOf(0x01, 0x01, 0x01, 0x00, 0x03, 'S'.code.toByte(), 'G'.code.toByte(), '2'.code.toByte())) + ) + coEvery { + repo.sendCommand(match { it.command == DctCommand.CMD_CARTYPE_CONTROL.code && it.payload[0] == 0x01.toByte() && it.payload[2] == 1.toByte() }) + } returns DctResult.Success( + DctPacket.create(DctCommand.RES_CARTYPE_CONTROL, byteArrayOf(0x01, 0x01, 0x01, 0x01, 0x03, 'N'.code.toByte(), 'Q'.code.toByte(), '5'.code.toByte())) + ) + + val service = MaintenanceService(repo) + val carList = service.listCarTypes(1) + assertEquals(listOf("SG2", "NQ5"), carList) + + // 2. 차종 삭제 + coEvery { + repo.sendCommand(match { it.command == DctCommand.CMD_CARTYPE_CONTROL.code && it.payload[0] == 0x02.toByte() }) + } returns DctResult.Success( + DctPacket.create(DctCommand.RES_CARTYPE_CONTROL, byteArrayOf(0x02, 0x01, 0x01)) + ) + service.deleteCarType(1, 0) + + // 3. DB 목록 및 선택/삭제 + coEvery { + repo.sendCommand(match { it.command == DctCommand.CMD_DB.code && it.payload[0] == 0x01.toByte() }) + } returns DctResult.Success( + DctPacket.create(DctCommand.RES_DB, byteArrayOf(0x01, 0x01, 0x01)) // 1개 DB + ) + coEvery { + repo.sendCommand(match { it.command == DctCommand.CMD_DB.code && it.payload[0] == 0x02.toByte() }) + } returns DctResult.Success( + DctPacket.create(DctCommand.RES_DB, byteArrayOf(0x02, 0x01, 0x00, 0x04, 'T'.code.toByte(), 'E'.code.toByte(), 'S'.code.toByte(), 'T'.code.toByte())) + ) + val dbs = service.listDatabases() + assertEquals(listOf("TEST"), dbs) + + coEvery { + repo.sendCommand(match { it.command == DctCommand.CMD_DB.code && it.payload[0] == 0x03.toByte() }) + } returns DctResult.Success( + DctPacket.create(DctCommand.RES_DB, byteArrayOf(0x03, 0x01)) + ) + service.deleteDatabase(0) + + coEvery { + repo.sendCommand(match { it.command == DctCommand.CMD_DB.code && it.payload[0] == 0x00.toByte() }) + } returns DctResult.Success( + DctPacket.create(DctCommand.RES_DB, byteArrayOf(0x00, 0x01)) + ) + service.selectDatabase("TEST") + } + + @Test + fun testMaintenanceTabStringFormattingParity() { + // 1. UdsDidTab 문자열 템플릿 보간자 검증 + val f187 = STANDARD_DIDS.first { it.did == 0xF187 } + assertEquals("0xF187", "0x${f187.didHex}") + assertEquals("최대 10자 입력", "최대 ${f187.maxChars}자 입력") + val sampleBytes = byteArrayOf(1, 2, 3, 4) + assertEquals("DID 0xF187 읽기 성공 (4 bytes)", "DID 0x${f187.didHex} 읽기 성공 (${sampleBytes.size} bytes)") + val readResults = mapOf(0xF187 to sampleBytes, 0xF100 to byteArrayOf(0x01)) + assertEquals("DID 일괄 읽기 완료 (2개 응답 수신)", "DID 일괄 읽기 완료 (${readResults.size}개 응답 수신)") + + // 2. VehicleDbTab 문자열 템플릿 보간자 검증 + val selectedMaker = 1 + assertEquals("단말 차종 목록 조회 중 (Maker=1)...", "단말 차종 목록 조회 중 (Maker=$selectedMaker)...") + val carList = listOf("SG2", "NQ5") + assertEquals("(2개)", "(${carList.size}개)") + assertEquals("[#0] SG2", "[#0] ${carList[0]}") + assertEquals("[#1] NQ5", "[#1] ${carList[1]}") + val selectedCount = 2 + assertEquals("삭제 (2)", "삭제 ($selectedCount)") + val dbList = listOf("TEST_DB") + assertEquals("(1개)", "(${dbList.size}개)") + assertEquals("[#0] TEST_DB", "[#0] ${dbList[0]}") + val targetIndices = listOf(0, 1) + assertEquals("선택한 2개 차종을 단말에서 삭제하시겠습니까?\n이 작업은 되돌릴 수 없습니다.", "선택한 ${targetIndices.size}개 차종을 단말에서 삭제하시겠습니까?\n이 작업은 되돌릴 수 없습니다.") + assertEquals("2개 차종 삭제 완료", "${targetIndices.size}개 차종 삭제 완료") + + // 3. LogTab 문자열 템플릿 보간자 검증 + val logFiles = listOf(RemoteLogFile(1, "log01.csv"), RemoteLogFile(2, "log02.csv")) + assertEquals("(2개 파일)", "(${logFiles.size}개 파일)") + assertEquals("선택 로그 삭제 (1)", "선택 로그 삭제 (1)") + assertEquals("#1", "#${logFiles[0].index}") + assertEquals("#2", "#${logFiles[1].index}") + assertEquals("선택한 2개의 로그 파일을 단말 플래시 메모리에서 영구 삭제하시겠습니까?\n이 작업은 되돌릴 수 없습니다.", "선택한 ${logFiles.size}개의 로그 파일을 단말 플래시 메모리에서 영구 삭제하시겠습니까?\n이 작업은 되돌릴 수 없습니다.") + assertEquals("2개 로그 파일 삭제 완료", "${logFiles.size}개 로그 파일 삭제 완료") + } +} diff --git a/Android/app/src/test/java/com/dct/app/presentation/signaltree/SignalTableTextStylesTest.kt b/Android/app/src/test/java/com/dct/app/presentation/signaltree/SignalTableTextStylesTest.kt new file mode 100644 index 0000000..bc0df83 --- /dev/null +++ b/Android/app/src/test/java/com/dct/app/presentation/signaltree/SignalTableTextStylesTest.kt @@ -0,0 +1,32 @@ +package com.dct.app.presentation.signaltree + +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.dp +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +class SignalTableTextStylesTest { + @Test + fun `normal and tablet font scales retain compact rows`() { + for (scale in listOf(1f, 1.3f)) { + for (density in listOf(1f, 1.75f, 3f)) { + assertEquals(24.dp, signalTableRowHeight(Density(density, scale))) + } + } + } + + @Test + fun `large fonts reserve room for every cell style and vertical padding`() { + for (scale in listOf(1.3f, 1.5f, 2f, 3f)) { + with(Density(1.75f, scale)) { + val row = signalTableRowHeight(this) + for (style in listOf(SignalTableTextStyle, SignalTableDetailTextStyle, SignalTableColorTextStyle)) { + assertTrue(row >= style.lineHeight.toDp() + 4.dp) + assertTrue(style.lineHeight > style.fontSize) + assertEquals(false, style.platformStyle?.paragraphStyle?.includeFontPadding) + } + } + } + } +} diff --git a/Android/app/src/test/java/com/dct/app/presentation/signaltree/TableAutoFitTest.kt b/Android/app/src/test/java/com/dct/app/presentation/signaltree/TableAutoFitTest.kt new file mode 100644 index 0000000..04acfd8 --- /dev/null +++ b/Android/app/src/test/java/com/dct/app/presentation/signaltree/TableAutoFitTest.kt @@ -0,0 +1,78 @@ +package com.dct.app.presentation.signaltree + +import androidx.compose.ui.unit.dp +import com.dct.app.data.repository.DpcWorkspaceJson +import com.dct.app.domain.model.project.DpcPageState +import com.dct.app.domain.model.project.DpcWindowState +import com.dct.app.domain.model.project.DpcWorkspaceState +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.Test + +class TableAutoFitTest { + @Test fun `opening respects default header controls and fractional clipping`() { + assertEquals(110.dp, calculateAutoFitWidth(110.dp, 30.dp, 20.dp)) + assertEquals(126.25.dp, calculateAutoFitWidth(110.dp, 90.25.dp, 50.dp)) + assertEquals(110.25.dp, calculateAutoFitWidth(110.dp, 30.dp, 101.25.dp)) + } + + @Test fun `checkbox and nested description padding fit and maximum matches manual resize`() { + assertEquals(161.dp, calculateAutoFitWidth(130.dp, 30.dp, 130.dp, 30.dp)) + assertEquals(209.dp, calculateAutoFitWidth(140.dp, 30.dp, 190.dp, 18.dp)) + assertEquals(1200.dp, calculateAutoFitWidth(110.dp, 100.dp, 2000.dp)) + } + + @Test fun `old saved narrow width opens instead of closing and close restores default`() { + val store = WindowUiStore(mapOf("width:name" to "70.0", "search" to "RPM")) {} + val widths = WindowColumnWidths(store) + assertFalse(widths.isAutoFit("name", 151.dp)) + widths.toggleAutoFit("name", 110.dp, 151.dp) + assertEquals(151.dp, widths["name"]) + assertTrue(widths.isAutoFit("name", 151.dp)) + widths.toggleAutoFit("name", 110.dp, 151.dp) + assertNull(widths["name"]) + assertFalse(store.values.containsKey("autoFit:name")) + assertEquals("RPM", store.values["search"]) + } + + @Test fun `manual drag clears fit mode even when width is larger than default`() { + val widths = WindowColumnWidths(WindowUiStore(emptyMap()) {}) + widths.toggleAutoFit("name", 110.dp, 200.dp) + widths["name"] = 150.dp + assertFalse(widths.isAutoFit("name", 200.dp)) + widths.toggleAutoFit("name", 110.dp, 200.dp) + assertEquals(200.dp, widths["name"]) + widths["name"] = 300.dp + assertFalse(widths.isAutoFit("name", 200.dp)) + widths.toggleAutoFit("name", 110.dp, 200.dp) + assertEquals(300.dp, widths["name"]) + assertTrue(widths.isAutoFit("name", 200.dp)) + } + + @Test fun `fractional stale fit is reopened not collapsed after metadata or scale change`() { + val widths = WindowColumnWidths(WindowUiStore(emptyMap()) {}) + widths.toggleAutoFit("name", 110.dp, 180.dp) + assertFalse(widths.isAutoFit("name", 180.25.dp)) + widths.toggleAutoFit("name", 110.dp, 180.25.dp) + assertEquals(180.25.dp, widths["name"]) + assertTrue(widths.isAutoFit("name", 180.25.dp)) + } + + @Test fun `width and fit mode are saved atomically and round trip with window preferences`() { + var writes = 0 + val store = WindowUiStore(mapOf("width:value" to "65.0")) { writes++ } + val widths = WindowColumnWidths(store) + widths.toggleAutoFit("name", 110.dp, 200.dp) + assertEquals(1, writes) + val window = DpcWindowState("one", "MDB_SIGNAL_LIST", "MDB", contentState = store.values) + val workspace = DpcWorkspaceState("p", listOf(DpcPageState("p", "Page", listOf(window)))) + val restored = DpcWorkspaceJson.decode(DpcWorkspaceJson.encode(workspace))!! + val preferences = restored.pages.single().windows.single().contentState + val reopened = WindowColumnWidths(WindowUiStore(preferences) {}) + assertEquals(200.dp, reopened["name"]) + assertTrue(reopened.isAutoFit("name", 200.dp)) + assertEquals("65.0", preferences["width:value"]) + widths["name"] = Float.NaN.dp + assertEquals(1, writes) + assertTrue(widths.isAutoFit("name", 200.dp)) + } +} diff --git a/Android/app/src/test/java/com/dct/app/tests/A2lCharacteristicLayoutTest.kt b/Android/app/src/test/java/com/dct/app/tests/A2lCharacteristicLayoutTest.kt new file mode 100644 index 0000000..4097266 --- /dev/null +++ b/Android/app/src/test/java/com/dct/app/tests/A2lCharacteristicLayoutTest.kt @@ -0,0 +1,113 @@ +package com.dct.app.tests + +import com.dct.app.data.parser.A2lParser +import com.dct.app.domain.model.a2l.A2lDataType +import com.dct.app.data.protocol.DctProtocolRequests +import org.junit.Assert.* +import org.junit.Test + +class A2lCharacteristicLayoutTest { + private fun parse(dimensions: String = "20 2", fnc: String = "1 SWORD COLUMN_DIR DIRECT") = + A2lParser.parseA2lFull(""" + /begin CHARACTERISTIC Table "table" + VAL_BLK 0x011E0020 AUTO_EXTRACTION_VALUE_S16 0 NO_COMPU_METHOD -32768 32767 + MATRIX_DIM $dimensions + /end CHARACTERISTIC + /begin CHARACTERISTIC Scalar "scalar" + VALUE 0x011E0000 AUTO_EXTRACTION_VALUE_S16 0 NO_COMPU_METHOD -32768 32767 + /end CHARACTERISTIC + /begin RECORD_LAYOUT AUTO_EXTRACTION_VALUE_S16 + FNC_VALUES $fnc + /end RECORD_LAYOUT + """.trimIndent().byteInputStream()) + + @Test fun recordLayoutAfterCharacteristicsResolvesActualTypeInsteadOfName() { + val result = parse() + assertEquals(A2lDataType.SWORD, result.characteristics.single().a2lDataType) + val array = result.characteristicsArray.single() + assertEquals("SWORD", array.dataType) + assertEquals("COLUMN_DIR", array.indexMode) + assertEquals(40, array.values.size) + assertNull(array.calibrationLayoutError) + } + + @Test fun allFortyCellsHaveUniquePcCompatibleAddressesFor20By2() { + val array = parse().characteristicsArray.single() + // PC GetArrayAddressIDby2D: base + (2*x+y)*2, not row-major y*20+x. + for (y in 0..1) for (x in 0..19) { + assertEquals(0x011E0020L + (2 * x + y) * 2, array.cellAddress(y * 20 + x)) + } + assertEquals(40, array.values.indices.map(array::cellAddress).distinct().size) + assertEquals(0x011E0024L, array.cellAddress(1)) + assertEquals(0x011E0022L, array.cellAddress(20)) + } + + @Test fun columnOrderUsesActualRowCountInsteadOfHardcodedTwo() { + val array = parse("4 3").characteristicsArray.single() + assertEquals(0x011E0026L, array.cellAddress(1)) + assertEquals(0x011E0022L, array.cellAddress(4)) + assertEquals(0x011E0036L, array.cellAddress(11)) + } + + @Test fun rowOrderAndOneDimensionalArraysRemainContiguous() { + val row = parse("4 3", "1 UWORD ROW_DIR DIRECT").characteristicsArray.single() + for (i in row.values.indices) assertEquals(0x011E0020L + i * 2, row.cellAddress(i)) + val one = parse("20").characteristicsArray.single() + for (i in one.values.indices) assertEquals(0x011E0020L + i * 2, one.cellAddress(i)) + } + + @Test fun declaredTypeOverridesMisleadingDepositNameIncluding64BitTypes() { + for ((declared, expected) in listOf("UBYTE" to A2lDataType.UBYTE, "UWORD" to A2lDataType.UWORD, + "A_UINT64" to A2lDataType.UINT64, "A_INT64" to A2lDataType.INT64, + "FLOAT64_IEEE" to A2lDataType.FLOAT64_IEEE)) { + assertEquals(expected, parse(fnc = "1 $declared ROW_DIR DIRECT").characteristicsArray.single().a2lDataType) + } + } + + @Test fun unsupportedLayoutsRejectAddressCalculationInsteadOfWritingGuessedBytes() { + for (fnc in listOf("2 SWORD COLUMN_DIR DIRECT", "1 SWORD COLUMN_DIR PWORD", + "1 SWORD ALTERNATE_CURVES DIRECT", "1 UNKNOWN COLUMN_DIR DIRECT")) { + val result = parse(fnc = fnc) + assertNotNull(result.characteristics.single().calibrationLayoutError) + val array = result.characteristicsArray.single() + assertNotNull(array.calibrationLayoutError) + assertThrows(IllegalArgumentException::class.java) { array.cellAddress(0) } + } + } + + @Test fun missingLayoutDoesNotSilentlyEnableUnknownTypeOrMultidimensionalWrites() { + val unknown = A2lParser.parseA2lFull(""" + /begin CHARACTERISTIC Unknown "unknown" + VAL_BLK 0x1000 MissingLayout 0 NO_COMPU_METHOD 0 100 MATRIX_DIM 2 2 + /end CHARACTERISTIC + """.trimIndent().byteInputStream()).characteristicsArray.single() + assertNotNull(unknown.calibrationLayoutError) + assertThrows(IllegalArgumentException::class.java) { unknown.cellAddress(0) } + // 이름에 자료형이 들어 있어도 실제 선언이 없으면 장비 쓰기를 허용하지 않는다. + val named = A2lParser.parseA2lFull(""" + /begin CHARACTERISTIC Named "named" + VALUE 0x1000 Deposit_UWORD 0 NO_COMPU_METHOD 0 100 + /end CHARACTERISTIC + """.trimIndent().byteInputStream()).characteristics.single() + assertEquals(A2lDataType.UWORD, named.a2lDataType) + assertNotNull(named.calibrationLayoutError) + } + + @Test fun negativeOutOfBoundsHigherDimensionalAndOverflowAddressesAreRejected() { + val array = parse().characteristicsArray.single() + for (index in listOf(-1, 40)) assertThrows(IllegalArgumentException::class.java) { array.cellAddress(index) } + assertThrows(IllegalArgumentException::class.java) { parse("2 2 2").characteristicsArray.single().cellAddress(0) } + assertThrows(IllegalArgumentException::class.java) { array.copy(ecuAddress = 0xFFFF_FFFFL).cellAddress(0) } + assertThrows(IllegalArgumentException::class.java) { array.copy(ecuAddress = Long.MAX_VALUE).cellAddress(0) } + } + + @Test fun correctedCellAddressAndSignedValueProducePcCompatibleWriteAndReadPayloads() { + val array = parse().characteristicsArray.single() + val address = array.cellAddress(1) + val write = DctProtocolRequests.xcpCharacteristicWrite(address, array.a2lDataType, -123.0, false) + assertArrayEquals(byteArrayOf(3, 0x85.toByte(), 0xFF.toByte(), 0x24, 0, 0x1E, 1), write.payload) + val read = DctProtocolRequests.xcpCharacteristicRead(address, array.a2lDataType, false) + assertArrayEquals(byteArrayOf(3, 0x24, 0, 0x1E, 1), read.payload) + assertEquals(-123.0, array.a2lDataType.unpackRawValue(write.payload, 1, false), 0.0) + } +} diff --git a/Android/app/src/test/java/com/dct/app/tests/A2lParserTest.kt b/Android/app/src/test/java/com/dct/app/tests/A2lParserTest.kt index d70e5b1..cbb46d9 100644 --- a/Android/app/src/test/java/com/dct/app/tests/A2lParserTest.kt +++ b/Android/app/src/test/java/com/dct/app/tests/A2lParserTest.kt @@ -9,6 +9,50 @@ import java.io.ByteArrayInputStream class A2lParserTest { + @Test + fun `XCP ON CAN 설정과 COMPU 포맷 단위를 PC처럼 상속한다`() { + val sample = """ + /begin IF_DATA XCP + BYTE_ORDER_MSB_FIRST + /end IF_DATA + /begin IF_DATA XCP + /begin XCP_ON_CAN + CAN_ID_MASTER 0x690 + CAN_ID_SLAVE 0x693 + BYTE_ORDER_MSB_LAST + /end XCP_ON_CAN + /end IF_DATA + /begin IF_DATA XCP + BYTE_ORDER_MSB_FIRST + /end IF_DATA + /begin COMPU_METHOD Conv "conversion" + LINEAR "%8.4" "km/h" + COEFFS_LINEAR 0.5 1.0 + /end COMPU_METHOD + /begin MEASUREMENT VehicleSpeed "speed" + UWORD Conv 1 0 0 300 + ECU_ADDRESS 0x1000 + /end MEASUREMENT + /begin MEASUREMENT Temperature "temp" + SWORD Conv 1 0 -40 200 + ECU_ADDRESS 0x1002 + FORMAT "%5.1f" + PHYS_UNIT "degC" + /end MEASUREMENT + """.trimIndent() + + val result = A2lParser.parseA2lFull(ByteArrayInputStream(sample.toByteArray())) + assertEquals(0x690L, result.ifData!!.masterCanId) + assertEquals(false, result.ifData!!.isMsbFirst) + val speed = result.measurements.first { it.name == "VehicleSpeed" } + assertEquals("%8.4", speed.measurementFormat) + assertEquals("km/h", speed.unit) + assertEquals("2.2350", speed.formatMeasurementValue(2.2346)) + val temperature = result.measurements.first { it.name == "Temperature" } + assertEquals("%5.1f", temperature.measurementFormat) + assertEquals("℃", temperature.unit) + } + @Test fun testA2lParsing() { val sampleA2l = """ diff --git a/Android/app/src/test/java/com/dct/app/tests/AlphanumericalUpdatesTest.kt b/Android/app/src/test/java/com/dct/app/tests/AlphanumericalUpdatesTest.kt new file mode 100644 index 0000000..78f80b8 --- /dev/null +++ b/Android/app/src/test/java/com/dct/app/tests/AlphanumericalUpdatesTest.kt @@ -0,0 +1,106 @@ +package com.dct.app.tests + +import com.dct.app.domain.model.mdb.MdbAttrType +import com.dct.app.domain.model.mdb.SignalDefinition +import com.dct.app.presentation.monitor.components.AlphaSignal +import com.dct.app.presentation.monitor.components.alphaDisplayValues +import com.dct.app.presentation.monitor.components.resolveAlphaSignalSelection +import com.dct.app.presentation.monitor.components.selectAlphaValues +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class AlphanumericalUpdatesTest { + private fun signal(name: String = "RPM", idx: Int = 1) = SignalDefinition( + 1, idx, name, MdbAttrType.ATTR_2_UINT16, 0.1, 0.0, "", 0x123456, 0.0, 100.0 + ) + + @Test + fun restoredAliasesKeepOrderDeduplicateAndReportOnlyMissingReferences() { + val mdb = signal() + val can = signal("Speed", 7).copy(messageId = 291) + val result = resolveAlphaSignalSelection( + listOf("can_7", "sig_1_1", "can_291_Speed", "missing", "mdb_sig_1_1"), + listOf(can), listOf(mdb), emptyList(), emptyList(), emptyList() + ) + assertEquals(listOf("can_291_Speed", "sig_1_1"), result.signals.map { it.id }) + assertEquals(listOf("missing"), result.unresolvedIds) + assertSame(mdb, result.signals.last().definition) + } + + @Test + fun sourceReplacementRefreshesDefinitionAndResolvesPreviouslyMissingSignal() { + val ids = listOf("sig_1_1", "a2l_4660") + val old = resolveAlphaSignalSelection(ids, emptyList(), listOf(signal()), emptyList(), emptyList(), emptyList()) + val replacement = signal("New RPM").copy(unit = "rpm", color = 0x0000FF) + val a2l = signal("Pressure").copy(adl = 4660, measurementFormat = "%6.2f") + val updated = resolveAlphaSignalSelection(ids, emptyList(), listOf(replacement), listOf(a2l), emptyList(), emptyList()) + + assertEquals(listOf("a2l_4660"), old.unresolvedIds) + assertTrue(updated.unresolvedIds.isEmpty()) + assertSame(replacement, updated.signals.first().definition) + assertEquals("12.35", updated.signals.last().formatValue(12.345)) + } + + @Test + fun valueProjectionPreservesCanonicalPriorityNameFallbackAndMissingValues() { + val entries = listOf(AlphaSignal("sig_1_1", signal()), AlphaSignal("sig_1_2", signal("Temp", 2))) + assertEquals( + mapOf("sig_1_1" to 10.0, "sig_1_2" to 20.0), + selectAlphaValues(entries, mapOf("sig_1_1" to 10.0, "RPM" to 99.0, "Temp" to 20.0, "other" to 50.0)) + ) + val cleared = selectAlphaValues(entries, emptyMap()) + assertEquals(setOf("sig_1_1", "sig_1_2"), cleared.keys) + assertNull(cleared["sig_1_1"]) + assertEquals("N/A", entries.first().formatValue(cleared["sig_1_1"])) + } + + @Test + fun unrelatedPacketsDoNotPublishNewAlphaValues() = runTest { + val entry = AlphaSignal("sig_1_1", signal()) + val telemetry = MutableStateFlow(mapOf("sig_1_1" to 1.0)) + val received = mutableListOf>() + backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { + telemetry.alphaDisplayValues(listOf(entry)).collect { received.add(it) } + } + advanceTimeBy(34) + runCurrent() + repeat(5) { i -> + telemetry.value = mapOf(entry.id to 1.0, "other" to i.toDouble()) + advanceTimeBy(34) + runCurrent() + } + assertEquals(listOf(mapOf(entry.id to 1.0)), received) + } + + @Test + fun burstUsesLatestValueAndPublishesFinalValueWithoutAnotherPacket() = runTest { + val entry = AlphaSignal("sig_1_1", signal()) + val telemetry = MutableStateFlow>(emptyMap()) + val received = mutableListOf>() + backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { + telemetry.alphaDisplayValues(listOf(entry)).collect { received.add(it) } + } + repeat(20) { i -> + telemetry.value = mapOf(entry.id to i.toDouble()) + advanceTimeBy(1) + } + assertTrue(received.isEmpty()) + advanceTimeBy(14) + runCurrent() + assertEquals(listOf(mapOf(entry.id to 19.0)), received) + telemetry.value = emptyMap() + advanceTimeBy(34) + runCurrent() + assertEquals(mapOf(entry.id to null), received.last()) + assertEquals(2, received.size) + } +} diff --git a/Android/app/src/test/java/com/dct/app/tests/CanSignalKeyTest.kt b/Android/app/src/test/java/com/dct/app/tests/CanSignalKeyTest.kt new file mode 100644 index 0000000..6bde642 --- /dev/null +++ b/Android/app/src/test/java/com/dct/app/tests/CanSignalKeyTest.kt @@ -0,0 +1,44 @@ +package com.dct.app.tests + +import com.dct.app.domain.model.mdb.CanSignalKey +import com.dct.app.domain.model.mdb.MdbAttrType +import com.dct.app.domain.model.mdb.SignalDefinition +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotEquals +import org.junit.Assert.assertSame +import org.junit.Test + +class CanSignalKeyTest { + private fun signal(idx: Int, messageId: Long, name: String) = SignalDefinition( + reclid = 0, + idx = idx, + name = name, + attr = MdbAttrType.ATTR_0_UINT8, + cvsA = 1.0, + cvsB = 0.0, + unit = "", + color = 0L, + min = 0.0, + max = 255.0, + messageId = messageId + ) + + @Test + fun stableKeyDoesNotDependOnParserIndex() { + val first = signal(1, 0x123, "VehicleSpeed") + val reparsed = signal(999, 0x123, "VehicleSpeed") + + assertEquals(CanSignalKey.stable(first), CanSignalKey.stable(reparsed)) + assertNotEquals(CanSignalKey.legacy(first), CanSignalKey.legacy(reparsed)) + } + + @Test + fun findsStableAndLegacyProjectKeys() { + val target = signal(7, 0x456, "MotorTorque") + val signals = listOf(target) + + assertSame(target, CanSignalKey.find("can_1110_MotorTorque", signals)) + assertSame(target, CanSignalKey.find("can_7", signals)) + assertSame(target, CanSignalKey.find("can_7_MotorTorque", signals)) + } +} diff --git a/Android/app/src/test/java/com/dct/app/tests/ChartContinuousScrollTest.kt b/Android/app/src/test/java/com/dct/app/tests/ChartContinuousScrollTest.kt index a9f9437..2edb55a 100644 --- a/Android/app/src/test/java/com/dct/app/tests/ChartContinuousScrollTest.kt +++ b/Android/app/src/test/java/com/dct/app/tests/ChartContinuousScrollTest.kt @@ -19,15 +19,16 @@ class ChartContinuousScrollTest { } private fun ChartBatchRenderer.sample(time: Double) = addSampleSec("s", time, 1.0) - @Test fun allFixedPresetsFollowEveryNewTimestampWhileKeepingSpanAndTickPreset() = runTest { + @Test fun allFixedPresetsHoldRangeBetweenBoundaryJumps() = runTest { for (preset in presets) { val r = renderer() + r.sample(10000.0 - preset.displaySpanSec) r.sample(10000.0) r.setTimeWindow(preset) for (time in listOf(10000.016, 10000.033, 10000.1, 10001.0, 10002.0)) { r.sample(time) val frame = r.buildSnapshot(600) - assertEquals(time, frame.timeMaxSec, 1e-8) + assertEquals(10000.016 + preset.tickIntervalSec!!, frame.timeMaxSec, 1e-8) assertEquals(preset.displaySpanSec, frame.timeMaxSec - frame.timeMinSec, 1e-8) assertEquals(preset, frame.xAxisPreset) assertTrue(frame.isXAxisAuto) @@ -35,7 +36,7 @@ class ChartContinuousScrollTest { } } - @Test fun clearRetainsInitialSixIntervalsThenScrollsBySampleDelta() = runTest { + @Test fun clearRetainsInitialSixIntervalsThenJumpsByTick() = runTest { for (preset in presets) { val r = renderer() r.setTimeWindow(preset) @@ -46,14 +47,42 @@ class ChartContinuousScrollTest { r.sample(end - .1) assertEquals(end, r.buildSnapshot(600).timeMaxSec) r.sample(end) - assertEquals(end, r.buildSnapshot(600).timeMaxSec) + assertEquals(end + preset.tickIntervalSec!!, r.buildSnapshot(600).timeMaxSec) r.sample(end + .016) val frame = r.buildSnapshot(600) - assertEquals(end + .016, frame.timeMaxSec, 1e-8) + assertEquals(end + preset.tickIntervalSec!!, frame.timeMaxSec, 1e-8) assertEquals(preset.resetSpanSec, frame.timeMaxSec - frame.timeMinSec, 1e-8) } } + @Test fun clearSamplesOnlyKeepsGraphItemsAndOptions() = runTest { + val r = renderer() + r.setTimeWindow(TimeWindowPreset.SEC_10) + r.setYAxisRange(-5.0, 25.0) + r.setLegendPosition(2) + r.setSeriesColor("s", 0x00FF00L) + r.setSeriesThickness("s", 4.0f) + r.addSampleSec("s", 1000.0, 2.0) + r.addSampleSec("s", 1010.0, 4.0) + val before = r.exportWorkspaceState() + + r.clearSamplesOnly() + + val series = r.seriesListFlow.value.single() + val after = r.exportWorkspaceState() + assertEquals(1, r.seriesListFlow.value.size, "Signal item must remain bound") + assertEquals(0, series.buffer.size, "Only samples must be removed") + assertEquals(before.axes, after.axes) + assertEquals(before.timeWindowSec, after.timeWindowSec) + assertEquals(before.timeWindowPreset, after.timeWindowPreset) + assertEquals(before.isAutoTimeWindow, after.isAutoTimeWindow) + assertEquals(before.isXAxisAuto, after.isXAxisAuto) + assertEquals(before.isYAxisAuto, after.isYAxisAuto) + assertEquals(before.legendPosition, after.legendPosition) + assertEquals(0x00FF00L, series.color) + assertEquals(4.0f, series.lineWidthDp) + } + @Test fun firstBatchLargerThanWindowDoesNotAllocateAnExtraBlankTick() = runTest { val r = renderer() r.setTimeWindow(TimeWindowPreset.SEC_10) @@ -64,8 +93,118 @@ class ChartContinuousScrollTest { assertEquals(1460.0, frame.timeMinSec) } + @Test fun holdAccumulatesFromFirstSampleAndKeepsWholeRecordingVisible() = runTest { + val r = renderer() + r.setTimeWindow(TimeWindowPreset.HOLD) + r.sample(1000.0) + val initial = r.buildSnapshot(600) + assertEquals(1000.0, initial.timeMinSec, 1e-8) + assertEquals(1060.0, initial.timeMaxSec, 1e-8) + assertFalse(initial.isXAxisAuto) + assertEquals(TimeWindowPreset.HOLD, initial.xAxisPreset) + + r.sample(1010.0) + val withinInitialSpan = r.buildSnapshot(600) + assertEquals(1000.0, withinInitialSpan.timeMinSec, 1e-8) + assertEquals(1060.0, withinInitialSpan.timeMaxSec, 1e-8) + + r.sample(1100.0) + val accumulated = r.buildSnapshot(600) + assertEquals(1000.0, accumulated.timeMinSec, 1e-8) + assertEquals(1110.0, accumulated.timeMaxSec, 1e-8) + + r.sample(2000.0) + val later = r.buildSnapshot(600) + assertEquals(1000.0, later.timeMinSec, 1e-8) + assertEquals(2030.0, later.timeMaxSec, 1e-8) + + // Hold guarantees both endpoints stay visible; historical panning is + // available after explicitly switching to Manual/zooming. + r.panTimeWindow(-100.0) + val stillFit = r.buildSnapshot(600) + assertEquals(1000.0, stillFit.timeMinSec, 1e-8) + assertEquals(later.timeMaxSec, stillFit.timeMaxSec, 1e-8) + } + + @Test fun holdWaitsForEachBoundaryThenAddsOneCurrentTickInBothGraphLayouts() = runTest { + for (axis in listOf(AxisPosition.LEFT_0, AxisPosition.LEFT_1)) { + val r = ChartBatchRenderer(TestDispatcherProvider(StandardTestDispatcher(testScheduler))) + r.bindSignal("s", "Signal", axis, 0xFF0000L) + r.setTimeWindow(TimeWindowPreset.HOLD) + r.sample(1000.0) + var before = r.buildSnapshot(600) + repeat(12) { + r.sample(before.timeMaxSec - 0.033) + assertEquals(before.timeMaxSec, r.buildSnapshot(600).timeMaxSec, 1e-8) + val step = com.dct.app.domain.model.chart.ChartAxisTicks.time( + before.timeMinSec, before.timeMaxSec, 600f, TimeWindowPreset.HOLD).interval.seconds + r.sample(before.timeMaxSec) + val expanded = r.buildSnapshot(600) + assertEquals(1000.0, expanded.timeMinSec, 1e-8) + assertEquals(before.timeMaxSec + step, expanded.timeMaxSec, 1e-8) + assertEquals(expanded.timeMaxSec, r.buildSnapshot(600).timeMaxSec, 1e-8, + "Rendering the same sample must not keep expanding the range") + r.sample(before.timeMaxSec + 0.033) + assertEquals(expanded.timeMaxSec, r.buildSnapshot(600).timeMaxSec, 1e-8) + before = expanded + } + } + } + + @Test fun holdUsesEachWindowsTickAndKeepsOptionsAndSamplesThroughClearAndRestore() = runTest { + val wide = renderer() + val narrow = renderer() + for (r in listOf(wide, narrow)) { + r.setTimeWindow(TimeWindowPreset.HOLD) + r.setYAxisRange(-10.0, 10.0) + r.setLegendPosition(2) + r.sample(1000.0) + } + val wideBefore = wide.buildSnapshot(1200) + val narrowBefore = narrow.buildSnapshot(300) + wide.sample(wideBefore.timeMaxSec) + narrow.sample(narrowBefore.timeMaxSec) + assertEquals(1065.0, wide.buildSnapshot(1200).timeMaxSec) + assertEquals(1090.0, narrow.buildSnapshot(300).timeMaxSec) + wide.sample(1060.1) + assertEquals(1065.0, wide.buildSnapshot(1200).timeMaxSec) + val state = wide.exportWorkspaceState() + val frame = wide.buildSnapshot(1200) + assertEquals(-10.0, frame.axesConfig.getValue(AxisPosition.LEFT_0).minVal) + assertEquals(3, wide.seriesListFlow.value.single().buffer.size) + val restored = renderer() + restored.restoreWorkspaceState(wide.seriesListFlow.value, state) + restored.sample(2000.0) + assertEquals(2060.0, restored.buildSnapshot(600).timeMaxSec) + assertEquals(TimeWindowPreset.HOLD, restored.buildSnapshot(600).xAxisPreset) + wide.clearSamplesOnly() + wide.sample(3000.0) + assertEquals(3000.0, wide.buildSnapshot(1200).timeMinSec) + assertEquals(3060.0, wide.buildSnapshot(1200).timeMaxSec) + assertEquals(state.axes, wide.exportWorkspaceState().axes) + assertEquals(state.legendPosition, wide.exportWorkspaceState().legendPosition) + } + + @Test fun selectingHoldWithExistingHistoryFitsEntireRecordingThenWaitsForBoundary() = runTest { + val r = renderer() + r.sample(1000.0) + r.sample(2000.0) + r.setTimeWindow(TimeWindowPreset.HOLD) + val initial = r.buildSnapshot(600) + assertEquals(1000.0, initial.timeMinSec) + assertTrue(initial.timeMaxSec > 2000.0) + r.sample(2000.033) + assertEquals(initial.timeMaxSec, r.buildSnapshot(600).timeMaxSec) + r.resetPan() + val reset = r.buildSnapshot(600) + assertEquals(1000.0, reset.timeMinSec) + assertTrue(reset.timeMaxSec >= 2000.033) + assertEquals(TimeWindowPreset.HOLD, reset.xAxisPreset) + } + @Test fun renderFlowRollsAtFrameCadenceAndStopsMovingWhenSamplesStop() = runTest { val r = renderer() + r.sample(960.0) r.sample(1000.0) r.setTimeWindow(TimeWindowPreset.SEC_10) r.start(backgroundScope) @@ -75,15 +214,16 @@ class ChartContinuousScrollTest { r.sample(time) advanceTimeBy(20) runCurrent() - assertEquals(time, r.renderSnapshotFlow.value.timeMaxSec, 1e-8) + assertEquals(1010.033, r.renderSnapshotFlow.value.timeMaxSec, 1e-8) } advanceTimeBy(1000) runCurrent() - assertEquals(1000.099, r.renderSnapshotFlow.value.timeMaxSec, 1e-8) + assertEquals(1010.033, r.renderSnapshotFlow.value.timeMaxSec, 1e-8) } @Test fun historyBrowsingStillFreezesAndDoubleTapReturnsToContinuousPresetTracking() = runTest { val r = renderer() + r.sample(700.0) r.sample(1000.0) r.setTimeWindow(TimeWindowPreset.MIN_1) r.panTimeWindow(-50.0) @@ -92,7 +232,7 @@ class ChartContinuousScrollTest { r.resetPan() r.sample(2000.033) val frame = r.buildSnapshot(600) - assertEquals(2000.033, frame.timeMaxSec, 1e-8) + assertEquals(2060.033, frame.timeMaxSec, 1e-8) assertEquals(300.0, frame.timeMaxSec - frame.timeMinSec, 1e-8) assertEquals(TimeWindowPreset.MIN_1, frame.xAxisPreset) } @@ -100,13 +240,14 @@ class ChartContinuousScrollTest { @Test fun rollingWindowsDoNotChangeOtherWindowsInitialFillRange() = runTest { val a = renderer() val b = renderer() + a.sample(960.0) a.sample(1000.0) a.setTimeWindow(TimeWindowPreset.SEC_10) b.setTimeWindow(TimeWindowPreset.MIN_1) b.sample(1000.0) val before = b.buildSnapshot(600) a.sample(1000.5) - assertEquals(1000.5, a.buildSnapshot(600).timeMaxSec) + assertEquals(1010.5, a.buildSnapshot(600).timeMaxSec) assertEquals(before.timeMaxSec, b.buildSnapshot(600).timeMaxSec) } } diff --git a/Android/app/src/test/java/com/dct/app/tests/ChartCursorStabilityTest.kt b/Android/app/src/test/java/com/dct/app/tests/ChartCursorStabilityTest.kt index 7f529d1..d4e9e62 100644 --- a/Android/app/src/test/java/com/dct/app/tests/ChartCursorStabilityTest.kt +++ b/Android/app/src/test/java/com/dct/app/tests/ChartCursorStabilityTest.kt @@ -110,14 +110,14 @@ class ChartCursorStabilityTest { advanceTimeBy(20) runCurrent() val live = r.renderSnapshotFlow.value - assertEquals(2.1, live.timeMaxSec) + assertTrue(live.timeMaxSec > 2.1, "Live edge reserves the next tick without changing sample values") assertEquals(1.0, live.crosshairTimeSec) assertEquals(listOf(15.0, 150.0), live.seriesList.map { it.cursorValue }) r.setSeriesVisible("other", true) r.setSeriesColor("other", 0xFFFFFFFFL) val other = r.renderSnapshotFlow.value.seriesList.last() assertTrue(other.isVisible) - assertEquals(0xFFFFFFFFL, other.color) + assertEquals(0x00FFFFFFL, other.color) assertEquals(150.0, other.cursorValue) } diff --git a/Android/app/src/test/java/com/dct/app/tests/ChartInitialFillTest.kt b/Android/app/src/test/java/com/dct/app/tests/ChartInitialFillTest.kt new file mode 100644 index 0000000..ae597f3 --- /dev/null +++ b/Android/app/src/test/java/com/dct/app/tests/ChartInitialFillTest.kt @@ -0,0 +1,90 @@ +package com.dct.app.tests + +import com.dct.app.domain.model.chart.* +import com.dct.app.domain.model.project.DpcChartState +import com.dct.app.domain.usecase.chart.ChartBatchRenderer +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.Test + +@OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class) +class ChartInitialFillTest { + private fun renderer() = ChartBatchRenderer(TestDispatcherProvider(UnconfinedTestDispatcher())).apply { + bindSignal("s", "Signal", AxisPosition.LEFT_0, 0xFF00FFL) + } + private fun ChartBatchRenderer.sample(t: Double) = addSampleSec("s", t, 1.0) + private fun ChartBatchRenderer.assertRange(left: Double, right: Double) { + val frame = buildSnapshot(600) + assertEquals(left, frame.timeMinSec, 1e-8) + assertEquals(right, frame.timeMaxSec, 1e-8) + } + @Test fun presetsFillFromFirstSampleRegardlessOfConfigurationOrder() { + for (preset in TimeWindowPreset.entries.filter { it.tickIntervalSec != null }) { + for (sampleFirst in listOf(true, false)) { + val r = renderer() + if (sampleFirst) r.sample(1000.0) + r.setTimeWindow(preset) + if (!sampleFirst) r.sample(1000.0) + val right = 1000.0 + preset.displaySpanSec + r.assertRange(1000.0, right) + r.sample(1001.0) + r.resetPan() + r.assertRange(1000.0, right) + r.sample(right) + r.assertRange(1000.0 + preset.tickIntervalSec!!, right + preset.tickIntervalSec!!) + r.sample(right + .033) + r.assertRange(1000.0 + preset.tickIntervalSec!!, right + preset.tickIntervalSec!!) + } + } + } + @Test fun doubleTapAfterZoomRestoresLeftAlignedRangeForBothAxisLayouts() { + for (multi in listOf(false, true)) { + val r = renderer() + if (multi) r.bindSignal("b", "Second", AxisPosition.RIGHT_0, 0xFFFFFFL) + r.sample(10.0) + r.sample(12.0) + r.setTimeWindow(TimeWindowPreset.SEC_10) + r.setYAxisRange(-5.0, 5.0) + r.zoomTimeWindow(2f, .5) + r.panTimeWindow(-1.0) + r.setCrosshairTimeSec(11.0) + repeat(3) { r.resetPan(); r.assertRange(10.0, 50.0) } + assertNull(r.renderSnapshotFlow.value.crosshairTimeSec) + assertEquals(-5.0, r.renderSnapshotFlow.value.axesConfig.getValue(AxisPosition.LEFT_0).minVal) + r.sample(51.0) + r.resetPan() + r.assertRange(11.0, 51.0) + r.sample(51.02) + r.assertRange(21.02, 61.02) + } + } + @Test fun customDurationAndRestoredFollowingScaleFillBeforeRolling() { + val r = renderer() + r.setTimeWindow(25.0) + r.sample(1000.0) + r.assertRange(1000.0, 1025.0) + r.sample(1002.0) + r.assertRange(1000.0, 1025.0) + val signals = r.seriesListFlow.value + val saved = r.exportWorkspaceState() + r.restoreWorkspaceState(signals, saved) + r.sample(2000.0) + r.resetPan() + r.assertRange(2000.0, 2025.0) + r.sample(2026.0) + val shifted = r.buildSnapshot(600) + assertTrue(shifted.timeMaxSec > 2026.0) + assertEquals(25.0, shifted.timeMaxSec - shifted.timeMinSec) + } + @Test fun restoredManualGetsInitialDataRangeThenRemainsFrozenUntilReset() { + val r = renderer() + r.restoreWorkspaceState(r.seriesListFlow.value, DpcChartState(timeWindowSec = 25.0, + timeWindowPreset = TimeWindowPreset.MANUAL, isXAxisAuto = false)) + r.sample(1000.0) + r.assertRange(1000.0, 1025.0) + r.sample(1100.0) + r.assertRange(1000.0, 1025.0) + r.resetPan() + r.assertRange(1075.0, 1100.0) + } +} diff --git a/Android/app/src/test/java/com/dct/app/tests/ChartNavigationTest.kt b/Android/app/src/test/java/com/dct/app/tests/ChartNavigationTest.kt index bac7e49..d518341 100644 --- a/Android/app/src/test/java/com/dct/app/tests/ChartNavigationTest.kt +++ b/Android/app/src/test/java/com/dct/app/tests/ChartNavigationTest.kt @@ -1,7 +1,11 @@ package com.dct.app.tests import com.dct.app.domain.model.chart.* +import com.dct.app.data.repository.DpcWorkspaceJson import com.dct.app.domain.model.project.DpcChartState +import com.dct.app.domain.model.project.DpcPageState +import com.dct.app.domain.model.project.DpcWindowState +import com.dct.app.domain.model.project.DpcWorkspaceState import com.dct.app.domain.usecase.chart.ChartBatchRenderer import kotlinx.coroutines.test.UnconfinedTestDispatcher import org.junit.jupiter.api.Assertions.* @@ -44,6 +48,10 @@ class ChartNavigationTest { @Test fun forwardPanCanPassTheOldRightEdgeAsNewSamplesArriveButNotTheLatestSample() { val r = renderer() + r.clearAllData() + r.sample(700.0) + r.sample(1000.0) + r.setTimeWindow(TimeWindowPreset.MIN_1) r.panTimeWindow(-50.0) assertEquals(950.0, r.snapshot().timeMaxSec) r.sample(1200.0) @@ -54,9 +62,9 @@ class ChartNavigationTest { assertEquals(300.0, r.snapshot().span()) assertTrue(r.snapshot().isXAxisAuto) r.sample(1300.0) - assertEquals(1300.0, r.snapshot().timeMaxSec) + assertEquals(1330.0, r.snapshot().timeMaxSec) r.panTimeWindow(-30.0) - assertEquals(1270.0, r.snapshot().timeMaxSec) + assertEquals(1300.0, r.snapshot().timeMaxSec) } @Test fun forwardPanAfterZoomAlsoUsesCurrentLatestData() { @@ -64,11 +72,11 @@ class ChartNavigationTest { r.zoomTimeWindow(2f, .5) r.sample(2000.0) r.panTimeWindow(10000.0) - assertEquals(2000.0, r.snapshot().timeMaxSec) + assertTrue(r.snapshot().timeMaxSec >= 2000.0) assertEquals(150.0, r.snapshot().span()) assertTrue(r.renderSnapshotFlow.value.isXAxisAuto) r.sample(2001.0) - assertEquals(2001.0, r.snapshot().timeMaxSec) + assertTrue(r.snapshot().timeMaxSec > 2001.0) assertEquals(150.0, r.snapshot().span()) } @@ -81,7 +89,7 @@ class ChartNavigationTest { r.panTimeWindow(10000.0) assertTrue(r.snapshot().isXAxisAuto) r.sample(1001.0) - assertEquals(1001.0, r.snapshot().timeMaxSec) + assertTrue(r.snapshot().timeMaxSec > 1001.0) assertEquals(600.0, r.snapshot().span()) } @@ -92,7 +100,7 @@ class ChartNavigationTest { assertEquals(1000.0, r.renderSnapshotFlow.value.timeMaxSec) assertTrue(r.renderSnapshotFlow.value.isXAxisAuto) r.sample(1100.0) - assertEquals(1100.0, r.snapshot().timeMaxSec) + assertTrue(r.snapshot().timeMaxSec > 1100.0) assertEquals(600.0, r.snapshot().span()) } @@ -104,7 +112,7 @@ class ChartNavigationTest { assertEquals(expectedSpan, r.snapshot().span(), 1e-8) val latest = r.snapshot().timeMaxSec + 1.0 r.sample(latest) - assertEquals(latest, r.snapshot().timeMaxSec) + assertTrue(r.snapshot().timeMaxSec > latest) assertEquals(expectedSpan, r.snapshot().span(), 1e-8) } r.resetPan() @@ -125,7 +133,7 @@ class ChartNavigationTest { r.panTimeWindow(1.0) assertTrue(r.renderSnapshotFlow.value.isXAxisAuto) r.sample(1011.0) - assertEquals(1011.0, r.snapshot().timeMaxSec) + assertTrue(r.snapshot().timeMaxSec > 1011.0) assertEquals(150.0, r.snapshot().span()) r.resetPan() assertEquals(300.0, r.snapshot().span()) @@ -142,7 +150,9 @@ class ChartNavigationTest { r.panTimeWindow(.001) assertTrue(r.snapshot().isXAxisAuto) r.sample(1700000000.1) - assertEquals(1700000000.1, r.snapshot().timeMaxSec) + assertTrue(r.snapshot().timeMaxSec > 1700000000.1) + assertTrue(r.snapshot().timeMaxSec < 1700000001.0, "Epoch must not determine the extension step") + assertTrue(r.snapshot().timeMinSec <= 1700000000.1, "Extreme zoom must keep the latest sample visible") assertEquals(.05, r.snapshot().span(), 1e-6) } @@ -184,9 +194,10 @@ class ChartNavigationTest { r.restoreWorkspaceState(r.seriesListFlow.value, state) r.sample(2000.0) other.sample(2000.0) - assertEquals(2000.0, r.snapshot().timeMaxSec) + assertEquals(2000.0, r.snapshot().timeMinSec) + assertEquals(2150.0, r.snapshot().timeMaxSec) assertEquals(150.0, r.snapshot().span()) - assertEquals(950.0, other.snapshot().timeMaxSec) + assertEquals(1250.0, other.snapshot().timeMaxSec) assertFalse(other.snapshot().isXAxisAuto) } @@ -250,7 +261,8 @@ class ChartNavigationTest { r.sample(5000.0) r.resetPan() assertEquals(25.0, r.snapshot().span()) - assertEquals(5000.0, r.snapshot().timeMaxSec) + assertEquals(5000.0, r.snapshot().timeMinSec) + assertEquals(5025.0, r.snapshot().timeMaxSec) assertTrue(r.snapshot().isXAxisAuto) } @@ -269,4 +281,75 @@ class ChartNavigationTest { assertEquals(360.0, a.snapshot().span(), .001) assertTrue(a.snapshot().timeMaxSec.isFinite()) } + + @Test fun directManualXAxisRangeIsPinnedBeforeAndAfterFirstSample() { + val r = ChartBatchRenderer(TestDispatcherProvider(UnconfinedTestDispatcher())).apply { + bindSignal("s", "Signal", AxisPosition.LEFT_0, 0x0000FFL) + } + + assertTrue(r.setXAxisManualRange(100.0, 125.0)) + assertEquals(100.0, r.snapshot().timeMinSec, 1e-8) + assertEquals(125.0, r.snapshot().timeMaxSec, 1e-8) + assertFalse(r.snapshot().isXAxisAuto) + + // A first sample must not replace the explicitly entered interval with + // the normal initial-fill range. + r.sample(1000.0) + assertEquals(100.0, r.snapshot().timeMinSec, 1e-8) + assertEquals(125.0, r.snapshot().timeMaxSec, 1e-8) + assertFalse(r.setXAxisManualRange(125.0, 125.0)) + } + + @Test fun explicitManualXAxisRangeRoundTripsThroughWorkspaceState() { + val r = renderer() + assertTrue(r.setXAxisManualRange(1010.25, 1042.75)) + val state = r.exportWorkspaceState() + assertEquals(1010.25, state.xAxisMinSec) + assertEquals(1042.75, state.xAxisMaxSec) + + val workspace = DpcWorkspaceState( + activePageId = "p", + pages = listOf( + DpcPageState( + id = "p", + title = "Page", + windows = listOf(DpcWindowState("w", "GRAPH", "Graph", chart = state)) + ) + ) + ) + val decodedState = DpcWorkspaceJson.decode(DpcWorkspaceJson.encode(workspace))!! + .pages.single().windows.single().chart!! + assertEquals(1010.25, decodedState.xAxisMinSec) + assertEquals(1042.75, decodedState.xAxisMaxSec) + + val restored = ChartBatchRenderer(TestDispatcherProvider(UnconfinedTestDispatcher())) + restored.restoreWorkspaceState(emptyList(), decodedState) + assertEquals(1010.25, restored.snapshot().timeMinSec, 1e-8) + assertEquals(1042.75, restored.snapshot().timeMaxSec, 1e-8) + assertFalse(restored.snapshot().isXAxisAuto) + } + + @Test fun clearingSamplesKeepsExplicitManualXAxisBounds() { + val r = renderer() + assertTrue(r.setXAxisManualRange(900.0, 930.0)) + r.sample(1000.0) + r.clearSamplesOnly() + assertEquals(900.0, r.snapshot().timeMinSec, 1e-8) + assertEquals(930.0, r.snapshot().timeMaxSec, 1e-8) + r.sample(1100.0) + assertEquals(900.0, r.snapshot().timeMinSec, 1e-8) + assertEquals(930.0, r.snapshot().timeMaxSec, 1e-8) + } + + @Test fun restoredExplicitManualRangeSurvivesDoubleTapReset() { + val source = renderer() + assertTrue(source.setXAxisManualRange(1010.0, 1040.0)) + val restored = ChartBatchRenderer(TestDispatcherProvider(UnconfinedTestDispatcher())) + restored.restoreWorkspaceState(emptyList(), source.exportWorkspaceState()) + restored.sample(2000.0) + restored.resetPan() + assertEquals(1010.0, restored.snapshot().timeMinSec, 1e-8) + assertEquals(1040.0, restored.snapshot().timeMaxSec, 1e-8) + assertFalse(restored.snapshot().isXAxisAuto) + } } diff --git a/Android/app/src/test/java/com/dct/app/tests/ChartPerformanceLifecycleTest.kt b/Android/app/src/test/java/com/dct/app/tests/ChartPerformanceLifecycleTest.kt new file mode 100644 index 0000000..c635a0e --- /dev/null +++ b/Android/app/src/test/java/com/dct/app/tests/ChartPerformanceLifecycleTest.kt @@ -0,0 +1,74 @@ +package com.dct.app.tests + +import com.dct.app.domain.model.chart.AxisPosition +import com.dct.app.domain.usecase.chart.ChartBatchRenderer +import kotlinx.coroutines.test.* +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.Test + +@OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class) +class ChartPerformanceLifecycleTest { + @Test fun backgroundChartKeepsEverySampleAndRestoresOnlyVisibleWindows() = runTest { + val renderer = ChartBatchRenderer(TestDispatcherProvider(StandardTestDispatcher(testScheduler))) + renderer.bindSignal("s", "Signal", AxisPosition.LEFT_0, 0xFF000000) + renderer.start(backgroundScope) + runCurrent() + renderer.setApplicationVisible(false) + val previous = renderer.renderSnapshotFlow.value + for (i in 1..60) renderer.addSampleSec("s", i.toDouble(), i.toDouble()) + renderer.setRenderingEnabled(true) // Late composable callback cannot override background visibility. + advanceTimeBy(100) + runCurrent() + assertSame(previous, renderer.renderSnapshotFlow.value) + assertEquals(60, renderer.seriesListFlow.value.single().buffer.size) + renderer.setRenderingEnabled(false) // Hidden page must remain hidden on return. + renderer.setApplicationVisible(true) + advanceTimeBy(100) + runCurrent() + assertSame(previous, renderer.renderSnapshotFlow.value) + renderer.setRenderingEnabled(true) + advanceTimeBy(20) + runCurrent() + assertEquals(60.0, renderer.renderSnapshotFlow.value.seriesList.single().latestValue) + renderer.dispose() + } + + @Test fun hiddenChartRetainsSamplesWithoutPublishingFramesAndDisposalReleasesBindings() = runTest { + val renderer = ChartBatchRenderer(TestDispatcherProvider(StandardTestDispatcher(testScheduler))) + renderer.bindSignal("s", "Signal", AxisPosition.LEFT_0, 0xFF000000) + renderer.start(backgroundScope) + runCurrent() + renderer.setRenderingEnabled(false) + val previous = renderer.renderSnapshotFlow.value + renderer.addSampleSec("s", 100.0, 10.0) + advanceTimeBy(100) + runCurrent() + assertSame(previous, renderer.renderSnapshotFlow.value) + assertEquals(1, renderer.seriesListFlow.value.single().buffer.size) + renderer.setRenderingEnabled(true) + advanceTimeBy(20) + runCurrent() + assertEquals(10.0, renderer.renderSnapshotFlow.value.seriesList.single().latestValue) + renderer.dispose() + assertTrue(renderer.seriesListFlow.value.isEmpty()) + assertTrue(renderer.renderSnapshotFlow.value.seriesList.isEmpty()) + } + + @Test fun successivePinchesComposeBeforeBackgroundFrameIsBuilt() = runTest { + val renderer = ChartBatchRenderer(TestDispatcherProvider(StandardTestDispatcher(testScheduler))) + renderer.bindSignal("s", "Signal", AxisPosition.LEFT_0, 0xFF000000) + for (i in 0..100) renderer.addSampleSec("s", i.toDouble(), i.toDouble()) + renderer.setTimeWindow(20.0) + renderer.start(backgroundScope) + runCurrent() + val before = renderer.renderSnapshotFlow.value + renderer.zoomTimeWindow(2f, 0.5) + renderer.zoomTimeWindow(2f, 0.5) + assertSame(before, renderer.renderSnapshotFlow.value, "Input must not resample synchronously") + advanceTimeBy(20) + runCurrent() + val after = renderer.renderSnapshotFlow.value + assertEquals(5.0, after.timeMaxSec - after.timeMinSec, 1e-8) + renderer.dispose() + } +} diff --git a/Android/app/src/test/java/com/dct/app/tests/ChartStoppedNavigationTest.kt b/Android/app/src/test/java/com/dct/app/tests/ChartStoppedNavigationTest.kt index 00462f5..23fa5ec 100644 --- a/Android/app/src/test/java/com/dct/app/tests/ChartStoppedNavigationTest.kt +++ b/Android/app/src/test/java/com/dct/app/tests/ChartStoppedNavigationTest.kt @@ -21,7 +21,8 @@ class ChartStoppedNavigationTest { } // Assert the flow the UI collects, not buildSnapshot(), which would mask the missing render. - private fun assertRight(r: ChartBatchRenderer, expected: Double, span: Double = 30.0) { + private fun TestScope.assertRight(r: ChartBatchRenderer, expected: Double, span: Double = 30.0) { + runCurrent() // A stopped chart publishes one background frame, without restarting its loop. val frame = r.renderSnapshotFlow.value assertEquals(expected, frame.timeMaxSec, 1e-8) assertEquals(span, frame.timeMaxSec - frame.timeMinSec, 1e-8) @@ -112,7 +113,7 @@ class ChartStoppedNavigationTest { r.start(backgroundScope) r.addSampleSec("s", 1001.0, 1001.0) runCurrent() - assertRight(r, 1001.0, 15.0) + assertRight(r, 1002.0, 15.0) r.stop() r.resetPan() assertRight(r, 1001.0, 30.0) diff --git a/Android/app/src/test/java/com/dct/app/tests/ChartTelemetryResolverTest.kt b/Android/app/src/test/java/com/dct/app/tests/ChartTelemetryResolverTest.kt new file mode 100644 index 0000000..fb702b6 --- /dev/null +++ b/Android/app/src/test/java/com/dct/app/tests/ChartTelemetryResolverTest.kt @@ -0,0 +1,127 @@ +package com.dct.app.tests + +import com.dct.app.domain.model.chart.AxisPosition +import com.dct.app.domain.model.chart.ChartSignalSeries +import com.dct.app.domain.model.mdb.MdbAttrType +import com.dct.app.domain.model.mdb.MdbDatabase +import com.dct.app.domain.model.mdb.SignalDefinition +import com.dct.app.domain.model.mdb.SignalGroup +import com.dct.app.domain.usecase.chart.ChartTelemetryResolver +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.Test + +class ChartTelemetryResolverTest { + private fun signal(idx: Int, name: String = "Speed", reclid: Int = 0, adl: Int = 42) = + SignalDefinition(reclid, idx, name, MdbAttrType.ATTR_0_UINT8, 1.0, 0.0, "", 0, 0.0, 255.0, + adl = adl, messageId = 0x456) + + private fun series(id: String, name: String = "Old name") = + ChartSignalSeries(id, name, AxisPosition.LEFT_0, 0L) + + private fun database(signals: List) = + MdbDatabase(groups = listOf(SignalGroup(1, "", "", true, signals = signals))) + + @Test fun canStableLegacyHexAndCaseInsensitiveKeysKeepExistingValues() { + val resolver = ChartTelemetryResolver() + resolver.updateSources(null, listOf(signal(7)), emptyList()) + val values = mapOf("can_1110_Speed" to 17.0) + for (id in listOf("can_7", "can_7_Speed", "can_1110_speed", "CAN_0x456_SPEED", "can_Speed")) { + assertEquals(17.0, resolver.updatedValue(series(id), values, values.keys), id) + } + assertEquals(17.0, resolver.updatedValue(series("unknown", "speed"), values, values.keys)) + } + + @Test fun directKeyWinsAndUnrelatedPacketDoesNotReuseCachedValueOrCreateZero() { + val resolver = ChartTelemetryResolver() + resolver.updateSources(null, listOf(signal(7)), emptyList()) + val binding = series("can_7", "Speed") + val values = mapOf("can_7" to 0.0, "Speed" to 4.0, "can_1110_Speed" to 8.0) + assertEquals(0.0, resolver.updatedValue(binding, values, values.keys)) + assertEquals(8.0, resolver.updatedValue(binding, values, setOf("can_1110_Speed"))) + assertNull(resolver.updatedValue(binding, values, setOf("sig_1_1"))) + assertNull(resolver.updatedValue(series("absent"), values, values.keys)) + } + + @Test fun mdbAndA2lAliasesSurviveLegacyNamesAndKeepFamilyPrecedence() { + val resolver = ChartTelemetryResolver() + resolver.updateSources(database(listOf(signal(2, reclid = 1))), emptyList(), listOf(signal(3))) + val binding = series("namespace_Speed", "speed") + val values = mapOf("sig_1_2" to 12.0, "a2l_42" to 34.0, "sig_0_3" to 56.0) + assertEquals(12.0, resolver.updatedValue(binding, values, values.keys)) + assertEquals(34.0, resolver.updatedValue(binding, values, setOf("a2l_42", "sig_0_3"))) + assertEquals(56.0, resolver.updatedValue(series("SIG_0_3"), values, setOf("sig_0_3"))) + assertNull(resolver.updatedValue(binding, values, setOf("sm_01_01"))) + } + + @Test fun eachDefinitionReplacementAndRemovalInvalidatesCachedMatches() { + val resolver = ChartTelemetryResolver() + val binding = series("namespace_Speed", "speed") + val oldCan = listOf(signal(7)) + val newCan = listOf(signal(8).copy(messageId = 0x457)) + resolver.updateSources(null, oldCan, emptyList()) + assertEquals(1.0, resolver.updatedValue(binding, mapOf("can_7" to 1.0), setOf("can_7"))) + resolver.updateSources(null, newCan, emptyList()) + assertNull(resolver.updatedValue(binding, mapOf("can_7" to 1.0), setOf("can_7"))) + assertEquals(2.0, resolver.updatedValue(binding, mapOf("can_8" to 2.0), setOf("can_8"))) + resolver.updateSources(database(listOf(signal(2, reclid = 1))), emptyList(), emptyList()) + assertEquals(3.0, resolver.updatedValue(binding, mapOf("sig_1_2" to 3.0), setOf("sig_1_2"))) + resolver.updateSources(database(listOf(signal(9, reclid = 2))), emptyList(), emptyList()) + assertNull(resolver.updatedValue(binding, mapOf("sig_1_2" to 3.0), setOf("sig_1_2"))) + assertEquals(4.0, resolver.updatedValue(binding, mapOf("sig_2_9" to 4.0), setOf("sig_2_9"))) + resolver.updateSources(null, emptyList(), listOf(signal(3))) + assertEquals(5.0, resolver.updatedValue(binding, mapOf("a2l_42" to 5.0), setOf("a2l_42"))) + resolver.updateSources(null, emptyList(), listOf(signal(3, adl = 99))) + assertNull(resolver.updatedValue(binding, mapOf("a2l_42" to 5.0), setOf("a2l_42"))) + assertEquals(6.0, resolver.updatedValue(binding, mapOf("a2l_99" to 6.0), setOf("a2l_99"))) + resolver.updateSources(null, emptyList(), emptyList()) + assertNull(resolver.updatedValue(binding, mapOf("a2l_99" to 6.0), setOf("a2l_99"))) + } + + @Test fun previouslyUnresolvedBindingIsRetriedWhenDefinitionsLoadAndNameChanges() { + val resolver = ChartTelemetryResolver() + val values = mapOf("can_7" to 7.0, "can_8" to 8.0) + assertNull(resolver.updatedValue(series("namespace", "speed"), values, values.keys)) + resolver.updateSources(null, listOf(signal(7), signal(8, "RPM")), emptyList()) + assertEquals(7.0, resolver.updatedValue(series("namespace", "speed"), values, values.keys)) + assertEquals(8.0, resolver.updatedValue(series("namespace", "RPM"), values, values.keys)) + } + + @Test fun duplicateNamesUseFirstDefinitionJustLikeUncachedResolver() { + val resolver = ChartTelemetryResolver() + resolver.updateSources(null, listOf(signal(7), signal(8)), emptyList()) + val values = mapOf("can_7" to 7.0, "can_8" to 8.0) + assertEquals(7.0, resolver.updatedValue(series("namespace", "speed"), values, values.keys)) + assertNull(resolver.updatedValue(series("namespace", "speed"), values, setOf("can_8"))) + } + + /** Count definition reads instead of relying on noisy wall-clock performance thresholds. */ + @Test fun repeatedMixedPacketsDoNotScanThousandsOfDefinitionsAfterWarmup() { + val definitions = CountingList((1..2_000).map { signal(it, "CAN_$it") }) + val mdb = CountingList(listOf(signal(1, "MDB", 1))) + val a2l = CountingList(listOf(signal(1, "A2L"))) + val db = database(mdb) + val resolver = ChartTelemetryResolver() + resolver.updateSources(db, definitions, a2l) + val bindings = listOf(series("legacy", "can_2000"), series("namespace_MDB", "mdb"), series("missing")) + val values = mapOf("can_2000" to 2.0, "sig_1_1" to 3.0) + bindings.forEach { resolver.updatedValue(it, values, setOf("unrelated")) } + val reads = listOf(definitions.reads, mdb.reads, a2l.reads) + repeat(1_000) { + resolver.updateSources(db, definitions, a2l) + assertEquals(2.0, resolver.updatedValue(bindings[0], values, setOf("can_2000"))) + assertNull(resolver.updatedValue(bindings[0], values, setOf("sig_1_1"))) + assertEquals(3.0, resolver.updatedValue(bindings[1], values, setOf("sig_1_1"))) + assertNull(resolver.updatedValue(bindings[2], values, values.keys)) + } + assertEquals(reads, listOf(definitions.reads, mdb.reads, a2l.reads)) + } + + private class CountingList(private val items: List) : AbstractList() { + var reads = 0 + override val size: Int get() = items.size + override fun get(index: Int): T { + reads++ + return items[index] + } + } +} diff --git a/Android/app/src/test/java/com/dct/app/tests/ChartXAxisParityTest.kt b/Android/app/src/test/java/com/dct/app/tests/ChartXAxisParityTest.kt index 5c9edf2..92781a9 100644 --- a/Android/app/src/test/java/com/dct/app/tests/ChartXAxisParityTest.kt +++ b/Android/app/src/test/java/com/dct/app/tests/ChartXAxisParityTest.kt @@ -28,7 +28,8 @@ class ChartXAxisParityTest { r.setTimeWindow(preset) val s = r.buildSnapshot(600) assertEquals(expected, span(s), 1e-8) - assertEquals(10000.0, s.timeMaxSec, 1e-8) + assertEquals(10000.0, s.timeMinSec, 1e-8) + assertEquals(10000.0 + expected, s.timeMaxSec, 1e-8) for (width in listOf(80f, 600f, 2400f)) { val ticks = ChartAxisTicks.time(s.timeMinSec, s.timeMaxSec, width, s.xAxisPreset, zone = utc) assertEquals(preset.durationSec, ticks.interval.seconds, 1e-8) @@ -115,7 +116,7 @@ class ChartXAxisParityTest { assertTrue(placeAxisLabels(listOf(10f), listOf(100f), 0f, 20f).isEmpty()) } - @Test fun fixedRangeFillsThenContinuouslyFollowsWithoutBlankTick() { + @Test fun fixedRangeFillsThenReservesOneTickAtEachBoundary() { val r = renderer() r.setTimeWindow(TimeWindowPreset.SEC_10) r.sample(1000.0) @@ -126,16 +127,18 @@ class ChartXAxisParityTest { assertEquals(first.timeMaxSec, r.buildSnapshot(600).timeMaxSec) r.sample(1040.0) val moved = r.buildSnapshot(600) - assertEquals(1000.0, moved.timeMinSec) - assertEquals(1040.0, moved.timeMaxSec) + assertEquals(1010.0, moved.timeMinSec) + assertEquals(1050.0, moved.timeMaxSec) r.sample(1040.033) val rolling = r.buildSnapshot(600) - assertEquals(1000.033, rolling.timeMinSec, 1e-8) - assertEquals(1040.033, rolling.timeMaxSec, 1e-8) + assertEquals(1010.0, rolling.timeMinSec, 1e-8) + assertEquals(1050.0, rolling.timeMaxSec, 1e-8) + r.sample(1050.0) + assertEquals(1060.0, r.buildSnapshot(600).timeMaxSec) r.sample(5000.0) val gap = r.buildSnapshot(600) assertEquals(40.0, span(gap)) - assertEquals(5000.0, gap.timeMaxSec) + assertEquals(5010.0, gap.timeMaxSec) } @Test fun autoIncludesSingleSampleAndFutureMarginWithoutDegenerateRange() { @@ -171,6 +174,7 @@ class ChartXAxisParityTest { @Test fun zoomPreservesAnchorAndFreezesManualRange() { val r = renderer() + r.sample(700.0) r.sample(1000.0) r.setTimeWindow(TimeWindowPreset.MIN_1) val before = r.buildSnapshot(600) @@ -225,7 +229,7 @@ class ChartXAxisParityTest { a.sample(1500.0) a.buildSnapshot(600) assertEquals(1200.0, span(b.buildSnapshot(600))) - assertEquals(1000.0, b.buildSnapshot(600).timeMaxSec) + assertEquals(2200.0, b.buildSnapshot(600).timeMaxSec) } @Test fun explicitDurationApiRemainsARollingSpanNotAFixedTickPreset() { @@ -233,10 +237,12 @@ class ChartXAxisParityTest { r.setTimeWindow(30.0) r.sample(1000.0) val first = r.buildSnapshot(600) - assertEquals(970.0, first.timeMinSec) - assertEquals(1000.0, first.timeMaxSec) + assertEquals(1000.0, first.timeMinSec) + assertEquals(1030.0, first.timeMaxSec) r.sample(1001.0) - assertEquals(1001.0, r.buildSnapshot(600).timeMaxSec) + assertEquals(1030.0, r.buildSnapshot(600).timeMaxSec) + r.sample(1031.0) + assertEquals(1036.0, r.buildSnapshot(600).timeMaxSec) assertEquals(30.0, span(r.buildSnapshot(600))) } } diff --git a/Android/app/src/test/java/com/dct/app/tests/ColorRefCompatibilityTest.kt b/Android/app/src/test/java/com/dct/app/tests/ColorRefCompatibilityTest.kt new file mode 100644 index 0000000..e8f6627 --- /dev/null +++ b/Android/app/src/test/java/com/dct/app/tests/ColorRefCompatibilityTest.kt @@ -0,0 +1,21 @@ +package com.dct.app.tests + +import com.dct.app.core.theme.PcColorRef +import com.dct.app.core.theme.toColorRef +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test + +class ColorRefCompatibilityTest { + @Test + fun legacyArgbIsConvertedToPcColorRefWithoutChannelLoss() { + assertEquals(0x00F39621L, 0xFF2196F3L.toColorRef()) + assertEquals(0x004C9A00L, 0xFF009A4CL.toColorRef()) + } + + @Test + fun existingPcColorRefRemainsUnchanged() { + val colorRef = PcColorRef.fromRgb(0x12, 0x34, 0x56) + assertEquals(0x00563412L, colorRef) + assertEquals(colorRef, colorRef.toColorRef()) + } +} diff --git a/Android/app/src/test/java/com/dct/app/tests/DctProtocolRequestsTest.kt b/Android/app/src/test/java/com/dct/app/tests/DctProtocolRequestsTest.kt index 658bfdd..1d7ad9b 100644 --- a/Android/app/src/test/java/com/dct/app/tests/DctProtocolRequestsTest.kt +++ b/Android/app/src/test/java/com/dct/app/tests/DctProtocolRequestsTest.kt @@ -26,6 +26,35 @@ class DctProtocolRequestsTest { .isEqualTo(byteArrayOf(0x02, 0x01, 0x02, 0x11, 0x22, 0x01, 0x02, 0x03, 0x04, 99)) assertThat(DctProtocolRequests.firmwareKeyFailure().payload) .isEqualTo(byteArrayOf(0x02, 0x02, 0x00)) + + // [ISSUE-R1-01] OTA firmware subcommands (0x06, 0x07, 0x08) & Heartbeat (0x05) + val removePacket = DctProtocolRequests.firmwareRemove(2) + assertThat(removePacket.command).isEqualTo(0x31.toByte()) + assertThat(removePacket.payload).isEqualTo(byteArrayOf(0x06, 0x02)) + + val removeBytes = DctProtocolRequests.buildRemoveFirmwareRequest(2) + assertThat(removeBytes.first()).isEqualTo(0xA1.toByte()) + assertThat(removeBytes.last()).isEqualTo(0x7E.toByte()) + + val triggerPacket = DctProtocolRequests.firmwareTriggerUpdate(0) + assertThat(triggerPacket.command).isEqualTo(0x31.toByte()) + assertThat(triggerPacket.payload).isEqualTo(byteArrayOf(0x07, 0x00)) + + val triggerBytes = DctProtocolRequests.buildTriggerFirmwareUpdateRequest() + assertThat(triggerBytes.first()).isEqualTo(0xA1.toByte()) + assertThat(triggerBytes.last()).isEqualTo(0x7E.toByte()) + + val progressAck = DctProtocolRequests.firmwareUpdateResponse(0x01) + assertThat(progressAck.command).isEqualTo(0x31.toByte()) + assertThat(progressAck.payload).isEqualTo(byteArrayOf(0x08, 0x01)) + + val completedAck = DctProtocolRequests.firmwareUpdateResponse(0x02) + assertThat(completedAck.command).isEqualTo(0x31.toByte()) + assertThat(completedAck.payload).isEqualTo(byteArrayOf(0x08, 0x02)) + + val alivePacket = DctProtocolRequests.possibleToConnect() + assertThat(alivePacket.command).isEqualTo(0x05.toByte()) + assertThat(alivePacket.payload).isEmpty() } private val codec = DctPacketCodec() @@ -39,6 +68,16 @@ class DctProtocolRequestsTest { assertThat(codec.encode(DctProtocolRequests.canStreamAck())).isEqualTo(hex("A1 00 02 23 01 38 7E")) assertThat(codec.encode(DctProtocolRequests.smStreamAck())).isEqualTo(hex("A1 00 02 24 01 37 7E")) assertThat(codec.encode(DctProtocolRequests.xcpStreamAck())).isEqualTo(hex("A1 00 02 50 01 0B 7E")) + assertThat(codec.encode(DctProtocolRequests.dctEnvQuery(0x00))).isEqualTo(hex("A1 00 02 09 00 53 7E")) + assertThat(codec.encode(DctProtocolRequests.dctEnvQuery(0x03))).isEqualTo(hex("A1 00 02 09 03 50 7E")) + assertThat(codec.encode(DctProtocolRequests.smInfoCountQuery())).isEqualTo(hex("A1 00 03 12 01 00 48 7E")) + } + + @Test + fun `프로젝트 DB 설정은 PC SendDBInfo 형식과 일치한다`() { + val request = DctProtocolRequests.dbInfo("HE1ID") + // HE1ID: [0x00][length 5][48 45 31 49 44] + assertThat(request.payload).isEqualTo(byteArrayOf(0x00, 5, 0x48, 0x45, 0x31, 0x49, 0x44)) } @Test @@ -121,8 +160,12 @@ class DctProtocolRequestsTest { .isEqualTo(hex("01 01 02 01 02")) assertThat(DctProtocolRequests.canDbcInfo(listOf(DctCanMessageConfiguration(0x7E0, 8))).payload) .isEqualTo(hex("01 00 01 07 E0 08")) + assertThat(DctProtocolRequests.canDbcInfo(emptyList()).payload) + .isEqualTo(hex("00 00 00")) assertThat(DctProtocolRequests.sensorModuleInfo(createDefaultSensorModules()).payload) .isEqualTo(hex("01 01 FF FF FF FF")) + assertThat(DctProtocolRequests.sensorModuleInfo(emptyList()).payload) + .isEqualTo(hex("01 00")) assertThat(DctProtocolRequests.canMode(isHighSpeedCan = true, isOldModel = true).payload) .isEqualTo(hex("06 11")) } diff --git a/Android/app/src/test/java/com/dct/app/tests/DctStreamPayloadParserTest.kt b/Android/app/src/test/java/com/dct/app/tests/DctStreamPayloadParserTest.kt index f411cb6..9ae1eb9 100644 --- a/Android/app/src/test/java/com/dct/app/tests/DctStreamPayloadParserTest.kt +++ b/Android/app/src/test/java/com/dct/app/tests/DctStreamPayloadParserTest.kt @@ -24,7 +24,7 @@ class DctStreamPayloadParserTest { @Test fun `CAN 스트림은 페이지와 복수 프레임 및 NA flag를 파싱한다`() { val parsed = DctStreamPayloadParser.parseCanStream( - hex("02 01 02 02 23 01 02 AA BB 56 04 81 CC") + hex("02 01 02 02 01 23 02 AA BB 04 56 81 CC") ) as DctStreamPayloadParser.CanStreamPayload.Can assertThat(parsed.currentPage).isEqualTo(1) assertThat(parsed.totalPages).isEqualTo(2) @@ -39,7 +39,7 @@ class DctStreamPayloadParserTest { @Test fun `SM 스트림은 모듈 채널 부호 소수와 NC를 파싱한다`() { val parsed = DctStreamPayloadParser.parseSensorStream( - hex("01 02 02 03 25 10 22 00 04 FF FF 00 00") + hex("01 02 03 03 10 25 00 22 04 FF FF 00 00 05 01 23 00 38") )!! assertThat(parsed).hasSize(1) assertThat(parsed[0].moduleIndex).isEqualTo(2) @@ -47,6 +47,7 @@ class DctStreamPayloadParserTest { assertThat(parsed[0].channels[0].value).isWithin(0.0001).of(-37.34) assertThat(parsed[0].channels[1].isNotConnected).isTrue() assertThat(parsed[0].channels[1].value).isNull() + assertThat(parsed[0].channels[2].value).isWithin(0.0001).of(291.56) } @Test @@ -110,7 +111,7 @@ class DctStreamPayloadParserTest { } @Test - fun `MDB 레코드는 전체 스키마가 있을 때만 원자적으로 디코딩한다`() { + fun `MDB 정의 길이가 달라도 수신 가능한 신호는 부분 디코딩한다`() { val first = SignalDefinition( 7, 1, "First", MdbAttrType.ATTR_0_UINT8, 1.0, 0.0, "", 0, 0.0, 255.0, arrayIndex = 0 @@ -119,14 +120,22 @@ class DctStreamPayloadParserTest { 7, 2, "Second", MdbAttrType.ATTR_2_UINT16, 1.0, 0.0, "", 0, 0.0, 65535.0, arrayIndex = 1 ) - val success = MdbRecordDecoder.decode(7, hex("0A 34 12"), listOf(first, second)) + val success = MdbRecordDecoder.decode(7, hex("0A 12 34"), listOf(first, second)) as MdbRecordDecoder.DecodeResult.Success assertThat(success.signals.map { it.physicalValue }).containsExactly(10.0, 4660.0).inOrder() - assertThat(MdbRecordDecoder.decode(7, hex("0A 34"), listOf(first, second))) - .isInstanceOf(MdbRecordDecoder.DecodeResult.Failure::class.java) - assertThat(MdbRecordDecoder.decode(7, hex("0A 34 12"), listOf(first, second.copy(idx = 1)))) + val partial = MdbRecordDecoder.decode(7, hex("0A 12"), listOf(first, second)) + as MdbRecordDecoder.DecodeResult.Success + assertThat(partial.signals.map { it.definition.name }).containsExactly("First") + assertThat(partial.signals.single().physicalValue).isEqualTo(10.0) + assertThat(partial.warning).contains("필요=3, 수신=2") + assertThat(MdbRecordDecoder.decode(7, hex("0A 12 34"), listOf(first, second.copy(idx = 1)))) .isInstanceOf(MdbRecordDecoder.DecodeResult.Failure::class.java) + + val extra = MdbRecordDecoder.decode(7, hex("0A 12 34 FF"), listOf(first, second)) + as MdbRecordDecoder.DecodeResult.Success + assertThat(extra.signals).hasSize(2) + assertThat(extra.warning).contains("초과 바이트 1B 무시") } @Test @@ -140,6 +149,21 @@ class DctStreamPayloadParserTest { assertThat(decoded.physicalValue).isGreaterThan(Long.MAX_VALUE.toDouble()) } + @Test + fun `DBC signed 값은 PC 저장 버킷 캐스팅 규칙을 따른다`() { + fun signed(length: Int) = SignalDefinition( + 0, length, "S$length", MdbAttrType.ATTR_1_INT8, 1.0, 0.0, "", 0, -1.0, 1.0, + startBit = 0, + bitLength = length + ) + + // PC는 4-bit 0xF와 12-bit 0xFFF를 각각 int8/int16 버킷으로 캐스팅한다. + assertThat(CanFrameSignalDecoder.decode(hex("0F"), listOf(signed(4))).single().rawValue).isEqualTo(15) + assertThat(CanFrameSignalDecoder.decode(hex("FF 0F"), listOf(signed(12))).single().rawValue).isEqualTo(4095) + assertThat(CanFrameSignalDecoder.decode(hex("FF"), listOf(signed(8))).single().rawValue).isEqualTo(-1) + assertThat(CanFrameSignalDecoder.decode(hex("FF FF"), listOf(signed(16))).single().rawValue).isEqualTo(-1) + } + private fun hex(value: String): ByteArray = value.trim().split(Regex("\\s+")) .map { it.toInt(16).toByte() } .toByteArray() diff --git a/Android/app/src/test/java/com/dct/app/tests/DpcProjectManagerTest.kt b/Android/app/src/test/java/com/dct/app/tests/DpcProjectManagerTest.kt index 972749a..5acbe2e 100644 --- a/Android/app/src/test/java/com/dct/app/tests/DpcProjectManagerTest.kt +++ b/Android/app/src/test/java/com/dct/app/tests/DpcProjectManagerTest.kt @@ -18,6 +18,13 @@ import org.junit.jupiter.api.DisplayName import org.junit.jupiter.api.Test class DpcProjectManagerTest { + @Test + fun pcProjectNameOmitsDpcExtensionWithoutChangingTheStoredFileName() { + val project = DpcProject(projectFileName = "example.dpc") + val json = JSONObject(DpcProjectManager.serializeProjectToJson(project)) + assertEquals("example", json.getString("name")) + assertEquals("example.dpc", project.projectFileName) + } @Test @DisplayName("P3 ask_info가 저장과 복원에서 보존되어야 한다") fun testAskInfoRoundtrip() { @@ -436,16 +443,15 @@ class DpcProjectManagerTest { assertEquals("DCTS", didObj.getString("DIDF197SN")) assertEquals("S001", didObj.getString("DIDF193SHW")) - // 2. sm_property 키 검증 - assertTrue(json.has("sm_property")) - val smArr = json.getJSONObject("sm_property").getJSONArray("modules") + // 2. sensormodule_property 키 검증 (PC C++ EnvSetting.cpp 표준 스키마) + assertTrue(json.has("sensormodule_property")) + val smArr = json.getJSONObject("sensormodule_property").getJSONArray("property") assertEquals(4, smArr.length()) val sm0 = smArr.getJSONObject(0) - assertEquals(1, sm0.getInt("is_used")) - val ch0 = sm0.getJSONArray("channels").getJSONObject(0) - assertEquals(1, ch0.getInt("is_voltage")) - assertEquals("V", ch0.getString("unit")) - assertEquals("배터리 전압 센서", ch0.getString("description")) + assertEquals(1, sm0.getInt("sm_use")) + val ch0 = sm0.getJSONArray("items").getJSONObject(0) + assertEquals(1, ch0.getInt("VOLTAGE")) + assertEquals("배터리 전압 센서", ch0.getString("Descrip")) // 3. CAN mode 및 Logging 플래그 검증 assertEquals(0, json.getInt("canmode")) // CAN(FD) -> 0 @@ -466,6 +472,143 @@ class DpcProjectManagerTest { assertEquals("배터리 전압 센서", parsed.sensorModules[0].channels[0].description) } + @Test + @DisplayName("기존 안드로이드 키(sm_property 및 formula_info)가 포함된 JSON도 완벽히 역직렬화되어야 한다") + fun testLegacyAndroidKeysCompatibility() { + val legacyJson = """ + { + "name": "LEGACY_ANDROID", + "cartype": "NQ5", + "formula_info": { + "items": [ + { + "Name": "CalcPower", + "Expression": "\"Torque\" * \"Speed\" / 9549", + "Unit": "kW", + "Color": 16711680, + "IsForSM": 0, + "Descrip": "출력 계산식" + } + ] + }, + "sm_property": { + "modules": [ + { + "index": 0, + "is_used": 1, + "channels": [ + { + "index": 0, + "name": "01-01 ch", + "is_voltage": 1, + "unit": "V", + "description": "배터리 전압", + "color": 65280 + } + ] + } + ] + } + } + """.trimIndent() + + val parsed = DpcProjectManager.parseProjectFromJson(legacyJson, null) + assertEquals("LEGACY_ANDROID", parsed.projectFileName) + assertEquals("NQ5", parsed.vehicleCode) + + // Formula 검증 + assertEquals(1, parsed.formulas.size) + val f = parsed.formulas[0] + assertEquals("CalcPower", f.name) + assertEquals("\"Torque\" * \"Speed\" / 9549", f.expression) + assertEquals("kW", f.unit) + assertFalse(f.isForSm) + assertEquals("출력 계산식", f.description) + + // SM 검증 + assertTrue(parsed.sensorModules[0].isUsed) + val ch0 = parsed.sensorModules[0].channels[0] + assertEquals(0, ch0.channelIndex) + assertEquals("01-01 ch", ch0.name) + assertTrue(ch0.isVoltage) + assertEquals("V", ch0.unit) + assertEquals("배터리 전압", ch0.description) + } + + @Test + @DisplayName("PC C++ 표준 formula_property 및 sensormodule_property 직렬화 및 양방향 라운드트립이 보장되어야 한다") + fun testPcFormulaAndSensorModuleRoundtrip() { + val formulas = listOf( + com.dct.app.domain.model.formula.FormulaDefinition( + name = "EnginePower", + expression = "\"Torque\" * \"EngineSpeed\" / 9549", + unit = "kW", + color = 0x00FF0000L, + isForSm = false, + description = "엔진 출력 수식" + ), + com.dct.app.domain.model.formula.FormulaDefinition( + name = "SmTempComp", + expression = "LIN(\"01-01\"; 0; 0; 5; 100)", + unit = "'C", + color = 0x0000FF00L, + isForSm = true, + description = "센서모듈 온도 보정" + ) + ) + + val smList = com.dct.app.domain.model.project.createDefaultSensorModules().toMutableList() + val sm0Ch = smList[0].channels.toMutableList() + sm0Ch[0] = sm0Ch[0].copy(name = "01-01 Custom", isVoltage = false, unit = "'C", description = "온도 센서") + smList[0] = smList[0].copy(isUsed = true, channels = sm0Ch) + + val project = DpcProject( + projectFileName = "PC_COMPAT_TEST", + vehicleCode = "RG3", + formulas = formulas, + sensorModules = smList + ) + + val jsonStr = DpcProjectManager.serializeProjectToJson(project) + val json = JSONObject(jsonStr) + + // PC C++ 키 존재 및 내부 스키마 검증 + assertTrue(json.has("formula_property")) + assertFalse(json.has("formula_info")) // 기존 키는 깔끔히 정리 + val fItems = json.getJSONObject("formula_property").getJSONArray("items") + assertEquals(2, fItems.length()) + assertEquals("EnginePower", fItems.getJSONObject(0).getString("name")) + assertEquals("\"Torque\" * \"EngineSpeed\" / 9549", fItems.getJSONObject(0).getString("expression")) + assertFalse(fItems.getJSONObject(0).getBoolean("forSM")) + assertEquals(0, fItems.getJSONObject(0).getInt("index")) + assertTrue(fItems.getJSONObject(1).getBoolean("forSM")) + + assertTrue(json.has("sensormodule_property")) + assertFalse(json.has("sm_property")) // 기존 키는 깔끔히 정리 + val smPropArr = json.getJSONObject("sensormodule_property").getJSONArray("property") + val sm0Obj = smPropArr.getJSONObject(0) + assertEquals(0, sm0Obj.getInt("sm_index")) + assertEquals(1, sm0Obj.getInt("sm_use")) + val chItems = sm0Obj.getJSONArray("items") + assertEquals("01-01 Custom", chItems.getJSONObject(0).getString("name")) + assertEquals(0, chItems.getJSONObject(0).getInt("VOLTAGE")) + assertEquals("온도 센서", chItems.getJSONObject(0).getString("Descrip")) + + // 역직렬화 라운드트립 검증 + val parsed = DpcProjectManager.parseProjectFromJson(jsonStr, null) + assertEquals(2, parsed.formulas.size) + assertEquals("EnginePower", parsed.formulas[0].name) + assertFalse(parsed.formulas[0].isForSm) + assertEquals("SmTempComp", parsed.formulas[1].name) + assertTrue(parsed.formulas[1].isForSm) + assertEquals("'C", parsed.formulas[1].unit) + + assertTrue(parsed.sensorModules[0].isUsed) + assertFalse(parsed.sensorModules[0].channels[0].isVoltage) + assertEquals("01-01 Custom", parsed.sensorModules[0].channels[0].name) + assertEquals("온도 센서", parsed.sensorModules[0].channels[0].description) + } + @Test @DisplayName("ensureDctWorkDirectories 호출 시 PC 표준 8대 서브폴더가 모두 생성되어야 한다") fun testEnsureDctWorkDirectoriesWithAllSubfolders() { diff --git a/Android/app/src/test/java/com/dct/app/tests/DpcWorkspacePersistenceTest.kt b/Android/app/src/test/java/com/dct/app/tests/DpcWorkspacePersistenceTest.kt index 9c65a6a..dda4ffd 100644 --- a/Android/app/src/test/java/com/dct/app/tests/DpcWorkspacePersistenceTest.kt +++ b/Android/app/src/test/java/com/dct/app/tests/DpcWorkspacePersistenceTest.kt @@ -2,6 +2,7 @@ package com.dct.app.tests import com.dct.app.data.repository.DpcProjectManager import com.dct.app.data.repository.DpcWorkspaceJson +import com.dct.app.core.theme.PcColorRef import com.dct.app.domain.model.chart.* import com.dct.app.domain.model.project.* import com.dct.app.domain.usecase.chart.ChartBatchRenderer @@ -13,7 +14,7 @@ import org.junit.jupiter.api.Test @OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class) class DpcWorkspacePersistenceTest { private fun sample(): DpcWorkspaceState { - val signal = DpcSignalBindingState("sig_1_2", "RPM", AxisPosition.RIGHT_1, 0xFFAABBCCL, + val signal = DpcSignalBindingState("sig_1_2", "RPM", AxisPosition.RIGHT_1, PcColorRef.fromRgb(0xAA, 0xBB, 0xCC), "rpm", 4f, 3, false, InterpolationMode.PWM_STEP) return DpcWorkspaceState("page_2", listOf( DpcPageState("page_1", "Engine", windows = listOf( @@ -25,7 +26,7 @@ class DpcWorkspacePersistenceTest { isXAxisAuto = false, isYAxisAuto = false, legendPosition = 2 )), DpcWindowState("alpha", "ALPHANUMERICAL", "Values", targetSignals = listOf("sig_1_2", "can_4"), - isTileMode = false, hiddenSignalIds = setOf("RPM"), highlightColors = mapOf("RPM" to 0xFFFF0000L)) + isTileMode = false, hiddenSignalIds = setOf("RPM"), highlightColors = mapOf("RPM" to PcColorRef.fromRgb(0xFF, 0x00, 0x00))) ), activeWindowId = "graph", scrollX = 250f, scrollY = 60f, signals = listOf(signal), alphanumericalSignals = listOf("can_4"), mdbSearchQuery = "speed", canSearchQuery = "engine"), DpcPageState("page_2", "Intentionally empty") @@ -99,7 +100,7 @@ class DpcWorkspacePersistenceTest { @Test fun rendererKeepsInsertionOrderAcrossRestores() { val renderer = ChartBatchRenderer(TestDispatcherProvider(UnconfinedTestDispatcher())) - val series = listOf("z", "a", "m").map { ChartSignalSeries(it, it, AxisPosition.LEFT_0, 0xFF112233L) } + val series = listOf("z", "a", "m").map { ChartSignalSeries(it, it, AxisPosition.LEFT_0, PcColorRef.fromRgb(0x11, 0x22, 0x33)) } renderer.restoreWorkspaceState(series, null) assertEquals(listOf("z", "a", "m"), renderer.buildSnapshot(100).seriesList.map { it.signalId }) } diff --git a/Android/app/src/test/java/com/dct/app/tests/FormulaTelemetryEngineTest.kt b/Android/app/src/test/java/com/dct/app/tests/FormulaTelemetryEngineTest.kt new file mode 100644 index 0000000..b15febb --- /dev/null +++ b/Android/app/src/test/java/com/dct/app/tests/FormulaTelemetryEngineTest.kt @@ -0,0 +1,173 @@ +package com.dct.app.tests + +import com.dct.app.core.formula.FormulaEvaluator +import com.dct.app.core.formula.FormulaTelemetryEngine +import com.dct.app.domain.model.formula.FormulaDefinition +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.Test + +class FormulaTelemetryEngineTest { + private fun formula(name: String, expression: String) = FormulaDefinition(name = name, expression = expression) + + @Test fun reverseRegistrationOrderAndAliasesUseCurrentInputs() { + val engine = FormulaTelemetryEngine() + val formulas = listOf(formula("B", "formula_A+1"), formula("A", "TORQUE*2")) + val values = mutableMapOf("Torque" to 2.0) + engine.update(formulas, values, mutableSetOf("Torque")) + assertEquals(5.0, values["B"]) + values["Torque"] = 3.0 + engine.update(formulas, values, mutableSetOf("Torque")) + assertEquals(7.0, values["B"]) + } + + @Test fun unrelatedPacketsDoNotMarkFormulaOutputsUpdated() { + val engine = FormulaTelemetryEngine() + val formulas = listOf(formula("F", "X*2"), formula("G", "F+1")) + val values = mutableMapOf("X" to 2.0) + engine.update(formulas, values, mutableSetOf("X")) + val updated = mutableSetOf("Y") + values["Y"] = 10.0 + engine.update(formulas, values, updated) + assertEquals(setOf("Y"), updated) + assertEquals(5.0, values["G"]) + values["X"] = 2.0 // Receiving the same value still represents a new real sample. + val repeated = mutableSetOf("X") + engine.update(formulas, values, repeated) + assertTrue("G" in repeated) + } + + @Test fun constantsReuseValuesButStillProvideGraphAndRecordingSamples() { + val engine = FormulaTelemetryEngine() + val formulas = listOf(formula("Dependent", "Constant+1"), formula("Constant", "42")) + val values = mutableMapOf() + engine.update(formulas, values, mutableSetOf()) + values["X"] = 2.0 + val updated = mutableSetOf("X") + engine.update(formulas, values, updated) + assertEquals(42.0, values["Constant"]) + assertTrue("Constant" in updated) + assertTrue("formula_Constant" in updated) + assertEquals(43.0, values["Dependent"]) + assertTrue("Dependent" in updated) + } + + @Test fun exactFormulaNamesTakePrecedenceAndDuplicateOutputsAreInvalid() { + val engine = FormulaTelemetryEngine() + val formulas = listOf(formula("B", "A+1"), formula("A", "X*2"), formula("a", "X*3")) + val values = mutableMapOf("X" to 2.0) + engine.update(formulas, values, mutableSetOf("X")) + assertEquals(5.0, values["B"]) + engine.update(listOf(formula("A", "2"), formula("A", "3"), formula("B", "A+1")), values, mutableSetOf()) + assertTrue(values.getValue("A").isNaN()) + assertTrue(values.getValue("B").isNaN()) + } + + @Test fun invalidInputsReplaceOldResultsAndPropagateThenRecover() { + val engine = FormulaTelemetryEngine() + val formulas = listOf(formula("A", "10/X"), formula("B", "A+1")) + val values = mutableMapOf("X" to 2.0) + engine.update(formulas, values, mutableSetOf("X")) + assertEquals(6.0, values["B"]) + for (invalid in listOf(0.0, Double.NaN, Double.POSITIVE_INFINITY)) { + values["X"] = invalid + val updated = mutableSetOf("X") + engine.update(formulas, values, updated) + assertTrue(values.getValue("A").isNaN()) + assertTrue(values.getValue("B").isNaN()) + assertTrue("formula_B" in updated) + } + values["X"] = 5.0 + engine.update(formulas, values, mutableSetOf("X")) + assertEquals(3.0, values["B"]) + } + + @Test fun missingInputIsNotZeroAndNewCaseInsensitiveKeysRecover() { + val engine = FormulaTelemetryEngine() + val formulas = listOf(formula("F", "MISSING+1")) + val values = mutableMapOf("Other" to 2.0) + engine.update(formulas, values, mutableSetOf("Other")) + assertTrue(values.getValue("F").isNaN()) + values["Missing"] = 9.0 + engine.update(formulas, values, mutableSetOf("Missing")) + assertEquals(10.0, values["F"]) + } + + @Test fun cyclesAndDescendantsAreInvalidButIndependentFormulasWork() { + val engine = FormulaTelemetryEngine() + val formulas = listOf(formula("A", "B+1"), formula("B", "A+1"), formula("C", "B+1"), formula("Self", "Self+1"), formula("Good", "X*2")) + val values = mutableMapOf("X" to 3.0) + engine.update(formulas, values, mutableSetOf("X")) + listOf("A", "B", "C", "Self").forEach { assertTrue(values.getValue(it).isNaN()) } + assertEquals(6.0, values["Good"]) + } + + @Test fun expressionEditRenameDeletionAndResetInvalidateCachedResults() { + val engine = FormulaTelemetryEngine() + val a = formula("A", "X*2") + val b = formula("B", "A+1") + val values = mutableMapOf("X" to 2.0) + engine.update(listOf(a, b), values, mutableSetOf("X")) + engine.update(listOf(a.copy(expression = "X*3"), b), values, mutableSetOf()) + assertEquals(7.0, values["B"]) + engine.update(listOf(a.copy(name = "New"), b), values, mutableSetOf()) + assertFalse("A" in values) + assertFalse("formula_A" in values) + assertTrue(values.getValue("B").isNaN()) + engine.update(listOf(b), values, mutableSetOf()) + assertFalse("New" in values) + engine.reset() + values.clear() + engine.update(listOf(a), values, mutableSetOf()) + assertTrue(values.getValue("A").isNaN()) + } + + @Test fun cachedIndexNeverEnumeratesAllSignalsOnSubsequentPackets() { + val backing = (0 until 10_000).associate { "Signal$it" to it.toDouble() }.toMutableMap() + backing["Torque"] = 20.0 + var enumerations = 0 + val values = object : MutableMap by backing { + override val keys: MutableSet + get() { enumerations++; return backing.keys } + override val entries: MutableSet> + get() = error("Per-variable full scan is forbidden") + } + val engine = FormulaTelemetryEngine() + val formulas = listOf(formula("A", "TORQUE*2"), formula("B", "NotReceived+1")) + engine.update(formulas, values, mutableSetOf("Torque")) + assertEquals(1, enumerations) + repeat(100) { + backing["Torque"] = it.toDouble() + engine.update(formulas, values, mutableSetOf("Torque")) + } + assertEquals(1, enumerations) + assertEquals(198.0, values["A"]) + } + + @Test fun linValidationIsIndependentOfTelemetryAndAllModuleNumbers() { + for (module in 1..16) { + val channel = "%02d-05".format(module) + val expression = "LIN(\"$channel\";0;0;5;100)" + assertTrue(FormulaEvaluator.validate(expression)) + assertEquals(50.0, FormulaEvaluator.evaluate(expression, mapOf("sm_${channel.replace('-', '_')}" to 2.5))) + } + assertFalse(FormulaEvaluator.validate("LIN(\"02-05\";0;0;0;100)")) + assertFalse(FormulaEvaluator.validate("LIN(\"02-05\";0;NaN;5;100)")) + } + + @Test fun linAliasesParticipateInSelectiveUpdatesAndDependencies() { + val engine = FormulaTelemetryEngine() + val formulas = listOf(formula("B", "A+1"), formula("A", "LIN(\"02-05\";0;0;5;100)")) + val values = mutableMapOf("sm_02_05" to 2.5) + engine.update(formulas, values, mutableSetOf("sm_02_05")) + assertEquals(51.0, values["B"]) + values["sm_02_05"] = 5.0 + engine.update(formulas, values, mutableSetOf("sm_02_05")) + assertEquals(101.0, values["B"]) + } + + @Test fun missingAndNonFiniteMathNeverProducePlausibleValues() { + assertTrue(FormulaEvaluator.evaluate("Unknown+1", emptyMap()).isNaN()) + assertTrue(FormulaEvaluator.evaluate("exp(10000)", emptyMap()).isNaN()) + assertTrue(FormulaEvaluator.evaluate("sqrt(-1)^0", emptyMap()).isNaN()) + } +} diff --git a/Android/app/src/test/java/com/dct/app/tests/FormulaTelemetryRegressionTest.kt b/Android/app/src/test/java/com/dct/app/tests/FormulaTelemetryRegressionTest.kt new file mode 100644 index 0000000..17f6354 --- /dev/null +++ b/Android/app/src/test/java/com/dct/app/tests/FormulaTelemetryRegressionTest.kt @@ -0,0 +1,197 @@ +package com.dct.app.tests + +import com.dct.app.domain.model.chart.AxisPosition +import com.dct.app.domain.model.formula.FormulaDefinition +import com.dct.app.domain.model.mdb.MdbDatabase +import com.dct.app.domain.repository.MdbRepository +import com.dct.app.domain.model.protocol.DctMeasurementConfiguration +import com.dct.app.domain.model.project.DpcProject +import com.dct.app.domain.model.protocol.DctPacket +import com.dct.app.domain.repository.DeviceConnectionState +import com.dct.app.domain.repository.PacketRepository +import com.dct.app.presentation.main.MainViewModel +import com.dct.app.presentation.main.MainUiState +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.io.TempDir +import java.io.File +import java.nio.ByteBuffer +import java.nio.ByteOrder +import io.mockk.coEvery +import io.mockk.every +import io.mockk.coVerify +import io.mockk.coVerifyOrder +import com.dct.app.core.common.DctResult +import com.dct.app.core.error.DctError + +@Suppress("UNCHECKED_CAST") +class FormulaTelemetryRegressionTest { + @TempDir lateinit var directory: File + private lateinit var fixture: MainViewModelTest + private lateinit var vm: MainViewModel + private lateinit var packets: MutableSharedFlow + private lateinit var repository: PacketRepository + private fun field(target: Any, name: String) = target.javaClass.getDeclaredField(name).also { it.isAccessible = true } + + @BeforeEach fun setUp() { + fixture = MainViewModelTest().also { it.setUp() } + vm = field(fixture, "viewModel").get(fixture) as MainViewModel + repository = field(fixture, "packetRepository").get(fixture) as PacketRepository + packets = repository.packetStream as MutableSharedFlow + vm.setCurrentProject(DpcProject(measuredFilesFolder = directory.path, writeCsv = true, writeMf4 = false)) + (field(fixture, "connectionState").get(fixture) as MutableStateFlow).value = DeviceConnectionState.READY + } + + @AfterEach fun tearDown() = fixture.tearDown() + + private fun telemetry(value: Float) { + assertTrue(packets.tryEmit(DctPacket(0x02, ByteArray(18).also { + val buffer = ByteBuffer.wrap(it).order(ByteOrder.LITTLE_ENDIAN) + buffer.position(2) + buffer.putFloat(value).putFloat(2f).putFloat(3f).putFloat(4f) + }))) + } + + private fun openMdbWithoutRawConsumers() { + // MainViewModelTest's UI fixture leaves both byte offsets at zero. Use a + // valid record layout when exercising the real binary decoder (UINT16 + INT8). + val sample = field(fixture, "sampleDb").get(fixture) as MdbDatabase + val source = sample.copy(groups = sample.groups.map { group -> + group.copy(signals = group.signals.mapIndexed { index, signal -> signal.copy(arrayIndex = if (index == 0) 0 else 2) }) + }) + val mdb = field(fixture, "mdbRepository").get(fixture) as MdbRepository + every { mdb.getCurrentDatabase() } returns source + vm.openMdbFromFile(File("Sample.mdb")) + // Legacy dashboard defaults must not register inputs on behalf of Formula. + val ui = field(vm, "_uiState").get(vm) as MutableStateFlow + ui.value = ui.value.copy(dashboardTiles = emptyList()) + } + + @Test fun reverseOrderAndErrorsUseSamePacketValues() { + vm.startMeasure() + vm.addFormula(FormulaDefinition(name = "B", expression = "A+1")) + vm.addFormula(FormulaDefinition(name = "A", expression = "10/Incar_Snr")) + telemetry(2f) + assertEquals(6.0, vm.liveTelemetryValues.value["B"]) + telemetry(0f) + assertTrue(vm.liveTelemetryValues.value.getValue("A").isNaN()) + assertTrue(vm.liveTelemetryValues.value.getValue("B").isNaN()) + telemetry(5f) + assertEquals(3.0, vm.liveTelemetryValues.value["B"]) + } + + @Test fun unrelatedSmPacketAddsNeitherFormulaGraphNorCsvSample() { + vm.startMeasure() + vm.addFormula(FormulaDefinition(name = "F", expression = "Incar_Snr*2")) + vm.chartRenderer.bindSignal("formula_F", "F", AxisPosition.LEFT_0, 0xFF0000L) + vm.startMeasurementRecording() + telemetry(2f) + val graph = vm.chartRenderer.seriesListFlow.value.single { it.signalId == "formula_F" } + assertEquals(1, graph.buffer.size) + val before = vm.uiState.value.packetCount + // One connected SM channel; it has no dependency edge to Incar_Snr. + assertTrue(packets.tryEmit(DctPacket(0xA4.toByte(), byteArrayOf(1, 0, 1, 0, 0, 10, 0, 0)))) + assertEquals(before + 1, vm.uiState.value.packetCount) + assertEquals(1, graph.buffer.size) + telemetry(3f) + assertEquals(2, graph.buffer.size) + vm.stopMeasurementRecording() + val csv = directory.listFiles()!!.single { it.name.endsWith("_formula.csv") } + val rows = csv.readLines().filter { it.matches(Regex("^\\d{2}:\\d{2}:\\d{2}\\.\\d{3},.*")) } + assertEquals(2, rows.size) + } + + @Test fun editsRenameAndDeletionRefreshWithoutWaitingForAnotherPacket() { + vm.startMeasure() + val f = FormulaDefinition(name = "F", expression = "Incar_Snr*2") + vm.addFormula(f) + telemetry(2f) + vm.updateFormula(f.copy(expression = "Incar_Snr*3")) + assertEquals(6.0, vm.liveTelemetryValues.value["F"]) + val renamed = f.copy(name = "New") + vm.updateFormula(renamed) + assertFalse("F" in vm.liveTelemetryValues.value) + assertFalse("formula_F" in vm.liveTelemetryValues.value) + assertEquals(4.0, vm.liveTelemetryValues.value["New"]) + vm.deleteFormula(renamed) + assertFalse("New" in vm.liveTelemetryValues.value) + assertFalse("formula_New" in vm.liveTelemetryValues.value) + } + + @Test fun calculationErrorIsRecordedAsNoValueNotOldGoodValue() { + vm.startMeasure() + vm.addFormula(FormulaDefinition(name = "F", expression = "10/Incar_Snr")) + vm.startMeasurementRecording() + telemetry(2f) + telemetry(0f) + vm.stopMeasurementRecording() + val csv = directory.listFiles()!!.single { it.name.endsWith("_formula.csv") } + assertTrue(csv.readLines().last().contains("No value")) + } + + @Test fun formulaCreatedWhileIdleRecoversFromMissingValueOnFirstInput() { + vm.addFormula(FormulaDefinition(name = "F", expression = "INCAR_SNR*2")) + assertFalse(vm.uiState.value.isMeasuring) + assertTrue(vm.liveTelemetryValues.value.getValue("F").isNaN()) + vm.startMeasure() + telemetry(3f) + assertEquals(6.0, vm.liveTelemetryValues.value["F"]) + } + + @Test fun idleFormulaRegistersItsMdbInputsWithoutBindingRawSignalsToGraph() { + openMdbWithoutRawConsumers() + vm.addFormula(FormulaDefinition(name = "F", expression = "TA_MOTOR_SPEED*2")) + val configurationMethod = MainViewModel::class.java.getDeclaredMethod("createDeviceMeasurementConfiguration").also { it.isAccessible = true } + val configuration = configurationMethod.invoke(vm) as DctMeasurementConfiguration + assertEquals(listOf("Ta_Motor_Speed"), configuration.selectedMdbSignals.map { it.name }) + vm.startMeasure() + coVerifyOrder { + repository.configureMeasurement(match { it.selectedMdbSignals.any { signal -> signal.name == "Ta_Motor_Speed" } }) + repository.startStreaming(any()) + } + assertTrue(vm.uiState.value.isMeasuring) + val before = vm.uiState.value.packetCount + assertTrue(packets.tryEmit(DctPacket(0xA3.toByte(), byteArrayOf(1, 1, 0, 12, 0)))) + assertEquals(before + 1, vm.uiState.value.packetCount, "Live values: ${vm.liveTelemetryValues.value}") + assertEquals(3.0, vm.liveTelemetryValues.value["Ta_Motor_Speed"]) + assertEquals(6.0, vm.liveTelemetryValues.value["F"]) + } + + @Test fun formulaAddedAfterReadySynchronizationIsRegisteredBeforeStart() { + openMdbWithoutRawConsumers() + vm.syncDeviceConfiguration() + vm.addFormula(FormulaDefinition(name = "F", expression = "Ta_Motor_Speed*2")) + vm.startMeasure() + coVerify(exactly = 2) { repository.configureMeasurement(any()) } + coVerify(exactly = 1) { repository.configureMeasurement(match { it.selectedMdbSignals.map { s -> s.idx } == listOf(1) }) } + vm.stopMeasure() + vm.startMeasure() + // Unchanged selection is reused; no full configuration on every restart. + coVerify(exactly = 2) { repository.configureMeasurement(any()) } + } + + @Test fun failedFormulaInputConfigurationDoesNotStartAndCanBeRetried() { + openMdbWithoutRawConsumers() + vm.syncDeviceConfiguration() + vm.addFormula(FormulaDefinition(name = "F", expression = "Ta_Motor_Speed*2")) + coEvery { repository.configureMeasurement(any()) } returns DctResult.Failure(DctError.Unknown("configuration NACK")) + vm.startMeasure() + assertFalse(vm.uiState.value.isMeasuring) + coVerify(exactly = 0) { repository.startStreaming(any()) } + coEvery { repository.configureMeasurement(any()) } returns DctResult.Success(Unit) + vm.startMeasure() + assertTrue(vm.uiState.value.isMeasuring) + } + + @Test fun nestedFormulasAndRawIdsRegisterAllMdbInputs() { + openMdbWithoutRawConsumers() + vm.addFormula(FormulaDefinition(name = "Result", expression = "Intermediate+RAW_SIG_1_2")) + vm.addFormula(FormulaDefinition(name = "Intermediate", expression = "ta_motor_speed*2")) + val method = MainViewModel::class.java.getDeclaredMethod("createDeviceMeasurementConfiguration").also { it.isAccessible = true } + assertEquals(listOf(1, 2), (method.invoke(vm) as DctMeasurementConfiguration).selectedMdbSignals.map { it.idx }) + } +} diff --git a/Android/app/src/test/java/com/dct/app/tests/FormulaWindowStateTest.kt b/Android/app/src/test/java/com/dct/app/tests/FormulaWindowStateTest.kt new file mode 100644 index 0000000..bcc9edb --- /dev/null +++ b/Android/app/src/test/java/com/dct/app/tests/FormulaWindowStateTest.kt @@ -0,0 +1,31 @@ +package com.dct.app.tests + +import androidx.compose.ui.unit.dp +import com.dct.app.data.repository.DpcProjectManager +import com.dct.app.domain.model.formula.FormulaDefinition +import com.dct.app.domain.model.project.* +import com.dct.app.presentation.signaltree.WindowColumnWidths +import com.dct.app.presentation.signaltree.WindowUiStore +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.Test + +class FormulaWindowStateTest { + @Test fun independentFormulaColumnWidthsAndSearchRoundTripThroughDpc() { + var saved = emptyMap() + val widths = WindowColumnWidths(WindowUiStore(mapOf("search" to "Power")) { saved = it }) + val expected = mapOf("name" to 210f, "value" to 110f, "unit" to 65f, "color" to 90f, "expression" to 330f, "act" to 70f) + expected.forEach { (key, width) -> widths[key] = width.dp } + val project = DpcProject(formulas = listOf(FormulaDefinition(name = "Power", expression = "Torque*2", unit = "kW")), + workspace = DpcWorkspaceState(activePageId = "page_1", pages = listOf(DpcPageState("page_1", "Page 1", windows = listOf( + DpcWindowState("formula", "FORMULA_LIST", "Formula", contentState = saved) + ))))) + val restored = DpcProjectManager.parseProjectFromJson(DpcProjectManager.serializeProjectToJson(project), null) + val content = restored.workspace!!.pages.single().windows.single().contentState + val restoredWidths = WindowColumnWidths(WindowUiStore(content) {}) + assertEquals("Power", content["search"]) + expected.forEach { (key, width) -> assertEquals(width.dp, restoredWidths[key]) } + // PC formula_property persists fields/name/index, not the runtime UUID. + val restoredFormula = restored.formulas.single() + assertEquals(project.formulas.single().copy(id = restoredFormula.id), restoredFormula) + } +} diff --git a/Android/app/src/test/java/com/dct/app/tests/GapClosureRegressionTest.kt b/Android/app/src/test/java/com/dct/app/tests/GapClosureRegressionTest.kt index a6dcc90..bd264c8 100644 --- a/Android/app/src/test/java/com/dct/app/tests/GapClosureRegressionTest.kt +++ b/Android/app/src/test/java/com/dct/app/tests/GapClosureRegressionTest.kt @@ -70,8 +70,12 @@ class GapClosureRegressionTest { } @Test fun setupSubtypeIsNotMistakenForSuccess() { val request = DctProtocolRequests.canMode(true, false) - for (payload in listOf(byteArrayOf(6,1), byteArrayOf(1))) DctResponseValidator.validateSetup(request, DctPacket.create(DctCommand.RES_DCT_ENV, payload)) - for (payload in listOf(byteArrayOf(6,2), byteArrayOf(2), byteArrayOf(1,1))) { + DctResponseValidator.validateSetup(request, DctPacket.create(DctCommand.RES_DCT_ENV, byteArrayOf(6,1,1))) + // DCT_test compatibility: a legacy peer may omit the subtype/mode and + // return either [06, ACK] or a generic [ACK]. Both still require ACK=1. + DctResponseValidator.validateSetup(request, DctPacket.create(DctCommand.RES_DCT_ENV, byteArrayOf(6,1))) + DctResponseValidator.validateSetup(request, DctPacket.create(DctCommand.RES_DCT_ENV, byteArrayOf(1))) + for (payload in listOf(byteArrayOf(6,2,1), byteArrayOf(6,1,0), byteArrayOf(6,2), byteArrayOf(2), byteArrayOf(1,1))) { assertThrows(IllegalArgumentException::class.java) { DctResponseValidator.validateSetup(request, DctPacket.create(DctCommand.RES_DCT_ENV, payload)) } } } @@ -147,7 +151,10 @@ class GapClosureRegressionTest { @Test fun rightAxesReserveSeparateSpaceFromLegend() { val without = MultiAxisCoordinateTransformer.calculatePlotBounds(1000f,600f,1,hasLegend=false) val with = MultiAxisCoordinateTransformer.calculatePlotBounds(1000f,600f,1,hasLegend=false,rightAxisCount=4) - assertEquals(without.right - 216f, with.right) + assertEquals( + without.right - MultiAxisCoordinateTransformer.DEFAULT_AXIS_WIDTH_PX * 4f, + with.right + ) } @Test fun pinchZoomClampsAndPersistsManualSpan() { val renderer = ChartBatchRenderer(TestDispatcherProvider(UnconfinedTestDispatcher())) diff --git a/Android/app/src/test/java/com/dct/app/tests/GraphSettingsRegressionTest.kt b/Android/app/src/test/java/com/dct/app/tests/GraphSettingsRegressionTest.kt index 6963bcd..ed554a5 100644 --- a/Android/app/src/test/java/com/dct/app/tests/GraphSettingsRegressionTest.kt +++ b/Android/app/src/test/java/com/dct/app/tests/GraphSettingsRegressionTest.kt @@ -7,6 +7,7 @@ import com.dct.app.domain.usecase.chart.ChartBatchRenderer import kotlinx.coroutines.test.UnconfinedTestDispatcher import org.junit.jupiter.api.Assertions.* import org.junit.jupiter.api.Test +import java.time.ZoneOffset @OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class) class GraphSettingsRegressionTest { @@ -52,6 +53,82 @@ class GraphSettingsRegressionTest { assertFalse(r.buildSnapshot(600).axesConfig.getValue(AxisPosition.LEFT_1).isAuto) } + @Test fun yAxisScaleIsIndependentAndFingerDirectionMatchesRangeSemantics() { + val r = renderer() + val autoBefore = r.buildSnapshot(600).axesConfig.getValue(AxisPosition.LEFT_0) + r.scaleYAxis(AxisPosition.LEFT_0, 2.0) + val autoAfter = r.buildSnapshot(600).axesConfig.getValue(AxisPosition.LEFT_0) + assertEquals(autoBefore.span * 2.0, autoAfter.span, 1e-9) + assertFalse(autoAfter.isAuto) + + r.setAxisRange(AxisPosition.LEFT_0, 0.0, 100.0, false) + r.setAxisRange(AxisPosition.LEFT_1, -10.0, 10.0, false) + + // Upward swipe is represented by a factor > 1 and expands only L1. + r.scaleYAxis(AxisPosition.LEFT_1, 2.0) + val expanded = r.buildSnapshot(600) + assertEquals(-20.0, expanded.axesConfig.getValue(AxisPosition.LEFT_1).minVal, 1e-9) + assertEquals(20.0, expanded.axesConfig.getValue(AxisPosition.LEFT_1).maxVal, 1e-9) + assertEquals(0.0, expanded.axesConfig.getValue(AxisPosition.LEFT_0).minVal, 1e-9) + assertEquals(100.0, expanded.axesConfig.getValue(AxisPosition.LEFT_0).maxVal, 1e-9) + assertFalse(expanded.axesConfig.getValue(AxisPosition.LEFT_1).isAuto) + + // Downward swipe is represented by a factor < 1 and narrows L1. + r.scaleYAxis(AxisPosition.LEFT_1, 0.5) + val narrowed = r.buildSnapshot(600) + assertEquals(-10.0, narrowed.axesConfig.getValue(AxisPosition.LEFT_1).minVal, 1e-9) + assertEquals(10.0, narrowed.axesConfig.getValue(AxisPosition.LEFT_1).maxVal, 1e-9) + } + + @Test fun yAxisPanMovesOnlyTheSelectedAxisAndDoubleTapResetRestoresManualBaseline() { + val r = renderer() + r.setAxisRange(AxisPosition.LEFT_0, 0.0, 100.0, false) + r.setAxisRange(AxisPosition.LEFT_1, -10.0, 10.0, false) + + // A downward drag equal to 25% of the plot height translates the + // selected axis by one quarter of its current 20-unit span. + r.panYAxis(AxisPosition.LEFT_1, 0.25) + val moved = r.buildSnapshot(600) + assertEquals(-5.0, moved.axesConfig.getValue(AxisPosition.LEFT_1).minVal, 1e-9) + assertEquals(15.0, moved.axesConfig.getValue(AxisPosition.LEFT_1).maxVal, 1e-9) + assertEquals(0.0, moved.axesConfig.getValue(AxisPosition.LEFT_0).minVal, 1e-9) + assertEquals(100.0, moved.axesConfig.getValue(AxisPosition.LEFT_0).maxVal, 1e-9) + assertFalse(moved.axesConfig.getValue(AxisPosition.LEFT_1).isAuto) + + // Axis double-tap is represented by resetYAxis in the renderer layer. + r.resetYAxis(AxisPosition.LEFT_1) + val restored = r.buildSnapshot(600).axesConfig.getValue(AxisPosition.LEFT_1) + assertEquals(-10.0, restored.minVal, 1e-9) + assertEquals(10.0, restored.maxVal, 1e-9) + assertFalse(restored.isAuto) + + // A second reset has no stale baseline and must not change the range. + r.resetYAxis(AxisPosition.LEFT_1) + val unchanged = r.buildSnapshot(600).axesConfig.getValue(AxisPosition.LEFT_1) + assertEquals(restored, unchanged) + } + + @Test fun yAxisPinchZoomUsesCentroidAnchorAndResetReturnsAutoMode() { + val r = renderer() + val before = r.buildSnapshot(600).axesConfig.getValue(AxisPosition.LEFT_0) + val anchor = 0.25 + + // Pinch-out factor 2 narrows the visible span by half around the + // quarter-height value (the same anchor convention used by Canvas). + r.zoomYAxis(AxisPosition.LEFT_0, factor = 2.0, anchorFraction = anchor) + val zoomed = r.buildSnapshot(600).axesConfig.getValue(AxisPosition.LEFT_0) + val anchorValue = before.minVal + before.span * anchor + assertEquals(before.span / 2.0, zoomed.span, 1e-9) + assertEquals(anchorValue, zoomed.minVal + zoomed.span * anchor, 1e-9) + assertFalse(zoomed.isAuto) + + r.resetYAxis(AxisPosition.LEFT_0) + val restored = r.buildSnapshot(600).axesConfig.getValue(AxisPosition.LEFT_0) + assertTrue(restored.isAuto) + assertEquals(before.minVal, restored.minVal, 1e-9) + assertEquals(before.maxVal, restored.maxVal, 1e-9) + } + @Test fun hiddenSeriesDoesNotAffectSharedAutoRange() { val r = renderer() r.assignSeriesAxis("b", AxisPosition.LEFT_0) @@ -118,6 +195,16 @@ class GraphSettingsRegressionTest { } } + @Test fun xAxisManualTimeUsesReadableLocalTextAndParsesLegacySeconds() { + val value = 1_789_447_222.234 + val text = chartTimeInputText(value, ZoneOffset.UTC) + assertEquals("2026-09-15 04:40:22.234", text) + assertFalse(text.contains('E', ignoreCase = true)) + assertEquals(value, parseChartTimeInput(text, value, ZoneOffset.UTC)!!, 0.001) + assertEquals(value, parseChartTimeInput("04:40:22.234", value, ZoneOffset.UTC)!!, 0.001) + assertEquals(value, parseChartTimeInput(value.toString(), value, ZoneOffset.UTC)!!, 0.001) + } + @Test fun zoomSwitchesFromFixedPresetToAdaptiveTicksEvenWhileLive() { val r = renderer() r.setTimeWindow(TimeWindowPreset.MIN_1) diff --git a/Android/app/src/test/java/com/dct/app/tests/HardwareWireRegressionTest.kt b/Android/app/src/test/java/com/dct/app/tests/HardwareWireRegressionTest.kt new file mode 100644 index 0000000..85789a5 --- /dev/null +++ b/Android/app/src/test/java/com/dct/app/tests/HardwareWireRegressionTest.kt @@ -0,0 +1,70 @@ +package com.dct.app.tests + +import com.dct.app.data.protocol.* +import com.dct.app.domain.model.protocol.* +import com.dct.app.domain.model.mdb.* +import com.google.common.truth.Truth.assertThat +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.Assertions.assertThrows + +class HardwareWireRegressionTest { + private fun hex(s: String) = s.split(" ").map { it.toInt(16).toByte() }.toByteArray() + + @Test fun capturedHardwareResponses() { + val codec = DctPacketCodec() + // 20260716_104112_log.txt: untouched complete hardware frames. + val mode = codec.decode(hex("A1 00 04 89 06 01 00 CA 7E"))!! + DctResponseValidator.validateSetup(DctProtocolRequests.canMode(false, false), mode) + val start = codec.decode(hex("A1 00 03 A0 01 01 B9 7E"))!! + DctResponseValidator.validateSetup(DctProtocolRequests.startAll(), start) + } + + @Test fun startStatusPrecedesTypeAndMalformedRepliesFail() { + for (payload in listOf("02 01", "01 02", "01 00", "01", "01 01 00", "")) { + val bytes = if (payload.isEmpty()) byteArrayOf() else hex(payload) + assertThrows(IllegalArgumentException::class.java) { + DctResponseValidator.validateSetup(DctProtocolRequests.startAll(), + DctPacket.create(DctCommand.RES_START_TRANSMIT_DATA, bytes)) + } + } + } + + @Test fun allCanModeFlagsMustBeEchoed() { + for (modern in listOf(false, true)) for (old in listOf(false, true)) { + val request = DctProtocolRequests.canMode(modern, old) + DctResponseValidator.validateSetup(request, + DctPacket.create(DctCommand.RES_DCT_ENV, byteArrayOf(6, 1, request.payload[1]))) + } + } + + @Test fun wireCanIdIsIndependentOfDbcSignalByteOrder() { + val parsed = DctStreamPayloadParser.parseCanStream(hex("02 01 01 01 00 10 02 12 34")) + as DctStreamPayloadParser.CanStreamPayload.Can + assertThat(parsed.frames.single().canId).isEqualTo(0x10L) + assertThat(parsed.frames.single().data).isEqualTo(hex("12 34")) + } + + // Golden bytes follow PC MAKEWORD(low, high) / MAKELONG(low, high), not the encoder. + @Test fun mdbBigEndianSignedUnsignedBoundariesAndScaling() { + val vectors = listOf( + Triple(MdbAttrType.ATTR_2_UINT16, "00 64", 100.0), + Triple(MdbAttrType.ATTR_2_UINT16, "12 34", 4660.0), + Triple(MdbAttrType.ATTR_2_UINT16, "FF FF", 65535.0), + Triple(MdbAttrType.ATTR_3_INT16, "FF 9C", -100.0), + Triple(MdbAttrType.ATTR_3_INT16, "80 00", -32768.0), + Triple(MdbAttrType.ATTR_4_UINT32, "12 34 56 78", 305419896.0), + Triple(MdbAttrType.ATTR_4_UINT32, "FF FF FF FF", 4294967295.0), + Triple(MdbAttrType.ATTR_5_INT32, "80 00 00 00", -2147483648.0), + Triple(MdbAttrType.ATTR_5_INT32, "7F FF FF FF", 2147483647.0), + Triple(MdbAttrType.ATTR_5_INT32, "FF FF FF 9C", -100.0) + ) + for ((attr, bytes, raw) in vectors) { + val definition = SignalDefinition(7, 1, "Value", attr, 0.5, -2.0, "", 0, + -Double.MAX_VALUE, Double.MAX_VALUE, arrayIndex = 0) + val result = MdbRecordDecoder.decode(7, hex(bytes), listOf(definition)) + as MdbRecordDecoder.DecodeResult.Success + assertThat(result.signals.single().rawValue.toDouble()).isEqualTo(raw) + assertThat(result.signals.single().physicalValue).isEqualTo(raw * 0.5 - 2.0) + } + } +} diff --git a/Android/app/src/test/java/com/dct/app/tests/JackcessLoadUnitTest.kt b/Android/app/src/test/java/com/dct/app/tests/JackcessLoadUnitTest.kt index 1817d3b..aad5dd6 100644 --- a/Android/app/src/test/java/com/dct/app/tests/JackcessLoadUnitTest.kt +++ b/Android/app/src/test/java/com/dct/app/tests/JackcessLoadUnitTest.kt @@ -1,4 +1,4 @@ -package com.dct.app.tests +package com.dct.app.tests import com.google.common.truth.Truth.assertThat import com.healthmarketscience.jackcess.ColumnBuilder 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 0f14d91..5217c6f 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 @@ -13,6 +13,7 @@ import com.dct.app.domain.usecase.chart.ChartBatchRenderer import com.dct.app.presentation.calibration.CalibrationEditorMode import com.dct.app.presentation.calibration.CalibrationViewModel import com.dct.app.presentation.main.MainViewModel +import com.dct.app.presentation.main.ProjectLoadState import com.dct.app.presentation.mdi.MdiViewModel import com.dct.app.presentation.mdi.MdiWindowType import com.dct.app.presentation.signaltree.AddToChartRequest @@ -39,6 +40,309 @@ import java.io.File @DisplayName("M5 - MainViewModel 메인 뷰모델 및 오케스트레이션 단위 테스트") class MainViewModelTest { + @Test fun propertyChangeKeepsUnchangedSourceSamplesAndMatchesMdbItems() { + viewModel.openMdbFromFile(File("Sample.mdb")) + viewModel.setCurrentProject(com.dct.app.domain.model.project.DpcProject(mdbFileName = "Sample.mdb")) + val window = viewModel.addGraphWindow() + val renderer = viewModel.getChartRenderer(window) + renderer.bindSignal("sig_1_1", "Ta_Motor_Speed", AxisPosition.LEFT_0, 0x112233L) + renderer.bindSignal("sig_1_2", "Ta_Inverter_Temp", AxisPosition.LEFT_1, 0x445566L) + renderer.bindSignal("can_7", "CAN", AxisPosition.LEFT_2, 0x778899L) + renderer.addSampleSec("sig_1_1", 1.0, 10.0) + renderer.addSampleSec("can_7", 1.0, 20.0) + val oldSignals = sampleDb.allSignals + val changed = sampleDb.copy(groups = sampleDb.groups.map { group -> + group.copy(signals = group.signals.map { signal -> + if (signal.idx == 2) signal.copy(name = "Replacement") else signal + }) + }) + every { mdbRepository.getCurrentDatabase() } returns changed + viewModel.openMdbFromFile(File("Replacement.mdb")) + val reconcile = MainViewModel::class.java.getDeclaredMethod( + "reconcilePropertySourceBindings", Boolean::class.javaPrimitiveType, + Boolean::class.javaPrimitiveType, Boolean::class.javaPrimitiveType, + List::class.java, List::class.java + ).apply { isAccessible = true } + reconcile.invoke(viewModel, true, false, false, oldSignals, emptyList()) + + val series = renderer.seriesListFlow.value.associateBy { it.signalId } + assertTrue("sig_1_1" in series) + assertFalse("sig_1_2" in series) + assertEquals(0, series.getValue("sig_1_1").buffer.size) + assertEquals(1, series.getValue("can_7").buffer.size) + } + + @Test fun seatToHe1iPropertyChangeKeepsNamedItemsAcrossWindowsPagesAndDpc() { + val intake = sampleDb.allSignals[0].copy(reclid = 160, idx = 12, name = "Intake", + cvsA = 0.01953125, unit = "C", color = 11500L) + val speed = sampleDb.allSignals[1].copy(reclid = 160, idx = 25, name = "Speed", + unit = "KPH", color = 39500L, max = 260.0) + val removed = intake.copy(idx = 30, name = "SeatOnly") + val oldSignals = listOf(intake, speed, removed) + sampleDb = sampleDb.copy(groups = listOf(sampleDb.groups.single().copy(signals = oldSignals))) + every { mdbRepository.getCurrentDatabase() } returns sampleDb + viewModel.openMdbFromFile(File("Seat.mdb")) + viewModel.setCurrentProject(com.dct.app.domain.model.project.DpcProject(mdbFileName = "Seat.mdb")) + val intakeBinding = com.dct.app.domain.model.project.DpcSignalBindingState( + "sig_160_12", "Intake", AxisPosition.LEFT_1, intake.color, "C", 4f, 3, false) + val speedBinding = intakeBinding.copy(signalId = "sig_160_25", signalName = "Speed", + axisPosition = AxisPosition.LEFT_0, unit = "KPH", isVisible = true) + val canBinding = intakeBinding.copy(signalId = "can_7", signalName = "CAN", axisPosition = AxisPosition.RIGHT_0) + val bindings = listOf(intakeBinding, canBinding, speedBinding, + intakeBinding.copy(signalId = "sig_160_30", signalName = "SeatOnly", axisPosition = AxisPosition.LEFT_2)) + val chart = com.dct.app.domain.model.project.DpcChartState( + axes = listOf(com.dct.app.domain.model.chart.ChartAxisConfig(AxisPosition.LEFT_1, -20.0, 80.0, false)), + timeWindowPreset = com.dct.app.domain.model.chart.TimeWindowPreset.SEC_10, legendPosition = 2) + fun windows(prefix: String) = listOf( + com.dct.app.domain.model.project.DpcWindowState("${prefix}_graph", "GRAPH", "Graph", x = 510f, + signals = bindings, chart = chart), + com.dct.app.domain.model.project.DpcWindowState("${prefix}_multi", "MULTI_AXIS_GRAPH", "Multi", + signals = bindings, chart = chart), + com.dct.app.domain.model.project.DpcWindowState("${prefix}_alpha", "ALPHANUMERICAL", "Alpha", + targetSignals = bindings.map { it.signalId }, isTileMode = false, + hiddenSignalIds = setOf("sig_160_12", "sig_160_30"), + highlightColors = mapOf("sig_160_25" to 0x123456L, "sig_160_30" to 0x654321L))) + viewModel.restoreProjectWorkspace(com.dct.app.domain.model.project.DpcWorkspaceState("page_1", listOf( + com.dct.app.domain.model.project.DpcPageState("page_1", "Page 1", windows("one"), signals = bindings), + com.dct.app.domain.model.project.DpcPageState("page_2", "Page 2", windows("two"), signals = bindings, + alphanumericalSignals = bindings.map { it.signalId })))) + val expectedIds = listOf("sig_160_8", "can_7", "sig_160_33") + val canBuffers = (listOf(chartRenderer) + listOf("one_graph", "one_multi", "two_graph", "two_multi") + .map(viewModel::getChartRenderer)).associateWith { renderer -> + renderer.addSampleSec("can_7", 1.0, 123.0) + renderer.addSampleSec("sig_160_12", 1.0, 999.0) + renderer.seriesListFlow.value.first { it.signalId == "can_7" }.buffer + } + val newIntake = intake.copy(idx = 8, cvsA = 0.02, color = 5639644L) + val newSpeed = speed.copy(idx = 33, unit = "km/h", max = 255.0, color = 12478542L) + val replacement = sampleDb.copy(groups = listOf(sampleDb.groups.single().copy( + signals = listOf(newIntake, newSpeed, intake.copy(name = "UnrelatedAtOldId"))))) + every { mdbRepository.getCurrentDatabase() } returns replacement + viewModel.openMdbFromFile(File("HE1i.mdb")) + reconcileMdbProperties(oldSignals) + + canBuffers.forEach { (renderer, buffer) -> + val series = renderer.seriesListFlow.value + assertEquals(expectedIds, series.map { it.signalId }) + assertSame(buffer, series[1].buffer) + assertEquals(1, series[1].buffer.size) + assertEquals(0, series[0].buffer.size) + assertEquals(intakeBinding.axisPosition, series[0].axisPosition) + assertEquals(intakeBinding.lineWidthDp, series[0].lineWidthDp) + assertEquals(intakeBinding.dashStyle, series[0].dashStyle) + assertFalse(series[0].isVisible) + assertEquals(newIntake.color, series[0].color) + } + val saved = viewModel.snapshotWorkspaceForSave() + saved.pages.forEach { page -> + assertEquals(expectedIds, page.signals.map { it.signalId }) + page.windows.filter { it.type == "ALPHANUMERICAL" }.forEach { alpha -> + assertEquals(expectedIds, alpha.targetSignals) + assertFalse(alpha.isTileMode) + assertEquals(setOf("sig_160_8"), alpha.hiddenSignalIds) + assertEquals(mapOf("sig_160_33" to 0x123456L), alpha.highlightColors) + } + page.windows.filter { it.type in listOf("GRAPH", "MULTI_AXIS_GRAPH") }.forEach { graph -> + assertEquals(expectedIds, graph.signals.map { it.signalId }) + val axis = graph.chart!!.axes.first { it.position == AxisPosition.LEFT_1 } + assertEquals(-20.0, axis.minVal) + assertEquals(80.0, axis.maxVal) + assertFalse(axis.isAuto) + assertEquals(newIntake.color, axis.color) + assertEquals(2, graph.chart.legendPosition) + } + } + assertEquals(expectedIds, saved.pages[1].alphanumericalSignals) + assertEquals(listOf("sig_160_8", "sig_160_33"), viewModel.uiState.value.dashboardTiles.map { it.signalId }) + assertEquals("km/h", viewModel.uiState.value.dashboardTiles[1].unit) + assertEquals(0.02, viewModel.uiState.value.mdbDatabase!!.allSignals.first().cvsA) + // A saved/reopened project must refer to the new wire IDs, never the old Seat slots. + val json = com.dct.app.data.repository.DpcProjectManager.serializeProjectToJson( + viewModel.currentProject.value!!.copy(workspace = saved)) + val restored = com.dct.app.data.repository.DpcProjectManager.parseProjectFromJson(json, null) + assertEquals(saved, restored.workspace) + viewModel.restoreProjectWorkspace(restored.workspace) + viewModel.switchPage("page_2") + assertEquals(expectedIds, viewModel.getChartRenderer("two_multi").seriesListFlow.value.map { it.signalId }) + assertEquals(expectedIds, mdiViewModel.uiState.value.windows.first { it.id == "two_alpha" }.config.targetSignals) + } + + @Test fun propertyChangeHandlesSwappedMdbIdsWithoutOverwritingItems() { + viewModel.openMdbFromFile(File("Sample.mdb")) + val oldSignals = sampleDb.allSignals + val window = viewModel.addGraphWindow() + val renderer = viewModel.getChartRenderer(window) + oldSignals.forEachIndexed { index, signal -> + renderer.bindSignal("sig_1_${signal.idx}", signal.name, AxisPosition.LEFT_AXES[index], signal.color) + } + val replacement = sampleDb.copy(groups = sampleDb.groups.map { group -> + group.copy(signals = group.signals.map { it.copy(idx = 3 - it.idx) }) + }) + every { mdbRepository.getCurrentDatabase() } returns replacement + viewModel.openMdbFromFile(File("Swapped.mdb")) + reconcileMdbProperties(oldSignals) + assertEquals(listOf("sig_1_2", "sig_1_1"), renderer.seriesListFlow.value.map { it.signalId }) + assertEquals(oldSignals.map { it.name }, renderer.seriesListFlow.value.map { it.signalName }) + } + + @Test fun propertyChangeUsesFirstExactMdbNameLikePcAndRemovesMissingItems() { + viewModel.openMdbFromFile(File("Sample.mdb")) + val oldSignals = sampleDb.allSignals + val window = viewModel.addGraphWindow() + val renderer = viewModel.getChartRenderer(window) + oldSignals.forEach { renderer.bindSignal("sig_1_${it.idx}", it.name, AxisPosition.LEFT_0, it.color) } + val replacement = sampleDb.copy(groups = sampleDb.groups.map { group -> group.copy(signals = listOf( + oldSignals[0].copy(reclid = 164, idx = 8), oldSignals[0].copy(reclid = 164, idx = 9), + oldSignals[1].copy(idx = 20, name = oldSignals[1].name.lowercase()) + )) }) + every { mdbRepository.getCurrentDatabase() } returns replacement + viewModel.openMdbFromFile(File("Duplicate.mdb")) + reconcileMdbProperties(oldSignals) + assertEquals(listOf("sig_164_8"), renderer.seriesListFlow.value.map { it.signalId }) + every { mdbRepository.getCurrentDatabase() } returns replacement.copy(groups = emptyList()) + viewModel.openMdbFromFile(File("Empty.mdb")) + reconcileMdbProperties(replacement.allSignals) + assertTrue(renderer.seriesListFlow.value.isEmpty()) + } + + private fun reconcileMdbProperties(oldSignals: List) { + MainViewModel::class.java.getDeclaredMethod( + "reconcilePropertySourceBindings", Boolean::class.javaPrimitiveType, + Boolean::class.javaPrimitiveType, Boolean::class.javaPrimitiveType, + List::class.java, List::class.java + ).apply { isAccessible = true }.invoke(viewModel, true, false, false, oldSignals, emptyList()) + } + + @Test fun propertyChangeRemapsA2lByNameWhenAddressChanges() { + val oldSignal = SignalDefinition(0, 1, "Oil_Temp", MdbAttrType.ATTR_2_UINT16, + 1.0, 0.0, "C", 0x123456L, 0.0, 100.0, adl = 10) + val newSignal = oldSignal.copy(idx = 2, adl = 20) + val signalsField = MainViewModel::class.java.getDeclaredField("_a2lSignals").apply { isAccessible = true } + @Suppress("UNCHECKED_CAST") + val signals = signalsField.get(viewModel) as MutableStateFlow> + signals.value = listOf(newSignal) + val window = viewModel.addGraphWindow() + val renderer = viewModel.getChartRenderer(window) + renderer.bindSignal("a2l_10", "Oil_Temp", AxisPosition.LEFT_0, 0x123456L) + renderer.bindSignal("can_7", "CAN", AxisPosition.LEFT_1, 0x334455L) + renderer.addSampleSec("can_7", 1.0, 20.0) + val reconcile = MainViewModel::class.java.getDeclaredMethod( + "reconcilePropertySourceBindings", Boolean::class.javaPrimitiveType, + Boolean::class.javaPrimitiveType, Boolean::class.javaPrimitiveType, + List::class.java, List::class.java + ).apply { isAccessible = true } + reconcile.invoke(viewModel, false, true, false, emptyList(), listOf(oldSignal)) + assertEquals(setOf("a2l_20", "can_7"), renderer.seriesListFlow.value.map { it.signalId }.toSet()) + assertEquals(1, renderer.seriesListFlow.value.first { it.signalId == "can_7" }.buffer.size) + } + + @Test fun characteristicOnlyA2lPropertyChangeKeepsCategoryWindowsAcrossPages( + @org.junit.jupiter.api.io.TempDir directory: java.nio.file.Path + ) = runTest { checkCalibrationOnlyA2lPropertyChange(directory, array = false) } + + @Test fun arrayOnlyA2lPropertyChangeKeepsCategoryWindowsAcrossPages( + @org.junit.jupiter.api.io.TempDir directory: java.nio.file.Path + ) = runTest { checkCalibrationOnlyA2lPropertyChange(directory, array = true) } + + /** Exercise the real parser and Apply path, including source removal after replacement. */ + private suspend fun checkCalibrationOnlyA2lPropertyChange(directory: java.nio.file.Path, array: Boolean) { + val old = com.dct.app.domain.model.project.DpcProject(a2lFileName = "Old.a2l") + viewModel.setCurrentProject(old) + val categoryTypes = listOf("A2L_MEASUREMENTS", "A2L_CHARACTERISTICS", + "A2L_CHARACTERISTICS_ARRAY", "A2L_SIGNAL_LIST") + fun windows(prefix: String) = categoryTypes.mapIndexed { index, type -> + com.dct.app.domain.model.project.DpcWindowState("${prefix}_$type", type, type, + x = 500f + index * 20f, y = 100f, width = 440f, height = 350f, + contentState = mapOf("selection" to "OldParameter"), + targetParameterName = "OldParameter", targetTableName = "OldTable") + } + listOf( + com.dct.app.domain.model.project.DpcWindowState("${prefix}_graph", "GRAPH", "Graph", + signals = listOf(com.dct.app.domain.model.project.DpcSignalBindingState( + "a2l_10", "OldMeasurement", AxisPosition.LEFT_0, 0x123456L))), + com.dct.app.domain.model.project.DpcWindowState("${prefix}_alpha", "ALPHANUMERICAL", "Alpha", + targetSignals = listOf("a2l_10"))) + val pages = listOf( + com.dct.app.domain.model.project.DpcPageState("page_1", "Page 1", windows("one")), + com.dct.app.domain.model.project.DpcPageState("page_2", "Page 2", windows("two"))) + viewModel.restoreProjectWorkspace(com.dct.app.domain.model.project.DpcWorkspaceState("page_1", pages)) + val replacement = directory.resolve("CalibrationOnly.a2l").toFile().apply { + writeText(""" + /begin PROJECT Test "Test" + /begin MODULE Test "Test" + /begin RECORD_LAYOUT Deposit_UWORD + FNC_VALUES 1 UWORD ROW_DIR DIRECT + /end RECORD_LAYOUT + /begin CHARACTERISTIC NewParameter "New calibration" + ${if (array) "VAL_BLK" else "VALUE"} + 0x70002000 Deposit_UWORD 0.0 NO_COMPU_METHOD 0.0 100.0 + ${if (array) "MATRIX_DIM 4" else ""} + /end CHARACTERISTIC + /end MODULE + /end PROJECT + """.trimIndent()) + } + mockkObject(com.dct.app.data.repository.DpcProjectManager) + try { + every { com.dct.app.data.repository.DpcProjectManager.publishSourceFile(any(), any(), any(), "A2L") } answers { + secondArg() + } + var success: Boolean? = null + val context = mockk(relaxed = true) + viewModel.applyProjectProperties(context, old.copy(a2lFileName = replacement.name, + localA2lPath = replacement.absolutePath)) { success = it }?.join() + assertEquals(true, success, viewModel.uiState.value.statusMessage) + assertTrue(viewModel.a2lSignals.value.isEmpty()) + assertEquals(if (array) emptyList() else listOf("NewParameter"), + viewModel.a2lCharacteristics.value.map { it.name }) + assertEquals(if (array) listOf("NewParameter") else emptyList(), + viewModel.a2lCharacteristicsArray.value.map { it.name }) + val saved = viewModel.snapshotWorkspaceForSave() + saved.pages.forEachIndexed { index, page -> + pages[index].windows.filter { it.type in categoryTypes }.forEach { before -> + val after = page.windows.single { it.id == before.id } + assertEquals(before.type, after.type) + assertEquals(before.x, after.x) + assertEquals(before.y, after.y) + assertEquals(before.width, after.width) + assertEquals(before.height, after.height) + assertTrue(after.contentState.isEmpty()) + assertEquals("", after.targetParameterName) + assertEquals("", after.targetTableName) + } + assertTrue(page.windows.single { it.type == "GRAPH" }.signals.isEmpty()) + assertTrue(page.windows.single { it.type == "ALPHANUMERICAL" }.targetSignals.isEmpty()) + } + viewModel.switchPage("page_2") + assertTrue(categoryTypes.all { type -> mdiViewModel.uiState.value.windows.any { it.type.name == type } }) + + // Removing the entire source still closes its windows on both active and inactive pages. + viewModel.applyProjectProperties(context, viewModel.currentProject.value!!.copy( + a2lFileName = "", localA2lPath = null, a2lFileUri = null)) { success = it }?.join() + assertEquals(true, success) + viewModel.snapshotWorkspaceForSave().pages.forEach { page -> + assertFalse(page.windows.any { it.type in categoryTypes }) + assertTrue(page.windows.any { it.type == "GRAPH" }) + assertTrue(page.windows.any { it.type == "ALPHANUMERICAL" }) + } + } finally { + unmockkObject(com.dct.app.data.repository.DpcProjectManager) + } + } + + @Test fun invalidPropertySourceDoesNotReplaceCurrentProject( + @org.junit.jupiter.api.io.TempDir directory: java.nio.file.Path + ) = runTest { + val old = com.dct.app.domain.model.project.DpcProject(mdbFileName = "Sample.mdb") + viewModel.setCurrentProject(old) + val invalid = directory.resolve("broken.mdb").toFile().apply { writeText("not an MDB") } + var success: Boolean? = null + viewModel.applyProjectProperties( + mockk(relaxed = true), old.copy(mdbFileName = "broken.mdb", localMdbPath = invalid.absolutePath) + ) { success = it }?.join() + assertEquals(false, success) + assertEquals(old, viewModel.currentProject.value) + } + private val testDispatcher = UnconfinedTestDispatcher() private lateinit var mdbRepository: MdbRepository private lateinit var packetRepository: PacketRepository @@ -54,9 +358,107 @@ class MainViewModelTest { private lateinit var viewModel: MainViewModel private lateinit var sampleDb: MdbDatabase + private var backgroundExecutionAllowed = true + + @Test fun rejectedForegroundStartDoesNotSendMeasurementStart() = runTest { + connectionState.value = DeviceConnectionState.READY + backgroundExecutionAllowed = false + viewModel.startMeasure() + coVerify(exactly = 0) { packetRepository.startStreaming(any()) } + assertFalse(viewModel.uiState.value.isMeasuring) + assertEquals(DeviceConnectionState.READY, connectionState.value) + } + + @Test fun rejectedForegroundStartDoesNotOpenTcpOrChangeConnectionState() = runTest { + backgroundExecutionAllowed = false + viewModel.connectWifi("127.0.0.1", "6000") + verify(exactly = 0) { viewModel.wifiSocketManager.startDualMode(any(), any(), any(), any(), any(), any()) } + assertFalse(viewModel.uiState.value.isConnected) + assertEquals(DeviceConnectionState.DISCONNECTED, connectionState.value) + } + + @Test fun closingWindowReleasesItsRendererButSwitchingPagesKeepsIt() { + val id = mdiViewModel.addWindow(MdiWindowType.GRAPH)!! + val renderer = viewModel.getChartRenderer(id) + renderer.bindSignal("s", "Signal", AxisPosition.LEFT_0, 0xFF000000) + renderer.addSampleSec("s", 100.0, 5.0) + viewModel.addPage() + assertSame(renderer, viewModel.getChartRenderer(id)) + assertEquals(1, renderer.seriesListFlow.value.single().buffer.size) + viewModel.switchPage("page_1") + viewModel.closeWorkspaceWindow(id) + assertTrue(renderer.seriesListFlow.value.isEmpty()) + assertTrue(mdiViewModel.uiState.value.windows.none { it.id == id }) + } + + @Test fun deletingPageDisposesItsGraphRenderers() { + val page = viewModel.addPage() + val id = mdiViewModel.addWindow(MdiWindowType.GRAPH)!! + val renderer = viewModel.getChartRenderer(id) + renderer.bindSignal("s", "Signal", AxisPosition.LEFT_0, 0xFF000000) + viewModel.removePage(page) + assertTrue(renderer.seriesListFlow.value.isEmpty()) + assertEquals("page_1", viewModel.activePageId.value) + } + + @Test fun unrelatedPacketsDoNotDuplicateGraphSamplesAndReceiveTimeIsPreserved() = runTest { + connectionState.value = DeviceConnectionState.MEASURING + viewModel.handleAddToChart("sm_01_01", "Sensor one", 0xFF0052CCL, "C") + viewModel.handleAddToChart("sm_01_02", "Sensor two", 0xFF0052CCL, "C") + val packets = packetRepository.packetStream as MutableSharedFlow + packets.emit(DctPacket(0xA4.toByte(), byteArrayOf(1, 0, 1, 0, 0, 25, 0, 0), receivedAtMillis = 100_000)) + packets.emit(DctPacket(0xA4.toByte(), byteArrayOf(1, 0, 1, 1, 0, 30, 0, 0), receivedAtMillis = 101_000)) + val first = chartRenderer.seriesListFlow.value.first { it.signalId == "sm_01_01" } + val second = chartRenderer.seriesListFlow.value.first { it.signalId == "sm_01_02" } + assertEquals(1, first.buffer.size) + assertEquals(1, second.buffer.size) + assertEquals(100.0, first.buffer.getLatestTimestamp()) + assertEquals(101.0, second.buffer.getLatestTimestamp()) + assertEquals(25.0, viewModel.liveTelemetryValues.value["sm_01_01"]) + chartRenderer.stop() + } + + @Test fun mixedCanAndSensorPacketsPreserveSamplesOrderAndFractionalReceiveTimes() = runTest { + connectionState.value = DeviceConnectionState.MEASURING + val definition = sampleDb.allSignals.first().copy( + reclid = 0, idx = 7, name = "Speed", messageId = 0x456, + attr = MdbAttrType.ATTR_0_UINT8, startBit = 0, bitLength = 8, + cvsA = 1.0, cvsB = 0.0 + ) + @Suppress("UNCHECKED_CAST") + val definitions = MainViewModel::class.java.getDeclaredField("_canSignals") + .apply { isAccessible = true }.get(viewModel) as MutableStateFlow> + definitions.value = listOf(definition) + val graph = viewModel.getChartRenderer(viewModel.addGraphWindow()).apply { stop() } + val multi = viewModel.getChartRenderer(mdiViewModel.addWindow(MdiWindowType.MULTI_AXIS_GRAPH)!!).apply { stop() } + for (renderer in listOf(graph, multi)) { + renderer.bindSignal("CAN_0x456_SPEED", "Old persisted name", AxisPosition.LEFT_0, 0L) + } + val packets = packetRepository.packetStream as MutableSharedFlow + repeat(60) { index -> + val received = 100.125 + index / 60.0 + packets.emit(DctPacket(0xA3.toByte(), receivedAtSec = received, ingressSequence = index * 2L + 1, + decodedStreamPayload = com.dct.app.data.protocol.DctStreamPayloadParser.CanStreamPayload.Can( + 0, 1, listOf(com.dct.app.data.protocol.DctStreamPayloadParser.CanFrame( + 0x456, byteArrayOf(index.toByte()), true))))) + packets.emit(DctPacket(0xA4.toByte(), byteArrayOf(1, 0, 1, 0, 0, 25, 0, 0), + receivedAtSec = received + 0.001, ingressSequence = index * 2L + 2)) + } + for (renderer in listOf(graph, multi)) { + val buffer = renderer.seriesListFlow.value.single().buffer + assertEquals(60, buffer.size) + repeat(60) { index -> + assertEquals(index.toDouble(), buffer.getValueAt(index)) + assertEquals(100.125 + index / 60.0, buffer.getTimestampAt(index), 0.000000001) + } + } + assertEquals(59.0, viewModel.liveTelemetryValues.value["can_1110_Speed"]) + assertEquals(25.0, viewModel.liveTelemetryValues.value["sm_01_01"]) + } @BeforeEach fun setUp() { + backgroundExecutionAllowed = true Dispatchers.setMain(testDispatcher) mdbRepository = mockk(relaxed = true) packetRepository = mockk(relaxed = true) @@ -100,7 +502,8 @@ class MainViewModelTest { eventBus = eventBus, dispatchers = dispatchers, wifiSocketManager = mockk(relaxed = true), // Lifecycle tests must not launch real asynchronous TCP attempts. - packetRepository = packetRepository + packetRepository = packetRepository, + ensureBackgroundExecution = { backgroundExecutionAllowed } ) // Rendering cadence is not under test; keep virtual time from advancing forever. chartRenderer.stop() @@ -115,10 +518,12 @@ class MainViewModelTest { val method = MainViewModel::class.java.getDeclaredMethod("decodeMdbRecord", Int::class.javaPrimitiveType, ByteArray::class.java, MutableMap::class.java) method.isAccessible = true val values = mutableMapOf() - assertNull(method.invoke(viewModel, 1, byteArrayOf(0x34, 0x12), values)) + assertNull(method.invoke(viewModel, 1, byteArrayOf(0x12, 0x34), values)) assertEquals(4660.0, values["raw_sig_1_1"]) assertEquals(7.0, values["sig_1_1"]) - assertNotNull(method.invoke(viewModel, 1, byteArrayOf(0x12), values)) + // 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)) assertEquals(4660.0, values["raw_sig_1_1"]) } @@ -199,6 +604,63 @@ class MainViewModelTest { } } + private suspend fun loadCalibrationArray(directory: java.nio.file.Path, fnc: String = "1 SWORD COLUMN_DIR DIRECT") { + val file = directory.resolve("layout.a2l").toFile().apply { writeText(""" + /begin CHARACTERISTIC Table "table" + VAL_BLK 0x011E0020 AUTO_EXTRACTION_VALUE_S16 0 NO_COMPU_METHOD -32768 32767 + MATRIX_DIM 20 2 + /end CHARACTERISTIC + /begin RECORD_LAYOUT AUTO_EXTRACTION_VALUE_S16 + FNC_VALUES $fnc + /end RECORD_LAYOUT + """.trimIndent()) } + viewModel.openA2lFromFile(file).join() + connectionState.value = DeviceConnectionState.READY + } + + @Test fun arrayWriteUsesPcColumnAddressAndUpdatesOnlyAfterAck( + @org.junit.jupiter.api.io.TempDir directory: java.nio.file.Path + ) = runTest { + loadCalibrationArray(directory) + val pending = kotlinx.coroutines.CompletableDeferred>() + coEvery { packetRepository.writeXcpCharacteristic(any(), any(), any()) } coAnswers { pending.await() } + viewModel.updateA2lCharacteristicArrayCell("Table", 1, -123.0) + coVerify(exactly = 1) { packetRepository.writeXcpCharacteristic(0x011E0024L, + com.dct.app.domain.model.a2l.A2lDataType.SWORD, -123.0) } + assertEquals(0.0, viewModel.a2lCharacteristicsArray.value.single().values[1]) + pending.complete(DctResult.Success(Unit)) + assertEquals(-123.0, viewModel.a2lCharacteristicsArray.value.single().values[1]) + coEvery { packetRepository.writeXcpCharacteristic(any(), any(), any()) } returns + DctResult.Failure(com.dct.app.core.error.DctError.Unknown("NACK")) + viewModel.updateA2lCharacteristicArrayCell("Table", 1, -456.0) + assertEquals(-123.0, viewModel.a2lCharacteristicsArray.value.single().values[1]) + } + + @Test fun arrayReadUsesSameLayoutAndSignedTypeAndUpdatesOnlyRequestedCell( + @org.junit.jupiter.api.io.TempDir directory: java.nio.file.Path + ) = runTest { + loadCalibrationArray(directory) + coEvery { packetRepository.readXcpCharacteristic(any(), any()) } returns + DctResult.Success(byteArrayOf(0x85.toByte(), 0xFF.toByte())) + viewModel.readA2lCharacteristic("Table", 20) + coVerify(exactly = 1) { packetRepository.readXcpCharacteristic(0x011E0022L, + com.dct.app.domain.model.a2l.A2lDataType.SWORD) } + val values = viewModel.a2lCharacteristicsArray.value.single().values + assertEquals(-123.0, values[20]) + assertEquals(1, values.count { it != 0.0 }) + } + + @Test fun unsupportedArrayLayoutNeverSendsReadOrWrite( + @org.junit.jupiter.api.io.TempDir directory: java.nio.file.Path + ) = runTest { + loadCalibrationArray(directory, "1 SWORD COLUMN_DIR PWORD") + viewModel.updateA2lCharacteristicArrayCell("Table", 0, 1.0) + viewModel.readA2lCharacteristic("Table", 0) + coVerify(exactly = 0) { packetRepository.writeXcpCharacteristic(any(), any(), any()) } + coVerify(exactly = 0) { packetRepository.readXcpCharacteristic(any(), any()) } + assertTrue(viewModel.uiState.value.statusMessage!!.contains("간접 주소")) + } + @Test fun constantResultCallbackWaitsForAckAndReportsBusyAndNack() = runTest { viewModel.openMdbFromFile(File("Sample.mdb")) connectionState.value = DeviceConnectionState.READY @@ -529,13 +991,20 @@ class MainViewModelTest { mockkObject(com.dct.app.data.repository.DpcProjectManager) try { every { com.dct.app.data.repository.DpcProjectManager.loadProjectFromFile(projectFile) } returns project - viewModel.openProjectFromFile(context, projectFile) + every { com.dct.app.data.repository.DpcProjectManager.publishAvailableProjectSources(context, project) } returns project + assertTrue(viewModel.openProjectFromFile(context, projectFile)) + assertEquals(ProjectLoadState.LOADING, viewModel.projectLoadState.value) + // A second tap while the first source is still being prepared must + // not cancel/restart the original transaction. + assertFalse(viewModel.openProjectFromFile(context, projectFile)) assertTrue(mdiViewModel.uiState.value.windows.none { it.id == "saved_graph" }) assertFalse(viewModel.saveActiveProject(context, showFeedback = false)) gate.complete(Unit) assertEquals("page_2", viewModel.activePageId.value) assertEquals(listOf("saved_graph"), mdiViewModel.uiState.value.windows.map { it.id }) assertNotNull(viewModel.uiState.value.mdbDatabase) + assertEquals(ProjectLoadState.COMPLETED, viewModel.projectLoadState.value) + verify(exactly = 1) { com.dct.app.data.repository.DpcProjectManager.loadProjectFromFile(projectFile) } } finally { gate.complete(Unit) unmockkObject(com.dct.app.data.repository.DpcProjectManager) @@ -798,6 +1267,43 @@ class MainViewModelTest { assertEquals(AxisPosition.LEFT_3, updated.find { it.signalId == "can_2" }?.axisPosition) } + @Test + @DisplayName("PC와 같이 MultiAxisGraph는 최대 4개 아이템만 등록하고 일반 Graph는 제한하지 않아야 한다") + fun testPcGraphItemLimits() { + val multiId = mdiViewModel.addWindow(MdiWindowType.MULTI_AXIS_GRAPH)!! + repeat(5) { index -> + viewModel.handleAddToChart( + signalId = "multi_$index", + signalName = "Multi $index", + colorLong = 0xFF000000L + index, + unit = "", + targetWindowId = multiId + ) + } + + val multiSeries = viewModel.getChartRenderer(multiId).seriesListFlow.value + assertEquals(4, multiSeries.size) + assertEquals( + listOf(AxisPosition.LEFT_0, AxisPosition.LEFT_1, AxisPosition.LEFT_2, AxisPosition.LEFT_3), + multiSeries.map { it.axisPosition } + ) + assertFalse(multiSeries.any { it.signalId == "multi_4" }) + assertFalse(chartRenderer.seriesListFlow.value.any { it.signalId == "multi_4" }) + assertEquals("다중축 그래프는 최대 4개까지 지원합니다.", viewModel.uiState.value.statusMessage) + + val graphId = mdiViewModel.addWindow(MdiWindowType.GRAPH)!! + repeat(5) { index -> + viewModel.handleAddToChart( + signalId = "graph_$index", + signalName = "Graph $index", + colorLong = 0xFF100000L + index, + unit = "", + targetWindowId = graphId + ) + } + assertEquals(5, viewModel.getChartRenderer(graphId).seriesListFlow.value.size) + } + @Test @DisplayName("MDB 로드 후 A2L 프로젝트로 전환 시 이전 MDB 데이터 및 MDB 서브윈도우가 완전 초기화되어야 한다") fun testProjectSwitchingMdbToA2lClearsMdbSignals() = runTest { @@ -921,7 +1427,7 @@ class MainViewModelTest { assertNull(viewModel.uiState.value.connectedDeviceName) // 4. Wi-Fi 연결 요청도 TCP 연결 및 4단계 handshake 전에는 false를 유지한다. - viewModel.connectWifi("DCT_WIFI_CAN_AP") + viewModel.connectWifi("DCT_WIFI_CAN_AP", "127.0.0.1") assertEquals(com.dct.app.presentation.main.CommunicationType.WIFI, viewModel.uiState.value.communicationType) assertFalse(viewModel.uiState.value.isConnected) assertEquals("DCT_WIFI_CAN_AP", viewModel.uiState.value.connectedDeviceName) diff --git a/Android/app/src/test/java/com/dct/app/tests/MaintenanceServiceTest.kt b/Android/app/src/test/java/com/dct/app/tests/MaintenanceServiceTest.kt index 1cfd89c..f0f99ef 100644 --- a/Android/app/src/test/java/com/dct/app/tests/MaintenanceServiceTest.kt +++ b/Android/app/src/test/java/com/dct/app/tests/MaintenanceServiceTest.kt @@ -5,6 +5,7 @@ import com.dct.app.data.maintenance.* import com.dct.app.domain.model.protocol.* import com.dct.app.domain.repository.* import io.mockk.* +import kotlinx.coroutines.async import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.test.runTest import org.junit.jupiter.api.Assertions.* @@ -39,4 +40,61 @@ class MaintenanceServiceTest { coEvery { repo.sendCommand(match { it.payload[0] == 2.toByte() }) } returns DctResult.Success(DctPacket.create(DctCommand.RES_DB, byteArrayOf(2,1,8,2,65,66))) assertTrue(runCatching { MaintenanceService(repo).listDatabases() }.isFailure) } + + @Test fun removeFirmwareValidatesSlotAndHandlesSuccessAndRejection() = runTest { + val repo = mockk() + every { repo.connectionState } returns MutableStateFlow(DeviceConnectionState.READY) + coEvery { repo.sendCommand(match { it.payload[0] == 0x06.toByte() && it.payload[1] == 1.toByte() }) } returns + DctResult.Success(DctPacket.create(DctCommand.RES_FW_INFO, byteArrayOf(0x06, 0x01))) + + val service = MaintenanceService(repo) + val successResult = service.removeFirmware(1) + assertTrue(successResult.isSuccess) + + // 단말 거절 (0x02) + coEvery { repo.sendCommand(match { it.payload[0] == 0x06.toByte() && it.payload[1] == 2.toByte() }) } returns + DctResult.Success(DctPacket.create(DctCommand.RES_FW_INFO, byteArrayOf(0x06, 0x02))) + val rejectedResult = service.removeFirmware(2) + assertTrue(rejectedResult.isFailure) + + // 슬롯 인덱스 범위 초과 + assertTrue(service.removeFirmware(-1).isFailure) + assertTrue(service.removeFirmware(256).isFailure) + } + + @Test fun triggerFirmwareUpdateHandlesProgressAndCompletion() = runTest { + val repo = mockk() + every { repo.connectionState } returns MutableStateFlow(DeviceConnectionState.READY) + val packetStream = kotlinx.coroutines.flow.MutableSharedFlow(replay = 10, extraBufferCapacity = 10) + every { repo.packetStream } returns packetStream + + // 0x07 트리거 성공 응답 + coEvery { repo.sendCommand(match { it.payload[0] == 0x07.toByte() }) } returns + DctResult.Success(DctPacket.create(DctCommand.RES_FW_INFO, byteArrayOf(0x07, 0x01))) + + // 0x08 ACK 송신 모킹 + coEvery { repo.sendCommand(match { it.payload[0] == 0x08.toByte() }) } returns + DctResult.Success(DctPacket.create(DctCommand.RES_FW_INFO, byteArrayOf(0x08, 0x01))) + + val service = MaintenanceService(repo) + + val updateJob = async { + service.triggerFirmwareUpdate(0) + } + + // 진행 패킷 방출 (50%) + packetStream.emit(DctPacket.create(DctCommand.RES_FW_INFO, byteArrayOf(0x08, 0x01, 50))) + // 완료 패킷 방출 + packetStream.emit(DctPacket.create(DctCommand.RES_FW_INFO, byteArrayOf(0x08, 0x02))) + + val result = updateJob.await() + assertTrue(result.isSuccess) + assertEquals(100L, service.transferProgress.value.transferredBytes) + assertFalse(service.transferProgress.value.isRunning) + + // 0x07 시작 거절 시 실패 검증 + coEvery { repo.sendCommand(match { it.payload[0] == 0x07.toByte() }) } returns + DctResult.Success(DctPacket.create(DctCommand.RES_FW_INFO, byteArrayOf(0x07, 0x02))) + assertTrue(service.triggerFirmwareUpdate(0).isFailure) + } } diff --git a/Android/app/src/test/java/com/dct/app/tests/MdbRecordDecoderTest.kt b/Android/app/src/test/java/com/dct/app/tests/MdbRecordDecoderTest.kt new file mode 100644 index 0000000..d586995 --- /dev/null +++ b/Android/app/src/test/java/com/dct/app/tests/MdbRecordDecoderTest.kt @@ -0,0 +1,127 @@ +package com.dct.app.tests + +import com.dct.app.data.protocol.MdbRecordDecoder +import com.dct.app.domain.model.mdb.MdbAttrType +import com.dct.app.domain.model.mdb.SignalDefinition +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test + +@DisplayName("MdbRecordDecoder - uint32_t 인덱스(256 이상) 지원 및 디코딩 무결성 단위 테스트") +class MdbRecordDecoderTest { + + @Test + @DisplayName("[ISSUE-R2-05] 256 이상의 신호 Index(0x0100, 0x0200)가 0xFF 마스킹으로 오인되지 않고 정상 디코딩되어야 한다") + fun testSignalIndexAbove255WithoutMasking() { + // idx = 0과 idx = 256은 0xFF 마스킹 시 둘 다 0이 되어 과거에는 중복 실패했으나, + // 마스킹 제거 후에는 서로 다른 uint32_t 인덱스로 정상 인식되어야 함 + val sigA = SignalDefinition( + reclid = 10, + idx = 0, + name = "Signal_0", + attr = MdbAttrType.ATTR_0_UINT8, + cvsA = 1.0, + cvsB = 0.0, + unit = "", + color = 0L, + min = 0.0, + max = 255.0, + arrayIndex = 0 + ) + val sigB = SignalDefinition( + reclid = 10, + idx = 256, // 0x0100: 과거에는 256 and 0xFF == 0 으로 sigA와 중복 판정됨 + name = "Signal_256", + attr = MdbAttrType.ATTR_2_UINT16, + cvsA = 0.5, + cvsB = 10.0, + unit = "rpm", + color = 0L, + min = 0.0, + max = 32767.0, + arrayIndex = 1 + ) + val sigC = SignalDefinition( + reclid = 10, + idx = 512, // 0x0200: 512 and 0xFF == 0 + name = "Signal_512", + attr = MdbAttrType.ATTR_4_UINT32, + cvsA = 0.1, + cvsB = 0.0, + unit = "km/h", + color = 0L, + min = 0.0, + max = 100000.0, + arrayIndex = 3 + ) + + val definitions = listOf(sigA, sigB, sigC) + // 페이로드 데이터: + // [0]: 0x55 (85) + // [1..2]: 0x03, 0xE8 (1000) + // [3..6]: 0x00, 0x01, 0x86, 0xA0 (100000) + val payload = byteArrayOf( + 0x55.toByte(), + 0x03.toByte(), 0xE8.toByte(), + 0x00.toByte(), 0x01.toByte(), 0x86.toByte(), 0xA0.toByte() + ) + + val result = MdbRecordDecoder.decode(10, payload, definitions) + assertTrue(result is MdbRecordDecoder.DecodeResult.Success, "256 이상 인덱스가 중복 에러 없이 정상 디코딩되어야 합니다.") + + val success = result as MdbRecordDecoder.DecodeResult.Success + assertEquals(3, success.signals.size) + + // sigA 검증 + assertEquals("Signal_0", success.signals[0].definition.name) + assertEquals(85, success.signals[0].rawValue.toInt()) + assertEquals(85.0, success.signals[0].physicalValue) + + // sigB 검증 (1000 * 0.5 + 10.0 = 510.0) + assertEquals("Signal_256", success.signals[1].definition.name) + assertEquals(1000, success.signals[1].rawValue.toInt()) + assertEquals(510.0, success.signals[1].physicalValue) + + // sigC 검증 (100000 * 0.1 = 10000.0) + assertEquals("Signal_512", success.signals[2].definition.name) + assertEquals(100000L, success.signals[2].rawValue.toLong()) + assertEquals(10000.0, success.signals[2].physicalValue) + } + + @Test + @DisplayName("동일한 실제 Index(예: 300과 300)가 존재하는 경우에는 중복 에러로 실패해야 한다") + fun testDuplicateActualIndexFails() { + val sig1 = SignalDefinition( + reclid = 5, + idx = 300, + name = "Sig1", + attr = MdbAttrType.ATTR_0_UINT8, + cvsA = 1.0, + cvsB = 0.0, + unit = "", + color = 0L, + min = 0.0, + max = 255.0, + arrayIndex = 0 + ) + val sig2 = SignalDefinition( + reclid = 5, + idx = 300, // 실제 인덱스 중복 + name = "Sig2", + attr = MdbAttrType.ATTR_0_UINT8, + cvsA = 1.0, + cvsB = 0.0, + unit = "", + color = 0L, + min = 0.0, + max = 255.0, + arrayIndex = 1 + ) + + val result = MdbRecordDecoder.decode(5, byteArrayOf(0x01, 0x02), listOf(sig1, sig2)) + assertTrue(result is MdbRecordDecoder.DecodeResult.Failure) + val failure = result as MdbRecordDecoder.DecodeResult.Failure + assertTrue(failure.reason.contains("Index가 중복됩니다")) + } +} diff --git a/Android/app/src/test/java/com/dct/app/tests/MdiWorkspaceManagerTest.kt b/Android/app/src/test/java/com/dct/app/tests/MdiWorkspaceManagerTest.kt index 6f651f9..13a8022 100644 --- a/Android/app/src/test/java/com/dct/app/tests/MdiWorkspaceManagerTest.kt +++ b/Android/app/src/test/java/com/dct/app/tests/MdiWorkspaceManagerTest.kt @@ -29,6 +29,22 @@ class MdiWorkspaceManagerTest { assertNull(state.activeWindowId) } + @Test + fun compensationDefaultsFitPointColumnsAndPreserveResizedWindows() { + listOf(MdiWindowType.COMPENSATION_TYPE_A, MdiWindowType.COMPENSATION_TYPE_B).forEach { type -> + val id = viewModel.openOrFocusCategoryWindow(type) + val window = viewModel.uiState.value.windows.first { it.id == id } + assertEquals(800f, window.width) + assertEquals(620f, window.height) + assertTrue(window.width - 12f - 6f - 300f >= 400f) + viewModel.updateWindowBounds(id, window.offsetX, window.offsetY, 800f, 700f) + assertEquals(id, viewModel.openOrFocusCategoryWindow(type)) + val resized = viewModel.uiState.value.windows.first { it.id == id } + assertEquals(800f, resized.width) + assertEquals(700f, resized.height) + } + } + @Test @DisplayName("새 서브윈도우 탭을 추가하면 윈도우 목록이 증가하고 새로 추가된 탭이 활성화되어야 한다") fun testAddWindow() { 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 new file mode 100644 index 0000000..0c4845a --- /dev/null +++ b/Android/app/src/test/java/com/dct/app/tests/MeasurementExecutionStateTest.kt @@ -0,0 +1,70 @@ +package com.dct.app.tests + +import com.dct.app.background.MeasurementExecutionState +import com.dct.app.data.communication.WifiDirectPhase +import com.dct.app.domain.repository.DeviceConnectionState +import com.dct.app.presentation.main.MainUiState +import com.dct.app.presentation.main.MeasurementRecordingState +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.Test + +class MeasurementExecutionStateTest { + @Test fun notificationOmitsFrequencyWithoutChangingMeasurementSettingsOrProgress() { + for (frequency in listOf(30, 60, 120)) { + val ui = MainUiState(isMeasuring = true, measurementFrequencyHz = frequency, + projectFileName = "Test.dpc", connectedDeviceName = "Device", packetCount = 100, + recordingSampleCount = 42, recordingState = MeasurementRecordingState.RECORDING) + val notification = state(ui) + assertEquals("Test.dpc · Device · 수신 100건 · 기록 42건", notification.detail) + assertFalse(notification.title.contains("Hz")) + assertEquals(frequency, ui.measurementFrequencyHz) + assertTrue(notification.measuring) + assertTrue(notification.recording) + } + } + + private fun state(ui: MainUiState = MainUiState(), connection: DeviceConnectionState = DeviceConnectionState.DISCONNECTED, + socket: Boolean = false, phase: WifiDirectPhase = WifiDirectPhase.IDLE, group: Boolean = false) = + MeasurementExecutionState.from(ui, connection, socket, phase, group) + + @Test fun idleAndSearchDoNotKeepForegroundServiceRunning() { + assertFalse(state().active) + assertFalse(state(phase = WifiDirectPhase.DISCOVERING).active) + } + + @Test fun negotiationSocketAndGroupOwnerWaitingAreActiveBeforeHandshake() { + assertTrue(state(phase = WifiDirectPhase.CONNECTING).active) + assertTrue(state(connection = DeviceConnectionState.HANDSHAKING).active) + assertTrue(state(socket = true).active) + val waiting = state(group = true, phase = WifiDirectPhase.CONNECTED) + assertTrue(waiting.active) + assertTrue(waiting.title.contains("TCP 대기")) + assertFalse(waiting.measuring) + } + + @Test fun measurementAndRecordingRemainSeparateAndIncludeProgress() { + val ui = MainUiState(isMeasuring = true, projectFileName = "Test.dpc", connectedDeviceName = "Device", + packetCount = 100, recordingSampleCount = 42, recordingStartedAtMillis = 1234, + recordingState = MeasurementRecordingState.RECORDING) + val recording = state(ui) + assertTrue(recording.active) + assertTrue(recording.measuring) + assertTrue(recording.recording) + assertTrue(recording.title.contains("계측 및 기록")) + assertEquals(1234L, recording.startedAtMillis) + assertTrue(recording.detail.contains("수신 100건")) + assertTrue(recording.detail.contains("기록 42건")) + val measuringOnly = state(ui.copy(recordingState = MeasurementRecordingState.IDLE, recordingStartedAtMillis = null)) + assertTrue(measuringOnly.title.contains("기록 안 함")) + assertFalse(measuringOnly.recording) + } + + @Test fun stopMeasurementKeepsConnectionAndServiceButDisconnectEndsIt() { + val waiting = state(connection = DeviceConnectionState.READY) + assertTrue(waiting.active) + assertFalse(waiting.measuring) + assertTrue(waiting.title.contains("계측 대기")) + assertFalse(state(connection = DeviceConnectionState.ERROR).active) + assertTrue(state(MainUiState(recordingState = MeasurementRecordingState.STOPPING)).active) + } +} diff --git a/Android/app/src/test/java/com/dct/app/tests/MeasurementLogPathsTest.kt b/Android/app/src/test/java/com/dct/app/tests/MeasurementLogPathsTest.kt index 8da09d1..797cd60 100644 --- a/Android/app/src/test/java/com/dct/app/tests/MeasurementLogPathsTest.kt +++ b/Android/app/src/test/java/com/dct/app/tests/MeasurementLogPathsTest.kt @@ -24,4 +24,8 @@ class MeasurementLogPathsTest { @Test fun uncPcDirectoryFallsBackToLocalData() { assertEquals(File(base, "Data"), MeasurementLogPaths.resolve("\\\\PC\\Logs", "\\\\PC\\Project", false, base)) } + @Test fun oldSafDisplayPathNeverBecomesARealFilesystemPath() { + assertEquals(File(base, "Data"), MeasurementLogPaths.resolve("/tree/primary:Documents/Logs", base.path, false, base)) + assertEquals(File(base, "Data"), MeasurementLogPaths.resolve("content://com.android.externalstorage.documents/tree/primary%3ADocuments", base.path, false, base)) + } } diff --git a/Android/app/src/test/java/com/dct/app/tests/MeasurementLoggerTest.kt b/Android/app/src/test/java/com/dct/app/tests/MeasurementLoggerTest.kt index 580ae06..e19e40f 100644 --- a/Android/app/src/test/java/com/dct/app/tests/MeasurementLoggerTest.kt +++ b/Android/app/src/test/java/com/dct/app/tests/MeasurementLoggerTest.kt @@ -2,6 +2,8 @@ package com.dct.app.tests import com.dct.app.data.logger.CsvMeasurementLogger import com.dct.app.data.logger.Mf4MeasurementLogger +import com.dct.app.data.logger.PcCsvColumn +import com.dct.app.data.logger.PcMeasurementNames import org.junit.jupiter.api.Assertions.* import org.junit.jupiter.api.DisplayName import org.junit.jupiter.api.Test @@ -12,6 +14,64 @@ import java.io.RandomAccessFile @DisplayName("MeasurementLogger - CSV 및 ASAM MF4 실시간 계측 로거 단위 테스트") class MeasurementLoggerTest { + @Test + fun pcProjectNameAndSecondPrecisionAreUsedForMeasurementFiles() { + val stamp = PcMeasurementNames.timestamp(java.util.Date(0L)) + assertEquals("TEST", PcMeasurementNames.projectName("TEST.dpc")) + assertEquals("test", PcMeasurementNames.projectName("test.DPC")) + assertEquals("PROJECT", PcMeasurementNames.projectName("")) + assertTrue(stamp.matches(Regex("\\d{4}-\\d{2}-\\d{2}_\\d{2}-\\d{2}-\\d{2}"))) + assertEquals("TEST_${stamp}_mdb.mf4", PcMeasurementNames.fileName("TEST", stamp, "mdb", "mf4")) + } + + @Test + fun pcCsvHeaderKeepsIdentityPrecisionAndMissingValues(@TempDir tempDir: File) { + val file = CsvMeasurementLogger( + tempDir, + listOf("EngineSpeed" to "rpm", "Coolant" to "C"), + "TEST_2026-09-28_14-30-00_mdb.csv", + listOf(PcCsvColumn("12", "[0xA3-12]", 0), PcCsvColumn("13", "[0xA3-13]", 2)) + ).apply { + start() + logSample(0.1, listOf(1500.0, null)) + logSample(0.2, listOf(1512.0, 87.125)) + stop() + }.outputFile!! + val lines = file.readLines() + assertEquals("File: ${file.name}", lines[0]) + assertTrue(lines[1].startsWith("Date: ")) + assertTrue(lines[3].contains("12,")) + assertTrue(lines[4].contains("[0xA3-12]")) + assertTrue(lines[6].startsWith(" Time, Count,")) + assertTrue(lines[7].matches(Regex("\\d{2}:\\d{2}:\\d{2}\\.\\d{3}, 0,1500,No value"))) + assertTrue(lines[8].endsWith(",1512,87.13")) + } + + @Test + fun a2lMissingMeasurementUsesPcNcMarker(@TempDir tempDir: File) { + val file = CsvMeasurementLogger(tempDir, listOf("Measurement" to "V"), "A2L_measurement.csv", + listOf(PcCsvColumn("0", "[0x1234]")), "NC").apply { + start() + logSample(0.1, listOf(null)) + stop() + }.outputFile!! + assertTrue(file.readLines().last().endsWith(",NC")) + } + + @Test + fun missingMf4ChannelIsNotWrittenAsMeasuredZero(@TempDir tempDir: File) { + val file = Mf4MeasurementLogger(tempDir, listOf("Signal" to "V"), "missing.mf4").apply { + start() + logSample(0.1, listOf(null)) + stop() + }.outputFile!! + RandomAccessFile(file, "r").use { raf -> + raf.seek(file.length() - 8) + val data = ByteArray(8).also(raf::readFully) + assertTrue(java.nio.ByteBuffer.wrap(data).order(java.nio.ByteOrder.LITTLE_ENDIAN).double.isNaN()) + } + } + @Test @DisplayName("CsvMeasurementLogger가 정상적으로 CSV 헤더 및 시계열 물리값 레코드를 생성해야 한다") fun testCsvMeasurementLogger(@TempDir tempDir: File) { @@ -37,7 +97,7 @@ class MeasurementLoggerTest { } @Test - @DisplayName("Mf4MeasurementLogger가 ASAM MDF v4.10 매직 헤더 및 바이너리 블록을 정상 기록해야 한다") + @DisplayName("Mf4MeasurementLogger가 ASAM MDF v4.10 전체 계층 블록(HD->DG->CG->CN 체인->TX->DT) 및 cycle_count를 정확히 기록해야 한다") fun testMf4MeasurementLogger(@TempDir tempDir: File) { val headers = listOf("EngineSpeed" to "RPM", "CoolantTemp" to "'C") val logger = Mf4MeasurementLogger(tempDir, headers) @@ -51,16 +111,96 @@ class MeasurementLoggerTest { logger.logSample(0.066, listOf(2050.0, 85.5)) logger.stop() - // 바이너리 파일 크기 및 ASAM MDF 매직 넘버 검증 - assertTrue(createdFile.length() > 64) + // 바이너리 파일 크기 및 ASAM MDF 4.10 블록 계층 전수 검증 + assertTrue(createdFile.length() > 296) RandomAccessFile(createdFile, "r").use { raf -> + val buf4 = ByteArray(4) + val buf8 = ByteArray(8) + + // 1. ID Block (0..63) val idMagic = ByteArray(8) + raf.seek(0) raf.readFully(idMagic) assertEquals("MDF ", String(idMagic, Charsets.US_ASCII)) val versionMagic = ByteArray(8) raf.readFully(versionMagic) assertEquals("4.10 ", String(versionMagic, Charsets.US_ASCII)) + + // 2. HD Block (64..167, 104B) + raf.seek(64) + raf.readFully(buf4) + assertEquals("##HD", String(buf4, Charsets.US_ASCII)) + raf.seek(64 + 8) + assertEquals(104L, java.nio.ByteBuffer.wrap(buf8.apply { raf.readFully(this) }).order(java.nio.ByteOrder.LITTLE_ENDIAN).long) // HD length + assertEquals(6L, java.nio.ByteBuffer.wrap(buf8.apply { raf.readFully(this) }).order(java.nio.ByteOrder.LITTLE_ENDIAN).long) // link count + val dgOffset = java.nio.ByteBuffer.wrap(buf8.apply { raf.readFully(this) }).order(java.nio.ByteOrder.LITTLE_ENDIAN).long // hd_dg_first + assertEquals(168L, dgOffset) + + // 3. DG Block (168..231, 64B) + raf.seek(dgOffset) + raf.readFully(buf4) + assertEquals("##DG", String(buf4, Charsets.US_ASCII)) + raf.seek(dgOffset + 8) + assertEquals(64L, java.nio.ByteBuffer.wrap(buf8.apply { raf.readFully(this) }).order(java.nio.ByteOrder.LITTLE_ENDIAN).long) // DG length + assertEquals(4L, java.nio.ByteBuffer.wrap(buf8.apply { raf.readFully(this) }).order(java.nio.ByteOrder.LITTLE_ENDIAN).long) // link count + raf.readFully(buf8) // dg_next (0L) + val cgOffset = java.nio.ByteBuffer.wrap(buf8.apply { raf.readFully(this) }).order(java.nio.ByteOrder.LITTLE_ENDIAN).long // dg_cg_first + assertEquals(232L, cgOffset) + val dtOffset = java.nio.ByteBuffer.wrap(buf8.apply { raf.readFully(this) }).order(java.nio.ByteOrder.LITTLE_ENDIAN).long // dg_data + assertTrue(dtOffset > cgOffset) + + // 4. CG Block (232..295, 64B) + raf.seek(cgOffset) + raf.readFully(buf4) + assertEquals("##CG", String(buf4, Charsets.US_ASCII)) + raf.seek(cgOffset + 8) + assertEquals(64L, java.nio.ByteBuffer.wrap(buf8.apply { raf.readFully(this) }).order(java.nio.ByteOrder.LITTLE_ENDIAN).long) // CG length + val firstCnOffset = java.nio.ByteBuffer.wrap(buf8.apply { raf.readFully(this) }).order(java.nio.ByteOrder.LITTLE_ENDIAN).long // cg_cn_first + assertEquals(296L, firstCnOffset) + val recordId = java.nio.ByteBuffer.wrap(buf8.apply { raf.readFully(this) }).order(java.nio.ByteOrder.LITTLE_ENDIAN).long // record_id + assertEquals(0L, recordId) + + // [ISSUE-R2-02 검증] cycle_count 백필 위치가 CG 블록 내 32바이트 위치 (232 + 32 = 264)인지 검증 + val cycleCount = java.nio.ByteBuffer.wrap(buf8.apply { raf.readFully(this) }).order(java.nio.ByteOrder.LITTLE_ENDIAN).long // cycle_count + assertEquals(2L, cycleCount, "stop() 후 cycle_count는 기록된 레코드 수(2)와 일치해야 합니다.") + + // 5. CN Block 체인 검증 (첫 번째 채널: Time 마스터 채널) + raf.seek(firstCnOffset) + raf.readFully(buf4) + assertEquals("##CN", String(buf4, Charsets.US_ASCII)) + raf.seek(firstCnOffset + 8) + assertEquals(128L, java.nio.ByteBuffer.wrap(buf8.apply { raf.readFully(this) }).order(java.nio.ByteOrder.LITTLE_ENDIAN).long) // CN length + assertEquals(8L, java.nio.ByteBuffer.wrap(buf8.apply { raf.readFully(this) }).order(java.nio.ByteOrder.LITTLE_ENDIAN).long) // link count + val secondCnOffset = java.nio.ByteBuffer.wrap(buf8.apply { raf.readFully(this) }).order(java.nio.ByteOrder.LITTLE_ENDIAN).long // cn_next + assertTrue(secondCnOffset > firstCnOffset) + raf.readFully(buf8) // cn_composition (0L) + val timeNameTxOffset = java.nio.ByteBuffer.wrap(buf8.apply { raf.readFully(this) }).order(java.nio.ByteOrder.LITTLE_ENDIAN).long // cn_tx_name + assertTrue(timeNameTxOffset > 0) + + // Name TXBlock 확인 ("Time") + raf.seek(timeNameTxOffset) + raf.readFully(buf4) + assertEquals("##TX", String(buf4, Charsets.US_ASCII)) + raf.seek(timeNameTxOffset + 24) + val nameBytes = ByteArray(5) + raf.readFully(nameBytes) + assertEquals("Time\u0000", String(nameBytes, Charsets.UTF_8)) + + // Time 채널의 cn_type (마스터 = 2), cn_sync_type (시간 = 1), cn_data_type (Float LE = 4) + raf.seek(firstCnOffset + 88) + assertEquals(2.toByte(), raf.readByte()) // cn_type + assertEquals(1.toByte(), raf.readByte()) // cn_sync_type + assertEquals(4.toByte(), raf.readByte()) // cn_data_type + + // 6. DT Block 검증 + raf.seek(dtOffset) + raf.readFully(buf4) + assertEquals("##DT", String(buf4, Charsets.US_ASCII)) + raf.seek(dtOffset + 8) + val expectedDtLength = 24L + (2L * (8 + 2 * 8)) // 24 header + 2 records * 24 bytes = 72L + val actualDtLength = java.nio.ByteBuffer.wrap(buf8.apply { raf.readFully(this) }).order(java.nio.ByteOrder.LITTLE_ENDIAN).long + assertEquals(expectedDtLength, actualDtLength, "DT 블록 길이는 헤더 24B + 레코드 크기 합산과 일치해야 합니다.") } } diff --git a/Android/app/src/test/java/com/dct/app/tests/MeasurementRestartRegressionTest.kt b/Android/app/src/test/java/com/dct/app/tests/MeasurementRestartRegressionTest.kt index 4f45231..0e67dd1 100644 --- a/Android/app/src/test/java/com/dct/app/tests/MeasurementRestartRegressionTest.kt +++ b/Android/app/src/test/java/com/dct/app/tests/MeasurementRestartRegressionTest.kt @@ -8,6 +8,7 @@ import com.dct.app.domain.model.protocol.DctPacket import com.dct.app.domain.repository.DeviceConnectionState import com.dct.app.domain.repository.PacketRepository import com.dct.app.presentation.main.MainViewModel +import com.dct.app.presentation.main.MeasurementRecordingState import io.mockk.* import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.cancel @@ -66,12 +67,58 @@ class MeasurementRestartRegressionTest { loggers.add(CsvMeasurementLogger(directory.toFile(), headers)) loggers.add(CsvMeasurementLogger(directory.toFile(), emptyList())) val before = vm.uiState.value.packetCount - packets.tryEmit(DctPacket(0x7F)) - packets.tryEmit(DctPacket(0x7F)) + // Unknown commands are intentionally not counted as measurement data. + val telemetry = DctPacket(0x02, ByteArray(18)) + packets.tryEmit(telemetry) + packets.tryEmit(telemetry) assertEquals(subscribers, packets.subscriptionCount.value) assertEquals(before + 2, vm.uiState.value.packetCount) } + @Test fun measurementStartDoesNotAutomaticallyStartFileRecording() { + vm.setCurrentProject( + DpcProject( + measuredFilesFolder = directory.toString(), + writeCsv = true, + writeMf4 = true + ) + ) + + vm.startMeasure() + + assertTrue(vm.uiState.value.isMeasuring) + assertEquals(MeasurementRecordingState.IDLE, vm.uiState.value.recordingState) + assertTrue(loggers.isEmpty()) + val mf4Loggers = field(vm, "activeMf4Loggers").get(vm) as List<*> + assertTrue(mf4Loggers.isEmpty()) + } + + @Test fun malformedStreamIsDiagnosticOnlyAndDoesNotChangeToastStatus() { + vm.startMeasure() + val before = vm.uiState.value.statusMessage + // A3 requires a type byte and a complete payload structure. This is + // deliberately malformed and must be dropped without a user Toast. + packets.tryEmit(DctPacket(0xA3.toByte(), byteArrayOf(0x09))) + assertEquals(before, vm.uiState.value.statusMessage) + } + + @Test fun restartTimestampGraceAcceptsSmallClockSkewButDropsOlderPackets() { + vm.startMeasure() + val requestMillis = 1_000L + field(vm, "measurementRequestMillis").setLong(vm, requestMillis) + val before = vm.uiState.value.packetCount + + // A packet older than the 200 ms grace window belongs to the previous + // measurement session and must not update the dashboard. + packets.tryEmit(DctPacket(0x02, ByteArray(18), receivedAtMillis = requestMillis - 201L)) + assertEquals(before, vm.uiState.value.packetCount) + + // The exact boundary is intentionally accepted to tolerate the small + // wall-clock/monotonic-clock offset observed after STOP -> START. + packets.tryEmit(DctPacket(0x02, ByteArray(18), receivedAtMillis = requestMillis - 200L)) + assertEquals(before + 1, vm.uiState.value.packetCount) + } + @Test fun packetProcessingExceptionDoesNotPreventStopStartOrReconnectReceiving() { vm.chartRenderer.bindSignal("s", "Incar_Snr", com.dct.app.domain.model.chart.AxisPosition.LEFT_0, 0xFF0000L) fun telemetry(value: Float) = DctPacket(0x02, ByteArray(18).also { @@ -90,7 +137,9 @@ class MeasurementRestartRegressionTest { } loggers.add(CsvMeasurementLogger(directory.toFile(), badHeaders)) packets.tryEmit(telemetry(10f)) - assertTrue(vm.uiState.value.statusMessage!!.contains("다음 패킷 수신 계속")) + // Processing failures are diagnostic-only and must not surface as a + // global Toast/status message while the subscription stays alive. + assertFalse(vm.uiState.value.statusMessage.orEmpty().contains("수신 패킷 처리 실패")) assertEquals(subscribers, packets.subscriptionCount.value) val before = vm.uiState.value.packetCount vm.stopMeasure() diff --git a/Android/app/src/test/java/com/dct/app/tests/MinMaxDownsamplerTest.kt b/Android/app/src/test/java/com/dct/app/tests/MinMaxDownsamplerTest.kt index 5cdc07b..1d5e10d 100644 --- a/Android/app/src/test/java/com/dct/app/tests/MinMaxDownsamplerTest.kt +++ b/Android/app/src/test/java/com/dct/app/tests/MinMaxDownsamplerTest.kt @@ -124,4 +124,16 @@ class MinMaxDownsamplerTest { val avgTime = elapsed / 10.0 assertTrue(avgTime < 10.0, "평균 다운샘플링 소요 시간이 10ms 이내여야 합니다 (실제: ${avgTime}ms)") } + + @Test + @DisplayName("다운샘플링 시 단절 NaN 마커가 같은 픽셀 버킷에 흡수되지 않아야 한다") + fun testGapMarkerSurvivesDownsampling() { + val buffer = TimeSeriesRingBuffer(7_000) + for (i in 0 until 3_500) buffer.addSample(i * 0.01, 1.0) + buffer.addGap(35.0) + for (i in 3_501 until 7_000) buffer.addSample(i * 0.01, 2.0) + + val points = DownsamplingAlgorithms.minMaxDownsample(buffer, 0, buffer.size - 1, 0.0, 70.0, 100) + assertTrue(points.any { it.second.isNaN() }, "단절 마커가 다운샘플링 결과에 남아야 합니다") + } } diff --git a/Android/app/src/test/java/com/dct/app/tests/MultiAxisCoordinateTransformTest.kt b/Android/app/src/test/java/com/dct/app/tests/MultiAxisCoordinateTransformTest.kt index 4f0bee1..c75975d 100644 --- a/Android/app/src/test/java/com/dct/app/tests/MultiAxisCoordinateTransformTest.kt +++ b/Android/app/src/test/java/com/dct/app/tests/MultiAxisCoordinateTransformTest.kt @@ -98,6 +98,20 @@ class MultiAxisCoordinateTransformTest { assertEquals(4000.0, recoveredRpm, 1e-4) } + @Test + @DisplayName("축 경계에 닿은 파형은 선 두께만큼 안쪽에 보이고 범위 밖 값은 클리핑 좌표를 유지한다") + fun testVisibleSignalStrokeAtPlotEdges() { + val top = 50f + val height = 400f + assertEquals(53f, MultiAxisCoordinateTransformer.valueToVisibleStrokeY(100.0, 0.0, 100.0, top, height, 3f), 1e-4f) + assertEquals(447f, MultiAxisCoordinateTransformer.valueToVisibleStrokeY(0.0, 0.0, 100.0, top, height, 3f), 1e-4f) + assertEquals(250f, MultiAxisCoordinateTransformer.valueToVisibleStrokeY(50.0, 0.0, 100.0, top, height, 3f), 1e-4f) + assertEquals(52f, MultiAxisCoordinateTransformer.valueToVisibleStrokeY(100.0, 0.0, 100.0, top, height, 1f), 1e-4f) + assertEquals(49f, MultiAxisCoordinateTransformer.valueToVisibleStrokeY(100.25, 0.0, 100.0, top, height, 3f), 1e-4f) + assertEquals(451f, MultiAxisCoordinateTransformer.valueToVisibleStrokeY(-0.25, 0.0, 100.0, top, height, 3f), 1e-4f) + assertEquals(50f, MultiAxisCoordinateTransformer.valueToScreenY(100.0, 0.0, 100.0, top, height), 1e-4f) + } + @Test @DisplayName("PC CChartStandardAxis 알고리즘에 따라 [-50 ~ 100] 범위는 10단위 16개 눈금이 정확히 생성되어야 한다") fun testGenerateStandardTicksLegacyPC() { diff --git a/Android/app/src/test/java/com/dct/app/tests/PacketPipelineMetricsTest.kt b/Android/app/src/test/java/com/dct/app/tests/PacketPipelineMetricsTest.kt new file mode 100644 index 0000000..3390812 --- /dev/null +++ b/Android/app/src/test/java/com/dct/app/tests/PacketPipelineMetricsTest.kt @@ -0,0 +1,23 @@ +package com.dct.app.tests + +import com.dct.app.core.common.PacketPipelineMetrics +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.Test + +class PacketPipelineMetricsTest { + @Test fun receiveToProcessAgeTracksQueueWaitSeparatelyFromProcessing() { + val metrics = PacketPipelineMetrics() + assertEquals(0.0, metrics.snapshot().averageReceiveToProcessMillis) + metrics.recordReceiveToProcess(2_000_000) + metrics.recordReceiveToProcess(8_000_000) + metrics.recordReceiveToProcess(0) + metrics.recordReceiveToProcess(-1) + metrics.recordProcessing(100_000) + val result = metrics.snapshot() + assertEquals(3L, result.receiveToProcessCount) + assertEquals(10.0 / 3, result.averageReceiveToProcessMillis, 0.000001) + assertEquals(0L, result.lastReceiveToProcessNanos) + assertEquals(8_000_000L, result.maxReceiveToProcessNanos) + assertEquals(100.0, result.averageProcessingMicros) + } +} diff --git a/Android/app/src/test/java/com/dct/app/tests/PacketPipelineReliabilityTest.kt b/Android/app/src/test/java/com/dct/app/tests/PacketPipelineReliabilityTest.kt new file mode 100644 index 0000000..f2b2ae7 --- /dev/null +++ b/Android/app/src/test/java/com/dct/app/tests/PacketPipelineReliabilityTest.kt @@ -0,0 +1,127 @@ +package com.dct.app.tests + +import com.dct.app.core.common.PacketDistributor +import com.dct.app.data.communication.OrderedPacketWriter +import com.dct.app.data.protocol.DctPacketCodec +import com.dct.app.data.protocol.SlidingRingBufferParser +import com.dct.app.domain.model.protocol.DctPacket +import kotlinx.coroutines.* +import kotlinx.coroutines.flow.collect +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.Test +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicInteger + +class PacketPipelineReliabilityTest { + @Test fun cancelledCallerDoesNotCloseSharedWriter() = runBlocking { + val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + val entered = CountDownLatch(1) + val gate = CountDownLatch(1) + val closed = AtomicInteger() + val writer = OrderedPacketWriter(scope, write = { + entered.countDown() + check(gate.await(3, TimeUnit.SECONDS)) + }, closeTransport = { closed.incrementAndGet(); gate.countDown() }) + try { + val caller = launch(start = CoroutineStart.UNDISPATCHED) { writer.send(byteArrayOf(1)) } + assertTrue(entered.await(2, TimeUnit.SECONDS)) + caller.cancelAndJoin() + assertEquals(0, closed.get()) + gate.countDown() + assertTrue(writer.send(byteArrayOf(2))) + } finally { writer.close(); scope.cancel() } + } + + @Test fun stalledSubscriberReceivesAll1001PacketsInOrder() = runBlocking { + val distributor = PacketDistributor() + val gate = CompletableDeferred() + val received = mutableListOf() + val done = CompletableDeferred() + val consumer = launch(start = CoroutineStart.UNDISPATCHED) { + distributor.packetStream.collect { + received.add(it.sequence) + if (it.sequence == 0) gate.await() + if (it.sequence == 1000) done.complete(Unit) + } + } + val producer = launch { + repeat(1001) { distributor.dispatchPacket(DctPacket(0xA3.toByte(), sequence = it)) } + } + yield() + assertFalse(producer.isCompleted, "A full buffer must suspend, not discard data") + gate.complete(Unit) + withTimeout(3000) { producer.join(); done.await() } + consumer.cancelAndJoin() + assertEquals((0..1000).toList(), received) + } + + @Test fun maximumWireFrameAndOversizedCoalescedReadAreLossless() { + val codec = DctPacketCodec() + val payload = ByteArray(65534) { (it % 256).toByte() } + val frame = codec.encode(DctPacket(0xA3.toByte(), payload)) + assertEquals(65540, frame.size) + val stream = frame + frame + frame + val parsed = SlidingRingBufferParser().parseStream(stream) + assertEquals(3, parsed.size) + parsed.forEach { assertArrayEquals(payload, it.payload) } + val fragmented = SlidingRingBufferParser() + assertTrue(fragmented.parseStream(frame.copyOfRange(0, 65535)).isEmpty()) + assertArrayEquals(payload, fragmented.parseStream(frame.copyOfRange(65535, frame.size)).single().payload) + assertThrows(IllegalArgumentException::class.java) { codec.encode(DctPacket(0xA3.toByte(), ByteArray(65535))) } + } + + @Test fun concurrentWritersNeverOverlapAndPreserveEnqueueOrder() = runBlocking { + val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + val entered = CountDownLatch(1) + val gate = CountDownLatch(1) + val values = mutableListOf() + val active = AtomicInteger() + val overlaps = AtomicInteger() + val writer = OrderedPacketWriter(scope, write = { + if (active.incrementAndGet() != 1) overlaps.incrementAndGet() + if (it[0] == 0.toByte()) { entered.countDown(); gate.await(2, TimeUnit.SECONDS) } + values.add(it[0].toInt()) + active.decrementAndGet() + }, closeTransport = { gate.countDown() }) + try { + val first = async(start = CoroutineStart.UNDISPATCHED) { writer.send(byteArrayOf(0)) } + assertTrue(entered.await(2, TimeUnit.SECONDS)) + val others = (1..20).map { async(start = CoroutineStart.UNDISPATCHED) { writer.send(byteArrayOf(it.toByte())) } } + gate.countDown() + assertTrue(first.await()) + assertTrue(others.awaitAll().all { it }) + assertEquals((0..20).toList(), values) + assertEquals(0, overlaps.get()) + } finally { writer.close(); scope.cancel() } + } + + @Test fun blockedWriteTimeoutDoesNotCloseTransport() = runBlocking { + val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + val gate = CountDownLatch(1) + val closed = AtomicInteger() + val writes = AtomicInteger() + val entered = CountDownLatch(1) + val writer = OrderedPacketWriter(scope, write = { + entered.countDown() + gate.await(2, TimeUnit.SECONDS) + writes.incrementAndGet() + }, closeTransport = { closed.incrementAndGet(); gate.countDown() }, timeoutMs = 100) + try { + withTimeout(2000) { + val first = async { writer.send(byteArrayOf(1)) } + withContext(Dispatchers.IO) { assertTrue(entered.await(1, TimeUnit.SECONDS)) } + val queued = async { writer.send(byteArrayOf(2)) } + assertFalse(first.await()) + assertFalse(queued.await()) + assertEquals(0, closed.get(), "A caller timeout must not close the socket") + gate.countDown() + delay(100) + assertTrue(writer.send(byteArrayOf(3)), "A fresh command must still be writable") + assertEquals(2, writes.get(), "The expired queued command must never reach the socket") + } + assertEquals(0, closed.get()) + } finally { writer.close(); scope.cancel() } + assertEquals(1, closed.get()) + } +} diff --git a/Android/app/src/test/java/com/dct/app/tests/PacketRepositoryResponseTest.kt b/Android/app/src/test/java/com/dct/app/tests/PacketRepositoryResponseTest.kt index 152c600..a077267 100644 --- a/Android/app/src/test/java/com/dct/app/tests/PacketRepositoryResponseTest.kt +++ b/Android/app/src/test/java/com/dct/app/tests/PacketRepositoryResponseTest.kt @@ -5,8 +5,10 @@ import com.dct.app.data.communication.WifiSocketManager import com.dct.app.data.protocol.DctProtocolRequests import com.dct.app.data.repository.PacketRepositoryImpl import com.dct.app.domain.model.protocol.* +import com.dct.app.domain.model.project.createDefaultSensorModules import com.dct.app.domain.repository.DeviceConnectionState import io.mockk.* +import kotlinx.coroutines.async import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.test.* @@ -14,22 +16,50 @@ import org.junit.jupiter.api.Assertions.* import org.junit.jupiter.api.Test class PacketRepositoryResponseTest { + @Test fun responseBudgetIncludesSlowSend() = runTest { + val f = Fixture(UnconfinedTestDispatcher(testScheduler)) + coEvery { f.wifi.sendCommand(any()) } coAnswers { kotlinx.coroutines.delay(1000); true } + val before = testScheduler.currentTime + assertTrue(f.repository.sendCommand(DctProtocolRequests.version()) is DctResult.Failure) + assertEquals(50, testScheduler.currentTime - before) + coVerify(exactly = 0) { f.wifi.disconnectSuspend() } + } + + @Test fun streamAcksValidateStructureAndNeverAcknowledgeOldSessions() = runTest { + val f = Fixture(UnconfinedTestDispatcher(testScheduler)) + every { f.wifi.currentSessionId } returns 7L + val sent = mutableListOf() + every { f.wifi.offerCommand(capture(sent)) } returns true + f.packets.emit(DctPacket(0xA4.toByte(), byteArrayOf(0), transportSessionId = 6)) + f.packets.emit(DctPacket(0xA4.toByte(), byteArrayOf(1), transportSessionId = 7)) // Truncated module + f.packets.emit(DctPacket(0xA3.toByte(), byteArrayOf(1, 0xA4.toByte(), 1), transportSessionId = 7)) // Unknown MDB schema still ACKs + f.packets.emit(DctPacket(0xD0.toByte(), byteArrayOf(0, 1), transportSessionId = 7)) // Missing XCP item + assertEquals(listOf(0x24, 0x23, 0x50), sent.map { it.command.toInt() }) + assertEquals(listOf(0, 1, 0), sent.map { it.payload.single().toInt() }) + assertTrue(sent.all { it.transportSessionId == 7L }) + } + private class Fixture(dispatcher: kotlinx.coroutines.CoroutineDispatcher) { val wifi = mockk(relaxed = true) val packets = MutableSharedFlow(extraBufferCapacity = 8) val connected = MutableStateFlow(true) var reject: Byte? = null var versionPayload = byteArrayOf(1, 65) + val sentPackets = mutableListOf() init { every { wifi.isConnected } returns connected every { wifi.packetStream } returns packets coEvery { wifi.disconnectSuspend() } coAnswers { connected.value = false } coEvery { wifi.sendCommand(any()) } coAnswers { val request = firstArg() + sentPackets += request + val subCmd = request.payload.firstOrNull() ?: 1 val payload = when (request.command.toInt()) { 1 -> versionPayload 3 -> ByteArray(6) - 9 -> byteArrayOf(6, if (reject == request.command) 2 else 1) + 8 -> byteArrayOf(subCmd, if (reject == request.command) 2 else 1) + 9 -> byteArrayOf(subCmd, if (reject == request.command) 2 else 1) + 12 -> byteArrayOf(subCmd, if (reject == request.command) 2 else 1) else -> byteArrayOf(if (reject == request.command) 2 else 1) } packets.emit(DctPacket(command = (request.command + 0x80).toByte(), payload = payload)) @@ -61,6 +91,23 @@ class PacketRepositoryResponseTest { assertTrue(f.repository.connect("") is DctResult.Success) assertEquals(DeviceConnectionState.READY, f.repository.connectionState.value) } + + @Test fun initialHandshakeUsesCompleteCanAndSensorPayloads() = runTest { + val f = Fixture(UnconfinedTestDispatcher(testScheduler)) + f.repository.setInitialMeasurementConfiguration( + DctMeasurementConfiguration( + isHighSpeedCan = true, + isOldModel = true, + sensorModules = createDefaultSensorModules() + ) + ) + + assertTrue(f.repository.connect("") is DctResult.Success) + val env = f.sentPackets.filter { it.command == 0x09.toByte() } + assertTrue(env.any { it.payload.contentEquals(byteArrayOf(0x06, 0x11)) }) + val sm = f.sentPackets.first { it.command == 0x12.toByte() } + assertTrue(sm.payload.contentEquals(byteArrayOf(0x01, 0x01, 0xFF.toByte(), 0xFF.toByte(), 0xFF.toByte(), 0xFF.toByte()))) + } @Test fun truncatedVersionStillStopsHandshakeBeforeHardwareId() = runTest { val f = Fixture(UnconfinedTestDispatcher(testScheduler)) f.versionPayload = byteArrayOf(15) + "DCT-MOBI-v1.0".toByteArray(Charsets.US_ASCII) @@ -71,18 +118,107 @@ class PacketRepositoryResponseTest { @Test fun configurationStopsAtNegativeAck() = runTest { val f = Fixture(UnconfinedTestDispatcher(testScheduler)) assertTrue(f.repository.connect("") is DctResult.Success) - f.reject = 9 - assertTrue(f.repository.configureMeasurement(DctMeasurementConfiguration()) is DctResult.Failure) + f.reject = 8 + val config = DctMeasurementConfiguration( + vehicleCode = "HE1", + projectName = "HE1ID", + mdbSignals = listOf(mockk(relaxed = true)) + ) + assertTrue(f.repository.configureMeasurement(config) is DctResult.Failure) coVerify(exactly = 0) { f.wifi.sendCommand(match { it.command == 0x10.toByte() }) } } - @Test fun timeoutClosesTransportSoLateAckCannotApproveNextWrite() = runTest { + @Test fun timeoutKeepsTransportAndBlocksAmbiguousRetryUntilLateAck() = runTest { val f = Fixture(UnconfinedTestDispatcher(testScheduler)) coEvery { f.wifi.sendCommand(any()) } returns true assertTrue(f.repository.sendCommand(DctProtocolRequests.constantWrite(0x1000, byteArrayOf(1))) is DctResult.Failure) - coVerify(exactly = 1) { f.wifi.disconnectSuspend() } - assertEquals(DeviceConnectionState.ERROR, f.repository.connectionState.value) - assertFalse(f.connected.value) + coVerify(exactly = 0) { f.wifi.disconnectSuspend() } + assertTrue(f.connected.value) + val command = DctProtocolRequests.constantWrite(0x1000, byteArrayOf(1)) + assertTrue(f.repository.sendCommand(command) is DctResult.Failure) + coVerify(exactly = 1) { f.wifi.sendCommand(any()) } + f.packets.emit(DctPacket((command.command + 0x80).toByte(), byteArrayOf(1))) + coEvery { f.wifi.sendCommand(any()) } coAnswers { + f.packets.emit(DctPacket((command.command + 0x80).toByte(), byteArrayOf(1))) + true + } + assertTrue(f.repository.sendCommand(command) is DctResult.Success) + } + @Test fun startTimeoutKeepsTransportAliveForRetry() = runTest { + val f = Fixture(UnconfinedTestDispatcher(testScheduler)) + assertTrue(f.repository.connect("") is DctResult.Success) + coEvery { f.wifi.sendCommand(match { it.command == DctCommand.CMD_START_TRANSMIT_DATA.code }) } returns true + + assertTrue(f.repository.startStreaming(30) is DctResult.Failure) + coVerify(exactly = 0) { f.wifi.disconnectSuspend() } + assertTrue(f.connected.value) + assertEquals(DeviceConnectionState.READY, f.repository.connectionState.value) + } + + @Test fun stopTimeoutEndsLocalMeasurementButKeepsTransportAlive() = runTest { + val f = Fixture(UnconfinedTestDispatcher(testScheduler)) + assertTrue(f.repository.connect("") is DctResult.Success) + coEvery { + f.wifi.sendCommand(match { it.command == DctCommand.CMD_START_TRANSMIT_DATA.code }) + } coAnswers { + f.packets.emit(DctPacket(DctCommand.RES_START_TRANSMIT_DATA.code, byteArrayOf(1, 1))) + true + } + assertTrue(f.repository.startStreaming(30) is DctResult.Success) + + // Simulate a peer that accepted the STOP write but whose A1 ACK is + // delayed/lost. This must not be treated as a Wi-Fi/TCP disconnect. + coEvery { + f.wifi.sendCommand(match { it.command == DctCommand.CMD_STOP_TRANSMIT_DATA.code }) + } returns true + + assertTrue(f.repository.stopStreaming() is DctResult.Success) + coVerify(exactly = 0) { f.wifi.disconnectSuspend() } + assertTrue(f.connected.value) + assertEquals(DeviceConnectionState.READY, f.repository.connectionState.value) + assertFalse(f.repository.isStreaming.value) + } + + @Test fun restartWaitsForLateStopAckBeforeSendingStart() = runTest { + val f = Fixture(UnconfinedTestDispatcher(testScheduler)) + every { f.wifi.currentSessionId } returns 7L + assertTrue(f.repository.connect("") is DctResult.Success) + + coEvery { + f.wifi.sendCommand(match { it.command == DctCommand.CMD_START_TRANSMIT_DATA.code }) + } coAnswers { + f.packets.emit(DctPacket( + DctCommand.RES_START_TRANSMIT_DATA.code, + byteArrayOf(1, 1), + transportSessionId = 7L + )) + true + } + assertTrue(f.repository.startStreaming(30) is DctResult.Success) + + coEvery { + f.wifi.sendCommand(match { it.command == DctCommand.CMD_STOP_TRANSMIT_DATA.code }) + } returns true + assertTrue(f.repository.stopStreaming() is DctResult.Success) + + val restart = async { f.repository.startStreaming(30) } + runCurrent() + coVerify(exactly = 1) { + f.wifi.sendCommand(match { it.command == DctCommand.CMD_START_TRANSMIT_DATA.code }) + } + + f.packets.emit(DctPacket( + DctCommand.RES_STOP_TRANSMIT_DATA.code, + byteArrayOf(1), + transportSessionId = 7L + )) + runCurrent() + + assertTrue(restart.await() is DctResult.Success) + coVerify(exactly = 2) { + f.wifi.sendCommand(match { it.command == DctCommand.CMD_START_TRANSMIT_DATA.code }) + } } + @Test fun disableDoesNotWaitForUndefined8fResponse() = runTest { val f = Fixture(UnconfinedTestDispatcher(testScheduler)) coEvery { f.wifi.sendCommand(any()) } returns true diff --git a/Android/app/src/test/java/com/dct/app/tests/PcDpcCompatibilityTest.kt b/Android/app/src/test/java/com/dct/app/tests/PcDpcCompatibilityTest.kt new file mode 100644 index 0000000..7c8740d --- /dev/null +++ b/Android/app/src/test/java/com/dct/app/tests/PcDpcCompatibilityTest.kt @@ -0,0 +1,158 @@ +package com.dct.app.tests + +import com.dct.app.data.repository.DpcProjectManager +import com.dct.app.domain.model.chart.AxisPosition +import com.dct.app.domain.model.mdb.MdbAttrType +import com.dct.app.domain.model.mdb.SignalDefinition +import com.dct.app.domain.model.project.DpcProject +import org.json.JSONObject +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertNotNull +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +class PcDpcCompatibilityTest { + @Test + fun pcGraphWindowsBecomeAndroidWorkspace() { + val json = JSONObject( + """ + { + "name":"PC project", + "graphItem_property": { + "graph_linear": false, + "items":[{"ITEMTYPE":0,"RECLID":7,"INDEX":3},{"ITEMTYPE":1,"INDEX":4}] + }, + "formula_property":{"items":[{"name":"Duty","expression":"1+2"}]}, + "graphWnd_property":{"graphWndInfo":[{"index":2,"legendpos":1,"titlename":"Engine", "items":[ + {"item_type":0,"item_reclID":7,"item_index":3,"item_name":"RPM","item_hideshow":false,"item_thickness":4,"item_dashstyle":2}, + {"item_type":1,"item_index":4}, + {"item_type":2,"item_smindex":0,"item_chindex":1}, + {"item_type":3,"item_index":0}, + {"item_type":4,"item_index":4096,"item_name":"Battery"} + ]}]}, + "multiXgraphWnd_property":{"multiXgraphWndInfo":[{"index":5,"legendpos":2,"titlename":"Multi", "items":[ + {"item_type":0,"item_reclID":7,"item_index":3} + ]}]} + } + """.trimIndent() + ) + + val project = DpcProjectManager.parseProjectFromJson(json.toString(), null) + val workspace = project.workspace + assertNotNull(workspace) + assertEquals("pc_page_1", workspace!!.activePageId) + assertEquals(2, workspace.pages.first().windows.size) + + val graph = workspace.pages.first().windows.first() + assertEquals("GRAPH", graph.type) + assertEquals("Engine", graph.title) + assertEquals( + listOf("sig_7_3", "can_4", "sm_01_02", "formula_Duty", "a2l_4096"), + graph.signals.map { it.signalId } + ) + assertFalse(graph.signals.first().isVisible) + assertEquals(4f, graph.signals.first().lineWidthDp) + assertEquals(AxisPosition.LEFT_0, graph.signals.first().axisPosition) + assertEquals(AxisPosition.LEFT_0, graph.signals[1].axisPosition) + assertEquals(AxisPosition.LEFT_0, graph.signals[4].axisPosition) + assertEquals(AxisPosition.LEFT_0, workspace.pages.first().windows[1].signals.first().axisPosition) + assertEquals(false, project.graphLinear) + } + + @Test + fun pcTileWindowMapsToAlphanumericalAndRoundTrips() { + val json = JSONObject( + """ + { + "name":"PC tiles", + "tileWnd_property":{"tileWndInfo":[{"index":3,"istilemode":false,"titlename":"Live values", "items":[ + {"item_type":0,"item_reclID":7,"item_index":3,"item_name":"RPM","item_hideshow":false,"item_tilecolor":16711935}, + {"item_type":1,"item_index":4,"item_tilecolor":255} + ]}]} + } + """.trimIndent() + ) + val parsed = DpcProjectManager.parseProjectFromJson(json.toString(), null) + val tile = parsed.workspace!!.pages.single().windows.single() + assertEquals("ALPHANUMERICAL", tile.type) + assertEquals(listOf("sig_7_3", "can_4"), tile.targetSignals) + assertEquals(setOf("sig_7_3"), tile.hiddenSignalIds) + assertEquals(false, tile.isTileMode) + + val saved = JSONObject(DpcProjectManager.serializeProjectToJson(parsed)) + .getJSONObject("tileWnd_property").getJSONArray("tileWndInfo").getJSONObject(0) + assertEquals("Live values", saved.getString("titlename")) + assertEquals(false, saved.getBoolean("istilemode")) + assertEquals(2, saved.getJSONArray("items").length()) + assertEquals(0, saved.getJSONArray("items").getJSONObject(0).getInt("item_type")) + assertEquals(7, saved.getJSONArray("items").getJSONObject(0).getInt("item_reclID")) + } + + @Test + fun androidWorkspaceIsExportedUsingPcGraphKeys() { + val signal = SignalDefinition( + reclid = 7, idx = 3, name = "RPM", attr = MdbAttrType.ATTR_2_UINT16, + cvsA = 1.0, cvsB = 0.0, unit = "rpm", color = 0x00112233L, + min = 0.0, max = 8000.0 + ) + val source = JSONObject( + """ + {"name":"PC", "graphWnd_property":{"vendor":true}, + "multiXgraphWnd_property":{"vendor":true}, + "graphItem_property":{"graph_linear":true,"vendor":7}} + """.trimIndent() + ) + val project = DpcProjectManager.parseProjectFromJson(source.toString(), null).copy( + graphLinear = true, + mdbDatabase = com.dct.app.domain.model.mdb.MdbDatabase( + groups = listOf(com.dct.app.domain.model.mdb.SignalGroup(7, "7", "", true, signals = listOf(signal))) + ) + ) + val workspace = com.dct.app.domain.model.project.DpcWorkspaceState( + activePageId = "page", + pages = listOf( + com.dct.app.domain.model.project.DpcPageState( + id = "page", title = "Page", + windows = listOf( + com.dct.app.domain.model.project.DpcWindowState( + id = "pc_graph_2", type = "GRAPH", title = "Engine", + signals = listOf( + com.dct.app.domain.model.project.DpcSignalBindingState( + "sig_7_3", "RPM", AxisPosition.LEFT_0, 0x00112233L, + unit = "rpm", isVisible = false, lineWidthDp = 4f, dashStyle = 2 + ) + ) + ), + com.dct.app.domain.model.project.DpcWindowState( + id = "pc_multi_5", type = "MULTI_AXIS_GRAPH", title = "Multi" + ) + ) + ) + ) + ) + + val serialized = JSONObject( + DpcProjectManager.serializeProjectToJson(project.copy(workspace = workspace)) + ) + val graph = serialized.getJSONObject("graphWnd_property") + .getJSONArray("graphWndInfo").getJSONObject(0) + assertEquals(2, graph.getInt("index")) + assertEquals("Engine", graph.getString("titlename")) + val item = graph.getJSONArray("items").getJSONObject(0) + assertEquals(0, item.getInt("item_type")) + assertEquals(7, item.getInt("item_reclID")) + assertEquals(3, item.getInt("item_index")) + assertFalse(item.getBoolean("item_hideshow")) + assertEquals(4, item.getInt("item_thickness")) + assertEquals(2, item.getInt("item_dashstyle")) + assertEquals(1, serialized.getJSONObject("multiXgraphWnd_property") + .getJSONArray("multiXgraphWndInfo").length()) + val global = serialized.getJSONObject("graphItem_property").getJSONArray("items") + assertEquals(1, global.length()) + assertEquals(7, global.getJSONObject(0).getInt("RECLID")) + assertEquals(3, global.getJSONObject(0).getInt("INDEX")) + assertTrue(serialized.has("android_workspace")) + assertTrue(serialized.getJSONObject("graphWnd_property").getBoolean("vendor")) + } +} 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 new file mode 100644 index 0000000..d7df872 --- /dev/null +++ b/Android/app/src/test/java/com/dct/app/tests/PcOperationalLoggerTest.kt @@ -0,0 +1,161 @@ +package com.dct.app.tests + +import com.dct.app.data.communication.WifiSocketManager +import com.dct.app.data.logger.PcOperationalLogger +import com.dct.app.data.protocol.DctPacketCodec +import com.dct.app.domain.model.protocol.DctPacket +import kotlinx.coroutines.* +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.* +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.io.TempDir +import java.io.File +import java.net.ServerSocket +import java.text.SimpleDateFormat +import java.util.Locale + +@OptIn(ExperimentalCoroutinesApi::class) +class PcOperationalLoggerTest { + @TempDir lateinit var folder: File + private val time = SimpleDateFormat("yyyy-MM-dd HH:mm:ss.SSS", Locale.US) + .parse("2026-10-01 12:34:56.789")!!.time + private val codec = DctPacketCodec() + private val packet = DctPacket(0xA3.toByte(), byteArrayOf(0, 1, 0xFF.toByte())) + private fun files(extension: String) = folder.listFiles()!!.filter { it.extension == extension } + private fun decodeLine(line: String): ByteArray = line.substringAfter("] ") + .trim().split(' ').map { it.toInt(16).toByte() }.toByteArray() + + @Test fun actionCsvHasPcHeadersEscapingAndDrainsOnClose() = runTest { + val logger = PcOperationalLogger({ folder }, StandardTestDispatcher(testScheduler), { time }) + logger.action("Open project file A,\"B\".dpc\nOK") + logger.close() + logger.action("must not be written") + logger.awaitClosed() + val file = files("csv").single() + assertEquals("2026-10-01_12-34-56_log.csv", file.name) + val csv = file.readText() + assertTrue(csv.startsWith("File: ${file.name}\r\nDate: 2026-10-01\r\n\r\n Time, Description,\r\n")) + assertTrue(csv.contains("12:34:56, Start DCT Program,")) + assertTrue(csv.contains("\"Open project file A,\"\"B\"\".dpc OK\"")) + assertTrue(csv.contains("Terminate DCT Program")) + assertFalse(csv.contains("must not")) + } + + @Test fun fullFramesAndIngressMillisecondsSurviveCallerMutation() = runTest { + val logger = PcOperationalLogger({ folder }, StandardTestDispatcher(testScheduler), { time }) + val bytes = codec.encode(packet) + logger.connected(1, "PC") + logger.frame(1, "Recv", bytes, time + 10) + logger.frame(1, "Send", bytes, time + 11) + bytes.fill(0) + logger.disconnected(1) + logger.close(); logger.awaitClosed() + val file = files("txt").single() + assertEquals("20261001_123456_log.txt", file.name) + val lines = file.readLines() + assertTrue(lines[0].startsWith("[Recv][12:34:56.799] A1 ")) + assertTrue(lines[1].startsWith("[Send][12:34:56.800] A1 ")) + lines.forEach { assertEquals(packet, codec.decode(decodeLine(it))) } + } + + @Test fun noPacketCreatesNoTxtAndSameSecondConnectionsNeverOverwrite() = runTest { + val logger = PcOperationalLogger({ folder }, StandardTestDispatcher(testScheduler), { time }) + logger.connected(1, "empty"); logger.disconnected(1) + logger.connected(2, "first"); logger.frame(2, "Recv", codec.encode(packet)) + logger.disconnected(2) + logger.connected(3, "second") + logger.frame(2, "Recv", codec.encode(packet)) // Stale writer after reconnect. + logger.disconnected(2) // Must not close connection 3. + logger.frame(3, "Send", codec.encode(packet)) + logger.close(); logger.awaitClosed() + assertEquals(2, files("txt").size) + assertEquals(setOf("20261001_123456_log.txt", "20261001_123456_log-01.txt"), files("txt").map { it.name }.toSet()) + assertEquals(2, files("txt").sumOf { it.readLines().size }) + } + + @Test fun sameSecondAppStartsPreservePreviousCsv() = runTest { + repeat(2) { + val logger = PcOperationalLogger({ folder }, StandardTestDispatcher(testScheduler), { time }) + logger.action("run $it"); logger.close(); logger.awaitClosed() + } + assertEquals(2, files("csv").size) + assertTrue(files("csv").any { it.readText().contains("run 0") }) + assertTrue(files("csv").any { it.readText().contains("run 1") }) + } + + @Test fun liveRawLogFlushesWithoutStoppingMeasurement() = runTest { + val logger = PcOperationalLogger({ folder }, StandardTestDispatcher(testScheduler), { time }) + logger.connected(1, "PC"); logger.frame(1, "Recv", codec.encode(packet)) + runCurrent() + advanceTimeBy(1001); runCurrent() + assertTrue(files("txt").single().readText().contains("[Recv][12:34:56.789]")) + assertFalse(files("csv").single().readText().contains("Terminate")) + logger.close(); logger.awaitClosed() + } + + @Test fun boundedQueueExplicitlyMarksIncompleteAndResumesOnNextConnection() = runTest { + val errors = mutableListOf() + val bytes = codec.encode(packet) + val logger = PcOperationalLogger({ folder }, StandardTestDispatcher(testScheduler), { time }, errors::add, bytes.size) + logger.connected(1, "PC") + logger.frame(1, "Recv", bytes) + logger.frame(1, "Recv", bytes) // Disk worker has not run; capacity exhausted. + logger.frame(1, "Recv", bytes) + runCurrent() + logger.disconnected(1); logger.connected(2, "PC") + logger.frame(2, "Recv", bytes) + logger.close(); logger.awaitClosed() + assertEquals(1, errors.size) + assertTrue(files("csv").single().readText().contains("Communication log incomplete")) + assertTrue(files("txt").any { it.readText().contains("# Communication log incomplete") }) + assertEquals(2, files("txt").sumOf { it.readLines().count { line -> line.startsWith("[Recv]") } }) + } + + @Test fun storageFailureIsReportedWithoutThrowingIntoCommunication() = runTest { + val errors = mutableListOf() + val notDirectory = File(folder, "not-a-folder").apply { writeText("preserve") } + val logger = PcOperationalLogger({ notDirectory }, StandardTestDispatcher(testScheduler), { time }, errors::add) + logger.connected(1, "PC"); logger.frame(1, "Recv", codec.encode(packet)) + logger.frame(1, "Send", codec.encode(packet)) + logger.close(); logger.awaitClosed() + assertEquals(2, errors.size) // Action creation and first raw creation; no repeated raw attempts. + assertEquals("preserve", notDirectory.readText()) + } + + @Test fun socketLogsActualTxAndValidatedFragmentedRxBeforeBusinessFiltering() = runBlocking { + val logger = PcOperationalLogger({ folder }) + val socketManager = WifiSocketManager(operationalLogger = logger) + ServerSocket(0).use { server -> + try { + assertFalse(socketManager.sendCommand(packet)) // No socket: no fictional TX entry. + val connected = CompletableDeferred() + socketManager.connect("127.0.0.1", server.localPort, + onSuccess = { connected.complete(Unit) }, + onFailure = { connected.completeExceptionally(IllegalStateException(it)) }) + withContext(Dispatchers.IO) { server.accept() }.use { peer -> + peer.soTimeout = 3000 + withTimeout(5000) { connected.await() } + val received = async(start = CoroutineStart.UNDISPATCHED) { socketManager.packetStream.first() } + val frame = codec.encode(packet) + val corrupt = frame.copyOf().apply { this[lastIndex - 1] = 0 } + peer.getOutputStream().write(corrupt + frame.copyOfRange(0, 3)) + peer.getOutputStream().write(frame.copyOfRange(3, frame.size)) + assertEquals(packet, withTimeout(5000) { received.await() }) + assertTrue(socketManager.sendCommand(packet)) + assertArrayEquals(frame, withContext(Dispatchers.IO) { peer.getInputStream().readNBytes(frame.size) }) + socketManager.invalidateConnection() + } + } finally { + socketManager.close(); logger.close(); withTimeout(5000) { logger.awaitClosed() } + } + } + val lines = files("txt").single().readLines() + assertEquals(1, lines.count { it.startsWith("[Recv]") }) + assertEquals(1, lines.count { it.startsWith("[Send]") }) + lines.forEach { assertEquals(packet, codec.decode(decodeLine(it))) } + val csv = files("csv").single().readText() + assertTrue(csv.contains("Connected 127.0.0.1")) + assertTrue(csv.contains("Disconnected")) + } +} diff --git a/Android/app/src/test/java/com/dct/app/tests/SignalColorPresetsTest.kt b/Android/app/src/test/java/com/dct/app/tests/SignalColorPresetsTest.kt new file mode 100644 index 0000000..6693703 --- /dev/null +++ b/Android/app/src/test/java/com/dct/app/tests/SignalColorPresetsTest.kt @@ -0,0 +1,19 @@ +package com.dct.app.tests + +import com.dct.app.core.theme.PcColorRef +import com.dct.app.core.theme.SignalColorPresets +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.Test + +class SignalColorPresetsTest { + @Test fun presetsAreDistinctCanonicalPcColorsAndKeepDefaultBlue() { + val colors = SignalColorPresets.colors + assertEquals(24, colors.size) + assertEquals(colors.size, colors.distinct().size) + assertEquals(PcColorRef.DEFAULT_SIGNAL, colors.first()) + colors.forEach { color -> + assertTrue(color in 0L..0xFFFFFFL) + assertEquals(color, PcColorRef.normalize(color)) + } + } +} diff --git a/Android/app/src/test/java/com/dct/app/tests/SlidingRingBufferParserTest.kt b/Android/app/src/test/java/com/dct/app/tests/SlidingRingBufferParserTest.kt index 5076cdf..882262a 100644 --- a/Android/app/src/test/java/com/dct/app/tests/SlidingRingBufferParserTest.kt +++ b/Android/app/src/test/java/com/dct/app/tests/SlidingRingBufferParserTest.kt @@ -1,4 +1,4 @@ -package com.dct.app.tests +package com.dct.app.tests import com.google.common.truth.Truth.assertThat import com.dct.app.data.protocol.DctPacketCodec @@ -144,22 +144,17 @@ class SlidingRingBufferParserTest { } @Test - @DisplayName("pollPacket 및 reset 메서드 동작 검증") - fun testPollPacketAndReset() { + @DisplayName("reset 메서드 동작 및 내부 상태 초기화 검증") + fun testResetAndStateCleanup() { val packet = DctPacket.create(DctCommand.CMD_CUR_STATUS) - parser.parseStream(codec.encode(packet)) + val result = parser.parseStream(codec.encode(packet)) - assertThat(parser.queuedPacketCount).isEqualTo(1) - val polled = parser.pollPacket() - assertThat(polled).isNotNull() - assertThat(polled?.command).isEqualTo(DctCommand.CMD_CUR_STATUS.code) - assertThat(parser.queuedPacketCount).isEqualTo(0) + assertThat(result).hasSize(1) + assertThat(result[0].command).isEqualTo(DctCommand.CMD_CUR_STATUS.code) // Reset 검증 parser.parseStream(codec.encode(packet)) - assertThat(parser.queuedPacketCount).isEqualTo(1) parser.reset() - assertThat(parser.queuedPacketCount).isEqualTo(0) assertThat(parser.availableByteCount).isEqualTo(0) } @@ -184,6 +179,9 @@ class SlidingRingBufferParserTest { @Test @DisplayName("연속된 다중 가짜 0xA1 노이즈 유입 시 완벽한 백트래킹 순환 및 정상 패킷 복구") fun testConsecutiveFalseA1HeadersBacktracking() { + // A1 A1 length is only invalid with this bounded-frame policy. With the + // full wire range it can be an incomplete 41 KB frame and must be retained. + parser = SlidingRingBufferParser(maxPacketSize = 8192) val stream = ByteArrayOutputStream() // 가짜 0xA1 바이트 5개 연속 stream.write(byteArrayOf(0xA1.toByte(), 0xA1.toByte(), 0xA1.toByte(), 0xA1.toByte(), 0xA1.toByte())) @@ -200,6 +198,7 @@ class SlidingRingBufferParserTest { @Test @DisplayName("비정상적인 packLen(0 또는 초과 크기) 가짜 헤더 유입 시 즉시 1바이트 백트래킹") fun testFalseHeaderLengthOutOfBoundsBacktracking() { + parser = SlidingRingBufferParser(maxPacketSize = 8192) // packLen = 0x0000 (최소 1 이상이어야 함) val invalidLenFrame1 = byteArrayOf(0xA1.toByte(), 0x00, 0x00, 0x00, 0x7E) // packLen = 0xFFFF (maxPacketSize 초과) diff --git a/Android/app/src/test/java/com/dct/app/tests/SlimScrollbarMetricsTest.kt b/Android/app/src/test/java/com/dct/app/tests/SlimScrollbarMetricsTest.kt new file mode 100644 index 0000000..e97bd6c --- /dev/null +++ b/Android/app/src/test/java/com/dct/app/tests/SlimScrollbarMetricsTest.kt @@ -0,0 +1,27 @@ +package com.dct.app.tests + +import com.dct.app.presentation.signaltree.slimScrollbarMetrics +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class SlimScrollbarMetricsTest { + @Test fun thumbTracksBeginningMiddleAndEnd() { + val start = slimScrollbarMetrics(100, 20f, 200f, 200f, 0f, 20f)!! + val middle = slimScrollbarMetrics(100, 20f, 200f, 200f, 900f, 20f)!! + val end = slimScrollbarMetrics(100, 20f, 200f, 200f, 1800f, 20f)!! + + assertEquals(20f, start.thumbHeightPx, 0.001f) + assertEquals(0f, start.thumbTopPx, 0.001f) + assertEquals(90f, middle.thumbTopPx, 0.001f) + assertEquals(180f, end.thumbTopPx, 0.001f) + assertEquals(1800f, end.maxScrollPx, 0.001f) + } + + @Test fun noScrollbarWhenContentFitsAndThumbNeverExceedsTrack() { + assertNull(slimScrollbarMetrics(5, 20f, 200f, 200f, 0f, 20f)) + val shortTrack = slimScrollbarMetrics(100, 20f, 200f, 12f, 1800f, 20f)!! + assertEquals(12f, shortTrack.thumbHeightPx, 0.001f) + assertEquals(0f, shortTrack.travelPx, 0.001f) + } +} diff --git a/Android/app/src/test/java/com/dct/app/tests/TimeSeriesRingBufferTest.kt b/Android/app/src/test/java/com/dct/app/tests/TimeSeriesRingBufferTest.kt index 1b66b2b..4fa905a 100644 --- a/Android/app/src/test/java/com/dct/app/tests/TimeSeriesRingBufferTest.kt +++ b/Android/app/src/test/java/com/dct/app/tests/TimeSeriesRingBufferTest.kt @@ -139,4 +139,33 @@ class TimeSeriesRingBufferTest { assertEquals(100.0, buffer.interpolateAt(5.0)!!, 1e-6) assertEquals(400.0, buffer.interpolateAt(40.0)!!, 1e-6) } + + @Test + @DisplayName("통신 단절 마커는 NaN으로 저장되어 다음 샘플과 선을 연결하지 않아야 한다") + fun testDataGapMarker() { + val buffer = TimeSeriesRingBuffer(10) + buffer.addSample(1.0, 10.0) + buffer.addGap(2.0) + buffer.addSample(3.0, 30.0) + + assertEquals(3, buffer.size) + assertTrue(buffer.getValueAt(1).isNaN()) + assertEquals(listOf(1.0, 2.0, 3.0), buffer.toList().map { it.first }) + } + + @Test + @DisplayName("동일 TCP read에서 들어온 ingress 프레임은 1ms 미만이어도 덮어쓰지 않아야 한다") + fun forceAppendPreservesSubMillisecondIngressFrames() { + val buffer = TimeSeriesRingBuffer(8) + buffer.addSample(10.000000, 1.0, forceAppend = true) + buffer.addSample(10.000100, 2.0, forceAppend = true) + buffer.addSample(10.000100, 3.0, forceAppend = true) + + assertEquals(3, buffer.size) + assertEquals(1.0, buffer.getValueAt(0), 1e-9) + assertEquals(2.0, buffer.getValueAt(1), 1e-9) + assertEquals(3.0, buffer.getValueAt(2), 1e-9) + assertTrue(buffer.getTimestampAt(1) > buffer.getTimestampAt(0)) + assertTrue(buffer.getTimestampAt(2) > buffer.getTimestampAt(1)) + } } diff --git a/Android/app/src/test/java/com/dct/app/tests/TwoFingerFlingTest.kt b/Android/app/src/test/java/com/dct/app/tests/TwoFingerFlingTest.kt index e6d6cec..4323c23 100644 --- a/Android/app/src/test/java/com/dct/app/tests/TwoFingerFlingTest.kt +++ b/Android/app/src/test/java/com/dct/app/tests/TwoFingerFlingTest.kt @@ -51,7 +51,15 @@ class TwoFingerFlingTest { @Test fun holdingRemainingFingerSuppressesDelayedFling() { val fling = moving() fling.release(90, true) - assertEquals(Velocity.Zero, fling.finish(200)) + // Discard velocity when remaining finger stays down past the 250ms hand-off grace window. + assertEquals(Velocity.Zero, fling.finish(360)) + } + @Test fun sequentialFingerReleaseWithinGraceKeepsTwoFingerFling() { + val fling = moving() + fling.release(90, true) + val velocity = fling.finish(200) + assertEquals(1000f, velocity.x, 1f) + assertEquals(500f, velocity.y, 1f) } @Test fun remainingFingerCannotOverwriteCentroidVelocity() { val fling = moving() diff --git a/Android/app/src/test/java/com/dct/app/tests/TwoFingerGestureTest.kt b/Android/app/src/test/java/com/dct/app/tests/TwoFingerGestureTest.kt index 681b073..1ce3aeb 100644 --- a/Android/app/src/test/java/com/dct/app/tests/TwoFingerGestureTest.kt +++ b/Android/app/src/test/java/com/dct/app/tests/TwoFingerGestureTest.kt @@ -2,6 +2,8 @@ package com.dct.app.tests import com.dct.app.presentation.monitor.components.TwoFingerGesture import com.dct.app.presentation.monitor.components.WorkspaceTouchNavigation +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Rect import org.junit.jupiter.api.Assertions.* import org.junit.jupiter.api.Test @@ -76,4 +78,19 @@ class TwoFingerGestureTest { assertFalse(a.ownsTouches) assertNull(a.zoomAtRoot) } + + @Test fun windowTouchReservationSurvivesGestureResetUntilWindowDisposes() { + val navigation = WorkspaceTouchNavigation() + navigation.registerWindowTouchRegion("window") { Rect(10f, 20f, 110f, 120f) } + + assertTrue(navigation.isWindowTouchReserved(Offset(50f, 60f))) + navigation.reset() + assertTrue( + navigation.isWindowTouchReserved(Offset(50f, 60f)), + "Window reservations are persistent, unlike per-gesture claims" + ) + + navigation.unregisterWindowTouchRegion("window") + assertFalse(navigation.isWindowTouchReserved(Offset(50f, 60f))) + } } diff --git a/Android/app/src/test/java/com/dct/app/tests/WifiDirectManagerStateTest.kt b/Android/app/src/test/java/com/dct/app/tests/WifiDirectManagerStateTest.kt new file mode 100644 index 0000000..cf30919 --- /dev/null +++ b/Android/app/src/test/java/com/dct/app/tests/WifiDirectManagerStateTest.kt @@ -0,0 +1,229 @@ +package com.dct.app.tests + +import android.content.Context +import android.content.pm.PackageManager +import android.location.LocationManager +import android.net.wifi.WifiManager +import android.net.wifi.p2p.* +import com.dct.app.data.communication.* +import io.mockk.* +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.test.* +import org.junit.jupiter.api.* +import org.junit.jupiter.api.Assertions.* +import java.net.InetAddress + +/** Deliberately reorder OS callbacks: API acceptance is not a completed link transition. */ +class WifiDirectManagerStateTest { + private val dispatcher = StandardTestDispatcher() + private lateinit var controller: WifiDirectManager + private lateinit var api: WifiP2pManager + private val infoRequests = mutableListOf() + private val groupRequests = mutableListOf() + private val peerRequests = mutableListOf() + private val discoveryRequests = mutableListOf() + private val stopDiscoveryRequests = mutableListOf() + private val connectRequests = mutableListOf() + private val cancelRequests = mutableListOf() + private val removeRequests = mutableListOf() + + @BeforeEach fun setup() { + Dispatchers.setMain(dispatcher) + val context = mockk(relaxed = true) + val pm = mockk(relaxed = true) + val wifi = mockk(relaxed = true) + val location = mockk(relaxed = true) + api = mockk(relaxed = true) + every { context.applicationContext } returns context + every { context.packageManager } returns pm + every { pm.hasSystemFeature(PackageManager.FEATURE_WIFI_DIRECT) } returns true + every { context.checkPermission(any(), any(), any()) } returns PackageManager.PERMISSION_GRANTED + every { context.getSystemService(WifiP2pManager::class.java) } returns api + every { context.getSystemService(android.net.ConnectivityManager::class.java) } returns null + every { context.getSystemService(WifiManager::class.java) } returns wifi + every { context.getSystemService(LocationManager::class.java) } returns location + every { wifi.isWifiEnabled } returns true + every { location.isProviderEnabled(any()) } returns true + every { api.initialize(any(), any(), any()) } returns mockk(relaxed = true) + every { api.requestConnectionInfo(any(), capture(infoRequests)) } just Runs + every { api.requestGroupInfo(any(), capture(groupRequests)) } just Runs + every { api.requestPeers(any(), capture(peerRequests)) } just Runs + every { api.discoverPeers(any(), capture(discoveryRequests)) } just Runs + every { api.stopPeerDiscovery(any(), capture(stopDiscoveryRequests)) } just Runs + every { api.connect(any(), any(), capture(connectRequests)) } just Runs + every { api.cancelConnect(any(), capture(cancelRequests)) } just Runs + every { api.removeGroup(any(), capture(removeRequests)) } just Runs + controller = WifiDirectManager(context) + controller.start() + dispatcher.scheduler.runCurrent() + respond(false) + } + + @AfterEach fun teardown() { + if (::controller.isInitialized) controller.close() + Dispatchers.resetMain() + unmockkAll() + } + + private fun info(formed: Boolean) = WifiP2pInfo().apply { + groupFormed = formed + isGroupOwner = false + groupOwnerAddress = if (formed) InetAddress.getByName("192.168.137.1") else null + } + private fun group() = mockk(relaxed = true).also { + every { it.`interface` } returns "p2p-test" + } + private fun respond(formed: Boolean) { + infoRequests.last().onConnectionInfoAvailable(info(formed)) + groupRequests.last().onGroupInfoAvailable(if (formed) group() else null) + } + private fun device() = WifiP2pDevice().apply { deviceName = "PC"; deviceAddress = "02:00:00:00:00:01"; status = WifiP2pDevice.AVAILABLE } + + @Test fun discoveryAcceptanceDoesNotInventPeersOrCompleteDiscovery() { + controller.discoverPeers(); dispatcher.scheduler.runCurrent() + discoveryRequests.last().onSuccess() + assertTrue(controller.uiState.value.discovering) + assertTrue(controller.uiState.value.peers.isEmpty()) + controller.discoverPeers(); dispatcher.scheduler.runCurrent() + assertEquals(1, discoveryRequests.size) + } + + @Test fun discoveryHasBoundedStopEvenWithoutBroadcasts() { + controller.discoverPeers(); dispatcher.scheduler.runCurrent() + dispatcher.scheduler.advanceTimeBy(20_001); dispatcher.scheduler.runCurrent() + verify(exactly = 1) { api.stopPeerDiscovery(any(), any()) } + } + + @Test fun connectAcceptanceWaitsForGroupInfo() { + controller.connect(device()); dispatcher.scheduler.runCurrent() + respond(false) + connectRequests.last().onSuccess() + assertEquals(WifiDirectPhase.CONNECTING, controller.uiState.value.phase) + assertNull(controller.uiState.value.link) + respond(true) + assertEquals(WifiDirectPhase.CONNECTED, controller.uiState.value.phase) + assertEquals("192.168.137.1", controller.uiState.value.link!!.info.groupOwnerAddress.hostAddress) + } + + @Test fun dialogDiscoveryCleanupDoesNotInterruptConnectionWatchdog() { + controller.connect(device()); dispatcher.scheduler.runCurrent(); respond(false) + controller.stopPeerDiscovery(); dispatcher.scheduler.runCurrent() + assertTrue(stopDiscoveryRequests.isEmpty()) + assertEquals(WifiDirectPhase.CONNECTING, controller.uiState.value.phase) + // Keep OS snapshots current throughout negotiation, as the real framework does. + repeat(46) { + dispatcher.scheduler.advanceTimeBy(1000); dispatcher.scheduler.runCurrent() + respond(false) + } + assertEquals(1, cancelRequests.size) + assertEquals(WifiDirectPhase.CANCELLING, controller.uiState.value.phase) + verify(exactly = 0) { api.removeGroup(any(), any()) } + } + + @Test fun staleDiscoveryFailureCannotReplaceNewConnectionBeforeGroupQueryCompletes() { + controller.discoverPeers(); dispatcher.scheduler.runCurrent() + controller.stopPeerDiscovery(); dispatcher.scheduler.runCurrent() + val oldStop = stopDiscoveryRequests.last() + controller.connect(device()); dispatcher.scheduler.runCurrent() + oldStop.onFailure(WifiP2pManager.BUSY) + discoveryRequests.last().onFailure(WifiP2pManager.ERROR) + assertEquals(WifiDirectPhase.CONNECTING, controller.uiState.value.phase) + assertNull(controller.uiState.value.error) + respond(false) + connectRequests.last().onSuccess(); respond(true) + assertEquals(WifiDirectPhase.CONNECTED, controller.uiState.value.phase) + } + + @Test fun stopDiscoveryBusyKeepsConfirmedLinkAndDoesNotDisconnect() { + controller.refresh(); dispatcher.scheduler.runCurrent(); respond(true) + val link = controller.uiState.value.link + controller.stopPeerDiscovery(); dispatcher.scheduler.runCurrent() + stopDiscoveryRequests.last().onFailure(WifiP2pManager.BUSY) + assertEquals(WifiDirectPhase.CONNECTED, controller.uiState.value.phase) + assertSame(link, controller.uiState.value.link) + assertNull(controller.uiState.value.error) + verify(exactly = 0) { api.removeGroup(any(), any()) } + } + + @Test fun stopDiscoveryTimeoutKeepsLinkAndIgnoresLateFailure() { + controller.discoverPeers(); dispatcher.scheduler.runCurrent() + controller.refresh(); dispatcher.scheduler.runCurrent(); respond(true) + controller.stopPeerDiscovery(); dispatcher.scheduler.runCurrent() + dispatcher.scheduler.advanceTimeBy(2001); dispatcher.scheduler.runCurrent() + assertFalse(controller.uiState.value.discovering) + assertEquals(WifiDirectPhase.CONNECTED, controller.uiState.value.phase) + stopDiscoveryRequests.last().onFailure(WifiP2pManager.ERROR) + assertEquals(WifiDirectPhase.CONNECTED, controller.uiState.value.phase) + assertNull(controller.uiState.value.error) + } + + @Test fun discoveryBusyAllowsExplicitRetryWithoutRetryLoop() { + controller.discoverPeers(); dispatcher.scheduler.runCurrent() + discoveryRequests.last().onFailure(WifiP2pManager.BUSY) + assertEquals(WifiDirectPhase.IDLE, controller.uiState.value.phase) + assertFalse(controller.uiState.value.discovering) + assertTrue(controller.uiState.value.error!!.contains("다시 시도")) + dispatcher.scheduler.advanceTimeBy(20001); dispatcher.scheduler.runCurrent() + assertEquals(1, discoveryRequests.size) + controller.discoverPeers(); dispatcher.scheduler.runCurrent() + assertEquals(2, discoveryRequests.size) + } + + @Test fun discoveryCleanupPreservesCancellationAndRemovalUntilOsConfirmation() { + controller.connect(device()); dispatcher.scheduler.runCurrent(); respond(false) + controller.cancelConnect(); dispatcher.scheduler.runCurrent() + controller.stopPeerDiscovery(); dispatcher.scheduler.runCurrent() + assertEquals(WifiDirectPhase.CANCELLING, controller.uiState.value.phase) + assertTrue(stopDiscoveryRequests.isEmpty()) + cancelRequests.last().onSuccess(); dispatcher.scheduler.runCurrent(); respond(true) + controller.disconnect(); dispatcher.scheduler.runCurrent() + controller.stopPeerDiscovery(); dispatcher.scheduler.runCurrent() + assertEquals(WifiDirectPhase.DISCONNECTING, controller.uiState.value.phase) + assertTrue(stopDiscoveryRequests.isEmpty()) + removeRequests.last().onSuccess(); respond(false) + assertEquals(WifiDirectPhase.IDLE, controller.uiState.value.phase) + } + + @Test fun cancellationNeverRemovesAGroupAndLateConnectAckIsIgnored() { + controller.connect(device()); dispatcher.scheduler.runCurrent(); respond(false) + val connectAck = connectRequests.last() + controller.cancelConnect(); dispatcher.scheduler.runCurrent() + connectAck.onSuccess() + assertEquals(WifiDirectPhase.CANCELLING, controller.uiState.value.phase) + cancelRequests.last().onSuccess(); dispatcher.scheduler.runCurrent(); respond(false) + assertEquals(WifiDirectPhase.IDLE, controller.uiState.value.phase) + verify(exactly = 0) { api.removeGroup(any(), any()) } + } + + @Test fun removeAcceptanceRetainsGroupUntilOsConfirmsLoss() { + controller.refresh(); dispatcher.scheduler.runCurrent(); respond(true) + controller.disconnect(); dispatcher.scheduler.runCurrent() + removeRequests.last().onSuccess() + assertNotNull(controller.uiState.value.link) + assertEquals(WifiDirectPhase.DISCONNECTING, controller.uiState.value.phase) + respond(false) + assertNull(controller.uiState.value.link) + assertEquals(WifiDirectPhase.IDLE, controller.uiState.value.phase) + verify(exactly = 0) { api.cancelConnect(any(), any()) } + } + + @Test fun staleGroupCallbackCannotReplaceNewerConfirmedState() { + controller.refresh(); dispatcher.scheduler.runCurrent() + infoRequests.last().onConnectionInfoAvailable(info(true)) + val stale = groupRequests.last() + controller.refresh(); dispatcher.scheduler.runCurrent(); respond(false) + stale.onGroupInfoAvailable(group()) + assertNull(controller.uiState.value.link) + } + + @Test fun closingControllerDoesNotRemoveOsGroup() { + controller.close() + verify(exactly = 0) { api.removeGroup(any(), any()) } + } + + @Test fun permissionMatrixAndBusyMessageAreExplicit() { + assertEquals(listOf(android.Manifest.permission.ACCESS_FINE_LOCATION), WifiDirectRequirements.runtimePermissions(32).toList()) + assertEquals(listOf(android.Manifest.permission.NEARBY_WIFI_DEVICES), WifiDirectRequirements.runtimePermissions(33).toList()) + assertTrue(WifiDirectRequirements.error("검색", WifiP2pManager.BUSY).contains("다시 시도")) + } +} diff --git a/Android/app/src/test/java/com/dct/app/tests/WifiDirectRouteTest.kt b/Android/app/src/test/java/com/dct/app/tests/WifiDirectRouteTest.kt new file mode 100644 index 0000000..63aa806 --- /dev/null +++ b/Android/app/src/test/java/com/dct/app/tests/WifiDirectRouteTest.kt @@ -0,0 +1,164 @@ +package com.dct.app.tests + +import com.dct.app.data.communication.WifiDirectRoute +import com.dct.app.data.communication.WifiDirectRouteNotReady +import com.dct.app.data.communication.WifiSocketManager +import com.dct.app.data.protocol.DctPacketCodec +import com.dct.app.domain.model.protocol.DctPacket +import kotlinx.coroutines.* +import kotlinx.coroutines.flow.first +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.Test +import java.io.IOException +import java.net.InetAddress +import java.net.ServerSocket +import java.util.concurrent.atomic.AtomicInteger +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import java.net.Socket +import java.net.SocketAddress +import javax.net.SocketFactory +import android.net.Network +import io.mockk.every +import io.mockk.mockk + +class WifiDirectRouteTest { + private fun ip(value: String) = InetAddress.getByName(value) + private fun loopbackRoute() = WifiDirectRoute("test-group-interface", ip("127.0.0.1"), ip("127.0.0.1"), false) + + @Test fun actualSubnetAndPrefixAreUsedInsteadOfFixedP2pRange() { + assertTrue(WifiDirectRoute.sameSubnet(ip("192.168.137.23"), ip("192.168.137.1"), 24)) + assertFalse(WifiDirectRoute.sameSubnet(ip("192.168.200.6"), ip("192.168.137.1"), 24)) + assertFalse(WifiDirectRoute.sameSubnet(ip("192.168.137.129"), ip("192.168.137.1"), 25)) + assertFalse(WifiDirectRoute.sameSubnet(ip("192.168.137.23"), ip("192.168.137.1"), 0)) + assertFalse(WifiDirectRoute.sameSubnet(ip("::1"), ip("127.0.0.1"), 24)) + } + + @Test fun staleOwnerAddressAndWrongRoleAreRejected() { + val route = WifiDirectRoute("swlan1", ip("192.168.137.23"), ip("192.168.137.1"), false) + route.requireClientTarget("192.168.137.1") + assertThrows(IOException::class.java) { route.requireClientTarget("192.168.200.16") } + assertThrows(IOException::class.java) { route.copy(isGroupOwner = true).requireClientTarget("192.168.137.1") } + } + + @Test fun networkObjectIsNotRequiredForBoundSocketAndBidirectionalDctPackets() = runBlocking { + val manager = WifiSocketManager() + manager.setWifiDirectRouteProvider { loopbackRoute() } + ServerSocket(0, 1, ip("127.0.0.1")).use { server -> + server.soTimeout = 3000 + try { + val result = CompletableDeferred() + manager.connect("127.0.0.1", server.localPort, + onSuccess = { result.complete(null) }, onFailure = { result.complete(it) }) + assertNull(withTimeout(3000) { result.await() }) + server.accept().use { peer -> + peer.soTimeout = 3000 + assertEquals(ip("127.0.0.1"), peer.inetAddress) + val request = DctPacket(0x00) + val bytes = DctPacketCodec().encode(request) + assertTrue(manager.sendCommand(request)) + assertArrayEquals(bytes, peer.getInputStream().readNBytes(bytes.size)) + val response = DctPacket(0x80.toByte()) + val received = async(start = CoroutineStart.UNDISPATCHED) { manager.packetStream.first() } + peer.getOutputStream().write(DctPacketCodec().encode(response)) + assertEquals(response, withTimeout(3000) { received.await() }) + } + } finally { manager.disconnectSuspend() } + } + } + + @Test fun missingGroupDoesNotFallBackToReachableOrdinarySocket() = runBlocking { + val manager = WifiSocketManager() + manager.setWifiDirectRouteProvider { throw WifiDirectRouteNotReady("group missing") } + ServerSocket(0, 1, ip("127.0.0.1")).use { server -> + try { + val result = CompletableDeferred() + manager.connect("127.0.0.1", server.localPort, + onSuccess = { result.complete(null) }, onFailure = { result.complete(it) }) + assertEquals("group missing", withTimeout(3000) { result.await() }) + assertFalse(manager.isConnected.value) + server.soTimeout = 150 + assertThrows(java.net.SocketTimeoutException::class.java) { server.accept().close() } + } finally { manager.disconnectSuspend() } + } + } + + @Test fun disconnectClosesSocketWhileConnectIsBlockedAndDoesNotReportFailure() = runBlocking { + val entered = CountDownLatch(1) + val closed = CountDownLatch(1) + val finished = CountDownLatch(1) + val callbacks = AtomicInteger() + val socket = object : Socket() { + override fun bind(bindpoint: SocketAddress?) {} + override fun connect(endpoint: SocketAddress?, timeout: Int) { + entered.countDown() + try { + check(closed.await(3, TimeUnit.SECONDS)) + throw IOException("closed during connect") + } finally { finished.countDown() } + } + override fun close() { super.close(); closed.countDown() } + } + val factory = mockk() + val network = mockk() + every { factory.createSocket() } returns socket + every { network.socketFactory } returns factory + val manager = WifiSocketManager() + manager.setWifiDirectRouteProvider { loopbackRoute().copy(network = network) } + try { + manager.connect("127.0.0.1", 6000, + onSuccess = { callbacks.incrementAndGet() }, onFailure = { callbacks.incrementAndGet() }) + assertTrue(entered.await(3, TimeUnit.SECONDS)) + manager.disconnectSuspend() + assertTrue(closed.await(1, TimeUnit.SECONDS)) + assertTrue(finished.await(1, TimeUnit.SECONDS)) + delay(50) + assertTrue(socket.isClosed) + assertFalse(manager.isConnected.value) + assertEquals(0, callbacks.get()) + } finally { manager.disconnectSuspend() } + } + + @Test fun changedGroupDuringConnectIsRejectedBeforePublishingConnected() = runBlocking { + val manager = WifiSocketManager() + val calls = AtomicInteger() + manager.setWifiDirectRouteProvider { + if (calls.incrementAndGet() == 1) loopbackRoute() + else loopbackRoute().copy(interfaceName = "replacement-group") + } + ServerSocket(0, 1, ip("127.0.0.1")).use { server -> + try { + val result = CompletableDeferred() + manager.connect("127.0.0.1", server.localPort, + onSuccess = { result.complete(null) }, onFailure = { result.complete(it) }) + assertNotNull(withTimeout(3000) { result.await() }) + assertFalse(manager.isConnected.value) + server.soTimeout = 3000 + server.accept().use { peer -> + peer.soTimeout = 3000 + assertEquals(-1, peer.getInputStream().read()) + } + } finally { manager.disconnectSuspend() } + } + } + + @Test fun ipReadinessRetryUsesTheRouteWhenItBecomesAvailable() = runBlocking { + val manager = WifiSocketManager() + val calls = AtomicInteger() + manager.setWifiDirectRouteProvider { + if (calls.incrementAndGet() < 2) throw WifiDirectRouteNotReady("IP pending") + loopbackRoute() + } + ServerSocket(0, 1, ip("127.0.0.1")).use { server -> + try { + val result = CompletableDeferred() + manager.startDualMode(false, "127.0.0.1", server.localPort, + onConnected = { _, _ -> result.complete(null) }, onFailure = { result.complete(it) }) + assertNull(withTimeout(4000) { result.await() }) + assertTrue(manager.isConnected.value) + server.soTimeout = 3000 + server.accept().close() + } finally { manager.disconnectSuspend() } + } + } +} diff --git a/Android/app/src/test/java/com/dct/app/tests/WindowRevealTest.kt b/Android/app/src/test/java/com/dct/app/tests/WindowRevealTest.kt new file mode 100644 index 0000000..3c7c27f --- /dev/null +++ b/Android/app/src/test/java/com/dct/app/tests/WindowRevealTest.kt @@ -0,0 +1,20 @@ +package com.dct.app.tests + +import com.dct.app.presentation.mdi.windowRevealScroll +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test + +class WindowRevealTest { + @Test fun visibleWindowDoesNotMoveViewport() { + assertEquals(100f, windowRevealScroll(100f, 800f, 150f, 400f)) + } + @Test fun revealsBothEdgesWithMinimalMovement() { + assertEquals(516f, windowRevealScroll(0f, 800f, 900f, 400f)) + assertEquals(84f, windowRevealScroll(500f, 800f, 100f, 400f)) + } + @Test fun oversizedWindowShowsLeadingEdgeAndNeverScrollsNegative() { + assertEquals(884f, windowRevealScroll(0f, 800f, 900f, 1000f)) + assertEquals(0f, windowRevealScroll(500f, 800f, 0f, 1000f)) + assertEquals(100f, windowRevealScroll(100f, 0f, 900f, 400f)) + } +} diff --git a/Android/app/src/test/java/com/dct/app/tests/YAxisHitTest.kt b/Android/app/src/test/java/com/dct/app/tests/YAxisHitTest.kt new file mode 100644 index 0000000..7eaae92 --- /dev/null +++ b/Android/app/src/test/java/com/dct/app/tests/YAxisHitTest.kt @@ -0,0 +1,34 @@ +package com.dct.app.tests + +import androidx.compose.ui.geometry.Offset +import com.dct.app.domain.model.chart.* +import com.dct.app.presentation.chart.hitTestYAxis +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.Test + +class YAxisHitTest { + private val bounds = PlotBounds(180f, 20f, 500f, 300f) + private val left = listOf(ChartAxisConfig(AxisPosition.LEFT_0), ChartAxisConfig(AxisPosition.LEFT_1)) + private val right = listOf(ChartAxisConfig(AxisPosition.RIGHT_0), ChartAxisConfig(AxisPosition.RIGHT_1)) + private fun hit(x: Float, y: Float = 100f) = hitTestYAxis(Offset(x, y), left, right, bounds) + + @Test fun leftNumbersBelongToTheirAxisNotTheNearestLine() { + assertEquals(AxisPosition.LEFT_1, hit(120f)) // 60px left of inner axis + assertEquals(AxisPosition.LEFT_0, hit(48f)) + assertEquals(AxisPosition.LEFT_0, hit(108f)) // exact shared boundary + assertEquals(AxisPosition.LEFT_1, hit(180f)) + } + @Test fun rightNumbersAndPinchEndpointsUseTheSameLane() { + assertEquals(AxisPosition.RIGHT_0, hit(505f)) + assertEquals(AxisPosition.RIGHT_0, hit(560f)) + assertEquals(AxisPosition.RIGHT_1, hit(572f)) + assertEquals(AxisPosition.RIGHT_1, hit(632f)) + } + @Test fun plotAndOutsideAxisHeightRemainUnclaimed() { + assertNull(hit(300f)) + assertNull(hit(120f, 19f)) + assertNull(hit(120f, 301f)) + assertNull(hit(20f)) + assertNull(hit(650f)) + } +} diff --git a/Android/gradlew.bat b/Android/gradlew.bat index 93e3f59..662413c 100644 --- a/Android/gradlew.bat +++ b/Android/gradlew.bat @@ -33,6 +33,19 @@ set APP_HOME=%DIRNAME% @rem Resolve any "." and ".." in APP_HOME to make it shorter. for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi +@rem JDK 16+ implements java.nio.Pipe with a Windows Unix-domain socket. In +@rem restricted Windows environments the default AppData TEMP path can make +@rem that socket fail with WSAEINVAL ("Unable to establish loopback connection"). +@rem Point only the JDK Unix-domain socket directory at a project-local path; +@rem JAVA_TOOL_OPTIONS is inherited by the Gradle daemon as well as the client. +set DCT_GRADLE_TEMP=%APP_HOME%\.gradle-tmp +if not exist "%DCT_GRADLE_TEMP%" mkdir "%DCT_GRADLE_TEMP%" >NUL 2>&1 +if defined JAVA_TOOL_OPTIONS ( + set "JAVA_TOOL_OPTIONS=%JAVA_TOOL_OPTIONS% -Djdk.net.unixdomain.tmpdir=%DCT_GRADLE_TEMP%" +) else ( + set "JAVA_TOOL_OPTIONS=-Djdk.net.unixdomain.tmpdir=%DCT_GRADLE_TEMP%" +) + @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" diff --git a/DCT_test/DCT_test/ClientSession.cs b/DCT_test/DCT_test/ClientSession.cs index ddfabd3..b7e2705 100644 --- a/DCT_test/DCT_test/ClientSession.cs +++ b/DCT_test/DCT_test/ClientSession.cs @@ -1,14 +1,477 @@ using System; using System.Net.Sockets; +using System.Collections.Generic; +using System.Diagnostics; +using System.Threading; +using System.Threading.Tasks; namespace DCT_test { public class ClientSession { + // At 30 Hz this allows roughly 267 ms of in-flight telemetry. Keeping + // the window small prevents bulk log playback from filling the local + // Wi-Fi Direct bridge ahead of a STOP/START control response. + public const int TelemetryAckWindowSize = 8; + private const int AckStallTimeoutMilliseconds = 3000; + private const int AckBackpressureWaitMilliseconds = 250; + + // Control responses and telemetry share one TCP byte stream, therefore + // writes must still be serialized. They are queued separately so a + // large/stalled telemetry write cannot hold up START/STOP responses. + private const int MaxPendingControlWrites = 16; + private const int MaxPendingTelemetryWrites = 64; + // Dispose closes the socket to interrupt blocked I/O on host shutdown. + private const int SocketWriteTimeoutMilliseconds = System.Threading.Timeout.Infinite; + + public object SendLock { get; } = new object(); + private readonly object ackLock = new object(); + private readonly Dictionary> pendingAcks = new Dictionary>(); + private readonly Stopwatch ackClock = Stopwatch.StartNew(); + private int pendingCount; + + private sealed class OutboundWrite + { + public readonly byte[] Bytes; + public readonly TaskCompletionSource Completion; + public Action OnComplete; + public int? TelemetryGeneration; + public DateTime SentAt; + private int cancelled; + + public OutboundWrite(byte[] bytes, bool waitForCompletion) + { + Bytes = bytes; + Completion = waitForCompletion + ? new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously) + : null; + } + + public bool IsCancelled => Volatile.Read(ref cancelled) != 0; + + public void Cancel() + { + Interlocked.Exchange(ref cancelled, 1); + } + + public void Complete(bool sent) + { + if (Completion != null) Completion.TrySetResult(sent); + OnComplete?.Invoke(sent); + } + } + + private readonly object outboundQueueLock = new object(); + private readonly Queue controlWrites = new Queue(); + private readonly Queue telemetryWrites = new Queue(); + private readonly SemaphoreSlim outboundSignal = new SemaphoreSlim(0); + private readonly CancellationTokenSource outboundCts = new CancellationTokenSource(); + private Task outboundWorker; + private int outboundWorkerStarted; + private int outboundClosed; + private int droppedTelemetry; + private int sentPackets; + private long lastWireWriteTicks; + + public volatile bool Streaming; + private int telemetryGeneration; + public int TelemetryGeneration => Volatile.Read(ref telemetryGeneration); + public int ControlRevision; + public int HandshakeMask { get; set; } + public int ConfigurationMask { get; set; } + /// + /// 0=아직 XCP 모드 미지정, 1=A2L/XCP 사용, 2=XCP 미사용. + /// PC는 MDB가 없는 A2L 프로젝트에서 0x10/0x13을 보내지 않고 + /// 0x40 -> 0x41 -> 0x42 -> 0x11 순서로 설정한다. + /// + public int XcpUseMode { get; set; } + + /// 0x01=USE, 0x02=IF_DATA, 0x04=MEASUREMENTS. + public int XcpConfigurationMask { get; set; } + + private bool CommonConfigurationReady => (ConfigurationMask & 0x05) == 0x05; + private bool MdbConfigurationReady => (ConfigurationMask & 0x0F) == 0x0F; + private bool XcpConfigurationReady => XcpConfigurationMask == 0x07; + + public bool ReadyToMeasure + { + get + { + if (HandshakeMask != 0x0F) return false; + if (XcpUseMode == 0x01) + return CommonConfigurationReady && XcpConfigurationReady; + if (XcpUseMode == 0x02) + return CommonConfigurationReady; + return MdbConfigurationReady; + } + } + + public string MeasurementReadinessSummary => + $"handshake=0x{HandshakeMask:X2}, config=0x{ConfigurationMask:X2}, " + + $"xcpMode=0x{XcpUseMode:X2}, xcpConfig=0x{XcpConfigurationMask:X2}"; + + /// + /// Applies the configured project family to log playback. A2L/XCP + /// sessions may still receive CAN-DBC A3 frames, but MDB record A3 + /// frames must never leak into them. Conversely, D0 is XCP-only. + /// + public bool AcceptsTelemetry(byte command, byte[] payload) + { + if (command == 0xD0) return XcpUseMode == 0x01; + if (command != 0xA3) return true; + + byte streamType = payload != null && payload.Length > 0 ? payload[0] : (byte)0x00; + if (XcpUseMode == 0x01 && streamType == 0x01) return false; // MDB record + return true; // CAN-DBC (0x02) is valid for both project families. + } + + /// Number of telemetry frames discarded because the bounded + /// per-client queue was full. Dropping old telemetry is preferable to + /// replaying seconds-old samples after a temporary Wi-Fi stall. + public int DroppedTelemetry => Volatile.Read(ref droppedTelemetry); + + /// Timestamp of the most recent successful NetworkStream.Write. + public DateTime LastWireWriteAt + { + get + { + long ticks = Interlocked.Read(ref lastWireWriteTicks); + return ticks == 0 ? DateTime.MinValue : new DateTime(ticks, DateTimeKind.Local); + } + } + + /// Raised once when the per-client writer encounters a socket error. + public event Action SendFailed; + + public void SetStreaming(bool value) + { + lock (ackLock) + { + if (Streaming != value) + { + Interlocked.Increment(ref telemetryGeneration); + // STOP is a protocol session boundary. Reservations for + // frames from the previous run must not throttle or be + // consumed by ACKs belonging to the restarted run. + pendingAcks.Clear(); + pendingCount = 0; + } + Streaming = value; + Monitor.PulseAll(ackLock); + } + + // A STOP request invalidates telemetry already waiting in the + // queue. The STOP response itself is put on the control queue + // afterwards and therefore reaches the peer first. + if (!value) ClearPendingTelemetryWrites(); + } + + /// + /// Enqueues a telemetry frame without waiting for socket I/O. The + /// queue is bounded and drops the oldest telemetry frame on overflow; + /// this keeps the graph close to live time and never blocks playback. + /// + public bool TryQueueTelemetry(byte[] bytes) + { + if (bytes == null || bytes.Length == 0) return false; + + lock (outboundQueueLock) + { + if (Volatile.Read(ref outboundClosed) != 0 || !Streaming) return false; + if (telemetryWrites.Count >= MaxPendingTelemetryWrites) + { + telemetryWrites.Dequeue(); + Interlocked.Increment(ref droppedTelemetry); + } + telemetryWrites.Enqueue(new OutboundWrite(bytes, false) { TelemetryGeneration = TelemetryGeneration }); + } + + EnsureOutboundWorker(); + SignalOutboundWorker(); + return true; + } + + /// + /// Queues a control response and waits only for the dedicated writer to + /// complete the actual socket write. A timeout cancels this queued + /// request but does not tear down the TCP session from the caller. + /// + public bool SendControl(byte[] bytes, int timeoutMilliseconds) + { + if (bytes == null || bytes.Length == 0 || timeoutMilliseconds <= 0) return false; + + var request = new OutboundWrite(bytes, true); + lock (outboundQueueLock) + { + if (Volatile.Read(ref outboundClosed) != 0 || controlWrites.Count >= MaxPendingControlWrites) + return false; + controlWrites.Enqueue(request); + } + + EnsureOutboundWorker(); + SignalOutboundWorker(); + + try + { + if (!request.Completion.Task.Wait(timeoutMilliseconds)) + { + request.Cancel(); + return false; + } + return request.Completion.Task.Result; + } + catch (Exception) + { + request.Cancel(); + return false; + } + } + + /// START/STOP must not block the receive loop or expire behind an in-flight write. + public bool QueueControl(byte[] bytes, Action onComplete) + { + lock (outboundQueueLock) + { + if (Volatile.Read(ref outboundClosed) != 0 || controlWrites.Count >= MaxPendingControlWrites) return false; + controlWrites.Enqueue(new OutboundWrite(bytes, false) { OnComplete = onComplete }); + } + EnsureOutboundWorker(); + SignalOutboundWorker(); + return true; + } + + /// File playback waits for admission and the exact write; never drops or retries a sent frame. + public async Task SendLogAsync(byte command, byte[] bytes, int generation, CancellationToken token) + { + while (Streaming && TelemetryGeneration == generation && + Volatile.Read(ref outboundClosed) == 0 && !token.IsCancellationRequested) + { + if (!ReserveTelemetry(command)) { await Task.Delay(10, token); continue; } + OutboundWrite request = null; + lock (outboundQueueLock) + { + if (Streaming && TelemetryGeneration == generation && + Volatile.Read(ref outboundClosed) == 0 && telemetryWrites.Count < MaxPendingTelemetryWrites) + { + request = new OutboundWrite(bytes, true) { TelemetryGeneration = generation }; + telemetryWrites.Enqueue(request); + } + } + if (request == null) + { + CancelTelemetryReservation(command); + await Task.Delay(10, token); + continue; + } + EnsureOutboundWorker(); + SignalOutboundWorker(); + using (token.Register(() => { request.Cancel(); request.Complete(false); })) + return await request.Completion.Task.ConfigureAwait(false) ? request.SentAt : (DateTime?)null; + } + return null; + } + + private void EnsureOutboundWorker() + { + if (Interlocked.CompareExchange(ref outboundWorkerStarted, 1, 0) != 0) return; + outboundWorker = Task.Run(() => OutboundLoopAsync(outboundCts.Token)); + } + + private void SignalOutboundWorker() + { + try { outboundSignal.Release(); } + catch (ObjectDisposedException) { } + catch (SemaphoreFullException) { } + } + + private async Task OutboundLoopAsync(CancellationToken cancellationToken) + { + try + { + while (!cancellationToken.IsCancellationRequested) + { + await outboundSignal.WaitAsync(cancellationToken).ConfigureAwait(false); + + // Drain all currently queued packets. Control traffic is + // always selected first, even when telemetry is arriving + // continuously. + while (!cancellationToken.IsCancellationRequested) + { + OutboundWrite request = null; + lock (outboundQueueLock) + { + if (controlWrites.Count > 0) request = controlWrites.Dequeue(); + else if (telemetryWrites.Count > 0) request = telemetryWrites.Dequeue(); + else break; + } + + if (request.IsCancelled || (request.TelemetryGeneration.HasValue && + (!Streaming || request.TelemetryGeneration.Value != TelemetryGeneration))) + { + request.Complete(false); + continue; + } + + Exception failure; + bool sent = TryWriteToSocket(request.Bytes, out failure); + if (sent) request.SentAt = LastWireWriteAt; + request.Complete(sent); + if (!sent) + { + if (Volatile.Read(ref outboundClosed) == 0) FailTransport(failure); + return; + } + } + } + } + catch (OperationCanceledException) + { + // Normal shutdown: Dispose closes the socket and cancels the + // worker so a blocked write is released promptly. + } + finally + { + FailPendingOutboundWrites(); + } + } + + private bool TryWriteToSocket(byte[] bytes, out Exception failure) + { + try + { + TcpClient client = Client; + if (client == null || !client.Connected) + throw new InvalidOperationException("TCP 클라이언트가 연결되어 있지 않습니다."); + + NetworkStream stream = client.GetStream(); + stream.WriteTimeout = SocketWriteTimeoutMilliseconds; + stream.Write(bytes, 0, bytes.Length); + Interlocked.Increment(ref sentPackets); + Interlocked.Exchange(ref lastWireWriteTicks, DateTime.Now.Ticks); + failure = null; + return true; + } + catch (Exception ex) + { + failure = ex; + return false; + } + } + + private void FailTransport(Exception failure) + { + SetStreaming(false); + ResetTelemetryAcks(); + try { Client?.Close(); } catch { } + try { SendFailed?.Invoke(this, failure); } catch { } + } + + private void ClearPendingTelemetryWrites() + { + lock (outboundQueueLock) + { + while (telemetryWrites.Count > 0) telemetryWrites.Dequeue().Complete(false); + } + } + + private void FailPendingOutboundWrites() + { + lock (outboundQueueLock) + { + while (controlWrites.Count > 0) controlWrites.Dequeue().Complete(false); + while (telemetryWrites.Count > 0) telemetryWrites.Dequeue().Complete(false); + } + } + + /// + /// Clears reservations when a socket is gone or a transport error is + /// reported. Normal STOP/START transitions also clear them in + /// SetStreaming so each measurement run starts with a fresh window. + /// + public void ResetTelemetryAcks() + { + lock (ackLock) + { + pendingAcks.Clear(); + pendingCount = 0; + Monitor.PulseAll(ackLock); + } + } + + // Test-host safety window, not a change to the hardware wire protocol. + public bool ReserveTelemetry(byte command) + { + lock (ackLock) + { + while (Streaming && pendingCount >= TelemetryAckWindowSize) + { + // A delayed ACK must apply backpressure, never tear down + // the TCP session. Retire only genuinely stale entries; + // if the window is still full after a short wait, skip + // this telemetry packet and let the next one retry. + RetireStaleAcksLocked(); + if (pendingCount < TelemetryAckWindowSize) break; + if (!Monitor.Wait(ackLock, AckBackpressureWaitMilliseconds)) return false; + } + if (!Streaming) return false; + RetireStaleAcksLocked(); + byte ack = command == 0xA3 ? (byte)0x23 : command == 0xA4 ? (byte)0x24 : (byte)0x50; + if (!pendingAcks.TryGetValue(ack, out LinkedList pending)) + pendingAcks[ack] = pending = new LinkedList(); + pending.AddLast(ackClock.ElapsedMilliseconds); + pendingCount++; + return true; + } + } + + public void Acknowledge(byte command, bool cancelLastReservation = false) + { + lock (ackLock) + { + if (pendingAcks.TryGetValue(command, out LinkedList pending) && pending.Count > 0) + { + if (cancelLastReservation) pending.RemoveLast(); + else pending.RemoveFirst(); + pendingCount--; + Monitor.PulseAll(ackLock); + } + } + } + + /// + /// Cancels the reservation made immediately before a send failed or + /// the stream was stopped before the socket write. + /// + public void CancelTelemetryReservation(byte command) + { + byte ack = command == 0xA3 ? (byte)0x23 : command == 0xA4 ? (byte)0x24 : (byte)0x50; + Acknowledge(ack, cancelLastReservation: true); + } + + private void RetireStaleAcksLocked() + { + long now = ackClock.ElapsedMilliseconds; + foreach (var pair in pendingAcks) + { + LinkedList queue = pair.Value; + int removed = 0; + while (queue.Count > 0 && now - queue.First.Value >= AckStallTimeoutMilliseconds) + { + queue.RemoveFirst(); + removed++; + } + if (removed > 0) + { + pendingCount -= removed; + } + } + if (pendingCount < 0) pendingCount = 0; + Monitor.PulseAll(ackLock); + } public TcpClient Client { get; set; } public string IpPort { get; set; } public string ConnectTime { get; set; } - public uint SentPackets { get; set; } + public uint SentPackets => (uint)Math.Max(0, Volatile.Read(ref sentPackets)); public uint ReceivedPackets { get; set; } public long FirmwareReceivedBytes { get; set; } public string FirmwareUploadName { get; set; } @@ -18,10 +481,20 @@ namespace DCT_test Client = client; IpPort = ipPort; ConnectTime = DateTime.Now.ToString("HH:mm:ss"); - SentPackets = 0; + sentPackets = 0; ReceivedPackets = 0; FirmwareReceivedBytes = 0; FirmwareUploadName = string.Empty; } + + /// Stops the writer and closes the client socket. + public void Dispose() + { + if (Interlocked.Exchange(ref outboundClosed, 1) != 0) return; + outboundCts.Cancel(); + FailPendingOutboundWrites(); + SignalOutboundWorker(); + try { Client?.Close(); } catch { } + } } } diff --git a/DCT_test/DCT_test/DCT_test.csproj b/DCT_test/DCT_test/DCT_test.csproj index 8350cee..5d8509d 100644 --- a/DCT_test/DCT_test/DCT_test.csproj +++ b/DCT_test/DCT_test/DCT_test.csproj @@ -14,6 +14,7 @@ 4 true true + PackageReference AnyCPU @@ -21,7 +22,7 @@ full false bin\Debug\ - DEBUG;TRACE + DEBUG;TRACE;WIFI_DIRECT_WINRT prompt 4 @@ -30,10 +31,13 @@ pdbonly true bin\Release\ - TRACE + TRACE;WIFI_DIRECT_WINRT prompt 4 + + + @@ -65,8 +69,10 @@ + + MainWindow.xaml Code @@ -99,4 +105,4 @@ - \ No newline at end of file + diff --git a/DCT_test/DCT_test/DctPacket.cs b/DCT_test/DCT_test/DctPacket.cs index 078f1cb..13146c2 100644 --- a/DCT_test/DCT_test/DctPacket.cs +++ b/DCT_test/DCT_test/DctPacket.cs @@ -18,9 +18,11 @@ namespace DCT_test public byte CommandId { get; set; } public ushort Sequence { get; set; } + // File playback metadata only. Never encoded into hardware packets. + public double LogElapsedMilliseconds { get; set; } public byte[] Payload { get; set; } = new byte[0]; - public ushort PackLen => (ushort)(Payload.Length + 1); // CmdID 1바이트 포함 + public ushort PackLen => checked((ushort)(Payload.Length + 1)); // Reject oversized frames. public int TotalLength => PackLen + OVERHEAD_LENGTH; public static DctPacket Create(byte cmdId, byte[] payload = null, ushort seq = 0) @@ -82,7 +84,7 @@ namespace DCT_test ushort packLen = (ushort)((buffer[1] << 8) | buffer[2]); int totalLength = packLen + OVERHEAD_LENGTH; - if (packLen < 1 || totalLength > 65535) + if (packLen < 1) { buffer.RemoveAt(0); continue; @@ -108,7 +110,9 @@ namespace DCT_test byte expectedChk = (byte)((sum ^ 0xFF) & 0xFF); if (buffer[totalLength - 2] != expectedChk) { - buffer.RemoveAt(0); + // A complete frame with matching footer has been received. Drop it as + // a unit; payload A1 bytes must not become a bogus long pending frame. + buffer.RemoveRange(0, totalLength); continue; } diff --git a/DCT_test/DCT_test/DctTcpServer.cs b/DCT_test/DCT_test/DctTcpServer.cs index a781279..ac67996 100644 --- a/DCT_test/DCT_test/DctTcpServer.cs +++ b/DCT_test/DCT_test/DctTcpServer.cs @@ -16,9 +16,6 @@ namespace DCT_test private TcpListener _listener; private CancellationTokenSource _cts; private readonly ConcurrentDictionary _clients = new ConcurrentDictionary(); - private readonly object _xcpConfigLock = new object(); - private byte[] _xcpMeasurementTypes = new byte[] { 3, 3, 2, 0 }; - private bool _xcpIsMsbFirst; private readonly string[] _maintenanceLogNames = { "TEST_MDB_LOG.dat", "TEST_CAN_LOG.dat" }; private readonly byte[][] _maintenanceLogData = { @@ -34,41 +31,39 @@ namespace DCT_test public ulong TotalXcpAcks { get; private set; } public bool ShowRxPackets { get; set; } = true; - public bool ShowHexDump { get; set; } = false; + public bool ShowHexDump { get; set; } = true; + + // Keep this below Android's response timeout. The actual socket write + // is performed by ClientSession's dedicated priority writer, so this + // wait never blocks the playback loop behind telemetry I/O. + private const int ControlResponseTimeoutMilliseconds = 2500; public event Action OnLogMessage; public event Action OnClientConnected; public event Action OnClientDisconnected; public event Action OnClientListChanged; - public event Action OnStartSimRequested; - public event Action OnStopSimRequested; + public event Action OnStartPlaybackRequested; + public event Action OnStopPlaybackRequested; public ICollection ActiveClients => _clients.Values; /// - /// 마지막 0x41/0x42 설정과 같은 count/type/endian으로 PC 호환 0xD0 payload를 생성한다. - /// rawValues가 부족한 나머지 항목은 0으로 채운다. + /// Most recent successful NetworkStream.Write among active clients. + /// This is used by the playback log to distinguish a real wire write + /// from a frame merely accepted by the outbound queue. /// - public byte[] BuildXcpDaqPayload(params double[] rawValues) + public DateTime LatestWireWriteAt { - byte[] types; - bool isMsbFirst; - lock (_xcpConfigLock) + get { - types = (byte[])_xcpMeasurementTypes.Clone(); - isMsbFirst = _xcpIsMsbFirst; - } - - var payload = new List(2 + types.Length * 5); - payload.Add((byte)((types.Length >> 8) & 0xFF)); - payload.Add((byte)(types.Length & 0xFF)); - for (int i = 0; i < types.Length; i++) - { - payload.Add(types[i]); - double value = i < rawValues.Length ? rawValues[i] : 0.0; - AppendXcpValue(payload, types[i], value, isMsbFirst); + DateTime latest = DateTime.MinValue; + foreach (var session in _clients.Values) + { + DateTime sentAt = session.LastWireWriteAt; + if (sentAt > latest) latest = sentAt; + } + return latest; } - return payload.ToArray(); } public bool Start(int port) @@ -113,11 +108,7 @@ namespace DCT_test foreach (var kv in _clients) { - try - { - kv.Value.Client?.Close(); - } - catch { } + kv.Value.Dispose(); } _clients.Clear(); @@ -125,28 +116,89 @@ namespace DCT_test Log("[INFO] TCP 서버가 중지되었습니다."); } - public void BroadcastPacket(DctPacket packet) + /// + /// Queues a telemetry frame for currently streaming clients. Each + /// client has a dedicated writer, so playback never performs a + /// blocking socket write. Control responses use a separate priority + /// queue in ClientSession and are therefore not starved by telemetry. + /// The return value is the number of clients that accepted the frame + /// into their bounded outbound queue. + /// + public int BroadcastPacket(DctPacket packet) { - if (!IsRunning || _clients.IsEmpty) return; + if (!IsRunning || _clients.IsEmpty) return 0; byte[] encoded = packet.Encode(); + int queuedCount = 0; foreach (var kv in _clients) { var session = kv.Value; + bool cancelReservationOnFailure = false; try { - if (session.Client.Connected) + if (!session.AcceptsTelemetry(packet.CommandId, packet.Payload)) + continue; + bool requiresAck = packet.CommandId == 0xA3 || + packet.CommandId == 0xA4 || packet.CommandId == 0xD0; + // The wire protocol requires one 0x23/0x24/0x50 ACK per + // stream frame. Reserve before enqueueing so playback + // cannot outrun the tablet and bury STOP/START responses + // behind a large bridge backlog. + if (requiresAck) + { + if (!session.ReserveTelemetry(packet.CommandId)) continue; + cancelReservationOnFailure = true; + } + + int droppedBefore = session.DroppedTelemetry; + if (session.TryQueueTelemetry(encoded)) { - var stream = session.Client.GetStream(); - stream.Write(encoded, 0, encoded.Length); - session.SentPackets++; + queuedCount++; + cancelReservationOnFailure = false; + } + else if (requiresAck) + { + session.CancelTelemetryReservation(packet.CommandId); + cancelReservationOnFailure = false; + } + int droppedAfter = session.DroppedTelemetry; + if (droppedAfter > droppedBefore && + (droppedAfter == 1 || droppedAfter % 16 == 0)) + { + Log($"[WARN] 텔레메트리 송신 큐 포화 ({session.IpPort}) - 오래된 프레임 {droppedAfter}건 폐기"); } } - catch + catch (Exception ex) { - // Handled in read loop + if (cancelReservationOnFailure) + session.CancelTelemetryReservation(packet.CommandId); + Log($"[ERROR] TCP 브로드캐스트 송신 실패 ({session.IpPort}): {ex.Message}"); } } + return queuedCount; + } + + /// Send each source record once per eligible session, waiting instead of skipping on backpressure. + public bool ShowTxPackets { get; set; } = true; + public async Task BroadcastLogPacketAsync(DctPacket packet, int recordIndex, CancellationToken ct) + { + var encoded = packet.Encode(); + var targets = _clients.Values.Where(s => s.Streaming && s.AcceptsTelemetry(packet.CommandId, packet.Payload)) + .Select(s => new { Session = s, Generation = s.TelemetryGeneration }).ToArray(); + await Task.WhenAll(targets.Select(async target => + { + var session = target.Session; + var sentAt = await session.SendLogAsync(packet.CommandId, encoded, target.Generation, ct).ConfigureAwait(false); + if (sentAt.HasValue) + { + if (ShowTxPackets) + { + var message = $"[TX >>>] [로그 파일 -> {session.IpPort}] record={recordIndex} | wire={sentAt.Value:HH:mm:ss.fff} | CMD: 0x{packet.CommandId:X2} | Payload: {packet.Payload.Length}B"; + if (ShowHexDump) message += "\r\n └ [TX HEX]: " + BitConverter.ToString(encoded).Replace("-", " "); + Log(message); + } + } + })).ConfigureAwait(false); } private async Task BroadcastBeaconAsync(int port, CancellationToken ct) @@ -263,12 +315,20 @@ namespace DCT_test { var client = await _listener.AcceptTcpClientAsync(); client.NoDelay = true; + // Keep the session alive during temporary bridge backpressure. + // Shutdown closes the socket and releases pending writes. + client.SendTimeout = 0; + client.ReceiveTimeout = 5000; string ipPort = client.Client.RemoteEndPoint?.ToString() ?? "Unknown"; var session = new ClientSession(client, ipPort); + session.SendFailed += (failedSession, error) => + { + Log($"[ERROR] TCP 송신 worker 종료 ({failedSession.IpPort}): {error.Message}"); + }; _clients[ipPort] = session; - Log($"[+] 태블릿 클라이언트 접속됨: {ipPort}"); + Log($"[INFO] 태블릿 클라이언트 접속됨: {ipPort}"); OnClientConnected?.Invoke(session); OnClientListChanged?.Invoke(); @@ -311,67 +371,81 @@ namespace DCT_test var packets = DctPacket.ParseStream(buffer); foreach (var pkt in packets) { - HandlePacket(session, pkt); + try { HandlePacket(session, pkt); } + catch (Exception ex) + { + ReportReceiveFailure($"패킷 처리 실패 (연결 유지): {session.IpPort}, CMD=0x{pkt.CommandId:X2}", ex); + } } - OnClientListChanged?.Invoke(); + try { OnClientListChanged?.Invoke(); } + catch (Exception ex) { ReportReceiveFailure("클라이언트 UI 갱신 실패 (연결 유지)", ex); } } } - catch + catch (OperationCanceledException) when (ct.IsCancellationRequested) { } + catch (Exception ex) { - // Disconnected + ReportReceiveFailure($"TCP 수신 루프 종료: {session.IpPort}", ex); } finally { _clients.TryRemove(session.IpPort, out _); - try { session.Client.Close(); } catch { } - Log($"[-] 클라이언트 접속 해제: {session.IpPort}"); + session.Dispose(); + Log($"[INFO] 클라이언트 접속 해제: {session.IpPort}"); OnClientDisconnected?.Invoke(session.IpPort); OnClientListChanged?.Invoke(); } } + // Logging subscribers must not turn a recoverable handler error into socket teardown. + private void ReportReceiveFailure(string message, Exception error) + { + System.Diagnostics.Trace.TraceError(message + ": " + error); + try { Log("[WARN] " + message + ": " + error); } + catch (Exception logError) { System.Diagnostics.Trace.TraceError(logError.ToString()); } + } + private void HandlePacket(ClientSession session, DctPacket packet) { string cmdName = GetCommandName(packet.CommandId); - string rxLog = null; if (ShowRxPackets) { - rxLog = $"[📥 RX <- {session.IpPort}] #{packet.Sequence:D5} | 0x{packet.CommandId:X2} ({cmdName}) | Payload: {packet.Payload.Length}B"; + string rxLog = $"[RX <<<] [수신 <- {session.IpPort}] #{packet.Sequence:D5} | CMD: 0x{packet.CommandId:X2} ({cmdName}) | Payload: {packet.Payload.Length}B"; if (ShowHexDump) { string hex = BitConverter.ToString(packet.Encode()).Replace("-", " "); - rxLog += $"\r\n └ [Raw RX]: {hex}"; + rxLog += $"\r\n └ [RX HEX]: {hex}"; } + Log(rxLog); } switch (packet.CommandId) { case 0x00: // CMD_ENABLE - SendPacket(session, DctPacket.Create(0x80, new byte[] { 0x01 }, packet.Sequence)); - LogRx(rxLog, $"[HANDSHAKE] CMD_ENABLE (0x00) -> [SEND] RES_ENABLE (0x80, ACK: 0x01)"); + session.HandshakeMask |= 0x01; + SendResponse(session, DctPacket.Create(0x80, new byte[] { 0x01 }, packet.Sequence), "RES_ENABLE (0x80, ACK: 0x01) [HANDSHAKE]"); break; case 0x01: // CMD_VERSION + session.HandshakeMask |= 0x02; // PC RES_VERSION payload: [length:1][ASCII version:length], no trailing NUL. byte[] verBytes = Encoding.ASCII.GetBytes("DCT-MOBI-v1.0.0"); byte[] verPayload = new byte[verBytes.Length + 1]; verPayload[0] = checked((byte)verBytes.Length); Buffer.BlockCopy(verBytes, 0, verPayload, 1, verBytes.Length); - SendPacket(session, DctPacket.Create(0x81, verPayload, packet.Sequence)); - LogRx(rxLog, $"[HANDSHAKE] CMD_VERSION (0x01) -> [SEND] RES_VERSION (0x81, Len: {verBytes.Length}, 'DCT-MOBI-v1.0.0')"); + SendResponse(session, DctPacket.Create(0x81, verPayload, packet.Sequence), $"RES_VERSION (0x81, Len: {verBytes.Length}, 'DCT-MOBI-v1.0.0') [HANDSHAKE]"); break; case 0x03: // CMD_HWID + session.HandshakeMask |= 0x04; byte[] hwBytes = new byte[] { 0x00, 0x1A, 0x2B, 0x3C, 0x4D, 0x5E }; - SendPacket(session, DctPacket.Create(0x83, hwBytes, packet.Sequence)); - LogRx(rxLog, $"[HANDSHAKE] CMD_HWID (0x03) -> [SEND] RES_HWID (0x83, 00:1A:2B:3C:4D:5E)"); + SendResponse(session, DctPacket.Create(0x83, hwBytes, packet.Sequence), "RES_HWID (0x83, 00:1A:2B:3C:4D:5E) [HANDSHAKE]"); break; case 0x04: // CMD_SYNC_TIME - SendPacket(session, DctPacket.Create(0x84, new byte[] { 0x01 }, packet.Sequence)); - LogRx(rxLog, $"[HANDSHAKE] CMD_SYNC_TIME (0x04) -> [SEND] RES_SYNC_TIME (0x84, ACK: 0x01)"); + session.HandshakeMask |= 0x08; + SendResponse(session, DctPacket.Create(0x84, new byte[] { 0x01 }, packet.Sequence), "RES_SYNC_TIME (0x84, ACK: 0x01) [HANDSHAKE]"); break; case 0x06: // CMD_CARTYPE @@ -382,97 +456,155 @@ namespace DCT_test if (len > 0 && len < packet.Payload.Length) carType = Encoding.ASCII.GetString(packet.Payload, 1, len); } - SendPacket(session, DctPacket.Create(0x86, new byte[] { 0x01 }, packet.Sequence)); - LogRx(rxLog, $"[CONFIG] CMD_CARTYPE (0x06, CarType: '{carType}') -> [SEND] RES_CARTYPE (0x86, 0x01)"); + SendResponse(session, DctPacket.Create(0x86, new byte[] { 0x01 }, packet.Sequence), $"RES_CARTYPE (0x86, 0x01, CarType: '{carType}') [CONFIG]"); break; case 0x07: // CMD_CARTYPE_CONTROL - SendPacket(session, DctPacket.Create(0x87, new byte[] { 0x01 }, packet.Sequence)); - LogRx(rxLog, $"[CONFIG] CMD_CARTYPE_CONTROL (0x07) -> [SEND] RES_CARTYPE_CONTROL (0x87, 0x01)"); + SendResponse(session, DctPacket.Create(0x87, new byte[] { 0x01 }, packet.Sequence), "RES_CARTYPE_CONTROL (0x87, 0x01) [CONFIG]"); break; case 0x08: // CMD_DB - SendPacket(session, DctPacket.Create(0x88, new byte[] { 0x01 }, packet.Sequence)); - LogRx(rxLog, $"[CONFIG] CMD_DB (0x08) -> [SEND] RES_DB (0x88, 0x01)"); + SendResponse(session, DctPacket.Create(0x88, new byte[] { 0x01 }, packet.Sequence), "RES_DB (0x88, 0x01) [CONFIG]"); break; case 0x09: // CMD_DCT_ENV - SendPacket(session, DctPacket.Create(0x89, new byte[] { 0x01 }, packet.Sequence)); - LogRx(rxLog, $"[CONFIG] CMD_DCT_ENV (0x09) -> [SEND] RES_DCT_ENV (0x89, 0x01)"); + session.ConfigurationMask |= 0x01; + // Protocol v0.58 carries the requested subtype in the + // response. 0x06 additionally echoes the applied CAN + // mode; older builds used a generic one-byte ACK, which + // Android still accepts for backward compatibility. + byte envSub = packet.Payload.Length > 0 ? packet.Payload[0] : (byte)0xFF; + byte[] envResponse; + if (envSub == 0x06 && packet.Payload.Length >= 2) + { + envResponse = new byte[] { 0x06, 0x01, packet.Payload[1] }; + } + else if (envSub == 0x00) + { + // [subtype][status][save-enabled][interval] + envResponse = new byte[] { 0x00, 0x01, 0x00, 0x00 }; + } + else if (envSub == 0x03) + { + // [subtype][beep][haptic][button sensitivity] + envResponse = new byte[] { 0x03, 0x01, 0x01, 0x01 }; + } + else + { + envResponse = new byte[] { 0x01 }; + } + SendResponse(session, DctPacket.Create(0x89, envResponse, packet.Sequence), + $"RES_DCT_ENV (0x89, Sub: 0x{envSub:X2}, Payload: {envResponse.Length}B) [CONFIG]"); break; case 0x0F: // CMD_DISABLE - SendPacket(session, DctPacket.Create(0x8F, new byte[] { 0x01 }, packet.Sequence)); - LogRx(rxLog, $"[HANDSHAKE] CMD_DISABLE (0x0F) -> [SEND] RES_DISABLE (0x8F, 0x01)"); + SendResponse(session, DctPacket.Create(0x8F, new byte[] { 0x01 }, packet.Sequence), "RES_DISABLE (0x8F, 0x01) [HANDSHAKE]"); break; case 0x10: // CMD_MDB_INFO - SendPacket(session, DctPacket.Create(0x90, new byte[] { 0x01 }, packet.Sequence)); - LogRx(rxLog, $"[CONFIG] CMD_MDB_INFO (0x10, Recl Info {packet.Payload.Length}B) -> [SEND] RES_MDB_INFO (0x90, 0x01)"); + session.ConfigurationMask |= 0x02; + SendResponse(session, DctPacket.Create(0x90, new byte[] { 0x01 }, packet.Sequence), $"RES_MDB_INFO (0x90, 0x01, Recl Info {packet.Payload.Length}B) [CONFIG]"); break; case 0x11: // CMD_CANDBC_INFO - SendPacket(session, DctPacket.Create(0x91, new byte[] { 0x01 }, packet.Sequence)); - LogRx(rxLog, $"[CONFIG] CMD_CANDBC_INFO (0x11, CAN IDs {packet.Payload.Length}B) -> [SEND] RES_CANDBC_INFO (0x91, 0x01)"); + session.ConfigurationMask |= 0x04; + SendResponse(session, DctPacket.Create(0x91, new byte[] { 0x01 }, packet.Sequence), $"RES_CANDBC_INFO (0x91, 0x01, CAN IDs {packet.Payload.Length}B) [CONFIG]"); break; case 0x12: // CMD_SM_INFO - SendPacket(session, DctPacket.Create(0x92, new byte[] { 0x01 }, packet.Sequence)); - LogRx(rxLog, $"[CONFIG] CMD_SM_INFO (0x12, Sensor Module {packet.Payload.Length}B) -> [SEND] RES_SM_INFO (0x92, 0x01)"); + // RequestSMCount: [0x01][module-use mask][channel masks...] + // Response: [0x01][number of enabled modules]. + byte smSub = packet.Payload.Length > 0 ? packet.Payload[0] : (byte)0xFF; + byte[] smResponse; + if (smSub == 0x01 && packet.Payload.Length >= 2) + { + int moduleMask = packet.Payload[1] & 0x0F; + byte moduleCount = 0; + for (int bit = 0; bit < 4; bit++) + if ((moduleMask & (1 << bit)) != 0) moduleCount++; + smResponse = new byte[] { 0x01, moduleCount }; + } + else if (smSub == 0x02 && packet.Payload.Length >= 2) + { + // RequestSensorCountInSM: [0x02][module index]. + smResponse = new byte[] { 0x02, packet.Payload[1], 0x08 }; + } + else + { + smResponse = new byte[] { 0x01 }; + } + SendResponse(session, DctPacket.Create(0x92, smResponse, packet.Sequence), + $"RES_SM_INFO (0x92, Sub: 0x{smSub:X2}, Payload: {smResponse.Length}B) [CONFIG]"); break; case 0x13: // CMD_MDB_SELECTED_ID - SendPacket(session, DctPacket.Create(0x93, new byte[] { 0x01 }, packet.Sequence)); - LogRx(rxLog, $"[CONFIG] CMD_MDB_SELECTED_ID (0x13, Monitored IDs {packet.Payload.Length}B) -> [SEND] RES_MDB_SELECTED_ID (0x93, 0x01)"); + session.ConfigurationMask |= 0x08; + SendResponse(session, DctPacket.Create(0x93, new byte[] { 0x01 }, packet.Sequence), $"RES_MDB_SELECTED_ID (0x93, 0x01, Monitored IDs {packet.Payload.Length}B) [CONFIG]"); break; case 0x14: // CMD_COMPENSATION_TYPE_DATA - SendPacket(session, DctPacket.Create(0x94, new byte[] { 0x01 }, packet.Sequence)); - LogRx(rxLog, $"[CALIB] CMD_COMPENSATION_TYPE_DATA (0x14) -> [SEND] RES_COMPENSATION_TYPE_DATA (0x94, 0x01)"); + SendResponse(session, DctPacket.Create(0x94, new byte[] { 0x01 }, packet.Sequence), "RES_COMPENSATION_TYPE_DATA (0x94, 0x01) [CALIB]"); break; case 0x15: // CMD_DID_INFO - SendPacket(session, DctPacket.Create(0x95, new byte[] { 0x01 }, packet.Sequence)); - LogRx(rxLog, $"[DID] CMD_DID_INFO (0x15) -> [SEND] RES_DID_INFO (0x95, 0x01)"); + SendResponse(session, DctPacket.Create(0x95, new byte[] { 0x01 }, packet.Sequence), "RES_DID_INFO (0x95, 0x01) [DID]"); break; case 0x16: // CMD_REQ_DID_INFO - SendPacket(session, DctPacket.Create(0x96, new byte[] { 0x01 }, packet.Sequence)); - LogRx(rxLog, $"[DID] CMD_REQ_DID_INFO (0x16) -> [SEND] RES_REQ_DID_INFO (0x96, 0x01)"); + SendResponse(session, DctPacket.Create(0x96, new byte[] { 0x01 }, packet.Sequence), "RES_REQ_DID_INFO (0x96, 0x01) [DID]"); break; case 0x17: // CMD_SETUP_DID_INFO - SendPacket(session, DctPacket.Create(0x97, new byte[] { 0x01 }, packet.Sequence)); - LogRx(rxLog, $"[DID] CMD_SETUP_DID_INFO (0x17) -> [SEND] RES_SETUP_DID_INFO (0x97, 0x01)"); + SendResponse(session, DctPacket.Create(0x97, new byte[] { 0x01 }, packet.Sequence), "RES_SETUP_DID_INFO (0x97, 0x01) [DID]"); break; case 0x18: // CMD_REQ_ALL_DID_INFO - SendPacket(session, DctPacket.Create(0x98, new byte[] { 0x01 }, packet.Sequence)); - LogRx(rxLog, $"[DID] CMD_REQ_ALL_DID_INFO (0x18) -> [SEND] RES_REQ_ALL_DID_INFO (0x98, 0x01)"); + SendResponse(session, DctPacket.Create(0x98, new byte[] { 0x01 }, packet.Sequence), "RES_REQ_ALL_DID_INFO (0x98, 0x01) [DID]"); break; case 0x20: // CMD_START_TRANSMIT_DATA - SendPacket(session, DctPacket.Create(0xA0, new byte[] { 0x01 }, packet.Sequence)); - LogRx(rxLog, $"[CONTROL] CMD_START_TRANSMIT_DATA (0x20) -> [SEND] RES_START_TRANSMIT_DATA (0xA0, 0x01) ACK 및 스트리밍 개시"); - OnStartSimRequested?.Invoke(); + byte startType = packet.Payload.Length > 0 ? packet.Payload[0] : (byte)0x01; + byte startStatus = session.ReadyToMeasure ? (byte)0x01 : (byte)0x02; + lock (session.SendLock) + { + int revision = ++session.ControlRevision; + QueueMeasurementResponse(session, DctPacket.Create(0xA0, new byte[] { startStatus, startType }, packet.Sequence), () => + { + lock (session.SendLock) + { + if (revision != session.ControlRevision || startStatus != 0x01) return; + session.SetStreaming(true); + OnStartPlaybackRequested?.Invoke(); + } + }); + } break; case 0x21: // CMD_STOP_TRANSMIT_DATA - SendPacket(session, DctPacket.Create(0xA1, new byte[] { 0x01 }, packet.Sequence)); - LogRx(rxLog, $"[CONTROL] CMD_STOP_TRANSMIT_DATA (0x21) -> [SEND] RES_STOP_TRANSMIT_DATA (0xA1, 0x01) ACK 및 스트리밍 정지"); - OnStopSimRequested?.Invoke(); + lock (session.SendLock) + { + ++session.ControlRevision; + session.SetStreaming(false); + // Stop playback immediately. A congested Wi-Fi Direct + // downstream must not keep producing frames while A1 is + // waiting for its socket write to complete. + OnStopPlaybackRequested?.Invoke(); + QueueMeasurementResponse(session, DctPacket.Create(0xA1, new byte[] { 0x01 }, packet.Sequence), null); + } break; case 0x23: // CMD_CAN_FRAME_ACK + session.Acknowledge(0x23); TotalCanAcks++; if (ShowRxPackets && TotalCanAcks % 20 == 1) - Log($"[📥 RX ACK <- {session.IpPort}] CMD_CAN_FRAME_ACK (0x23) 누적 {TotalCanAcks}건 수신"); + Log($"[RX <<<] [수신 ACK <- {session.IpPort}] CMD_CAN_FRAME_ACK (0x23) 누적 {TotalCanAcks}건 수신"); break; case 0x24: // CMD_SM_DATA_ACK + session.Acknowledge(0x24); TotalSmAcks++; if (ShowRxPackets && TotalSmAcks % 20 == 1) - Log($"[📥 RX ACK <- {session.IpPort}] CMD_SM_DATA_ACK (0x24) 누적 {TotalSmAcks}건 수신"); + Log($"[RX <<<] [수신 ACK <- {session.IpPort}] CMD_SM_DATA_ACK (0x24) 누적 {TotalSmAcks}건 수신"); break; case 0x25: // CMD_SAVED_LOGFILE_INFO @@ -488,56 +620,91 @@ namespace DCT_test break; case 0x32: // CMD_FW_KEY_VALUE (host response; no ACK) - LogRx(rxLog, $"[P3 ASK] CMD_FW_KEY_VALUE (0x32, {packet.Payload.Length}B) 수신"); + Log($"[RX <<<] [수신 <- {session.IpPort}] CMD_FW_KEY_VALUE (0x32, {packet.Payload.Length}B) [P3 ASK]"); break; case 0x40: // CMD_XCP_USE - SendPacket(session, DctPacket.Create(0xC0, new byte[] { 0x01 }, packet.Sequence)); - LogRx(rxLog, $"[XCP] CMD_XCP_USE (0x40, Use: {packet.Payload.FirstOrDefault()}) -> [SEND] RES_XCP_USE (0xC0, 0x01)"); + byte xcpMode = packet.Payload.Length == 1 ? packet.Payload[0] : (byte)0x00; + bool xcpUseOk = xcpMode == 0x01 || xcpMode == 0x02; + if (xcpUseOk) + { + session.XcpUseMode = xcpMode; + // 새 USE 명령은 이전 프로젝트의 IF_DATA/측정 목록을 무효화한다. + session.XcpConfigurationMask = xcpMode == 0x01 ? 0x01 : 0x00; + } + SendResponse(session, DctPacket.Create(0xC0, new byte[] { xcpUseOk ? (byte)0x01 : (byte)0x00 }, packet.Sequence), + $"RES_XCP_USE (0xC0, 0x{(xcpUseOk ? 1 : 0):X2}, mode: 0x{xcpMode:X2}) [XCP]"); break; case 0x41: // CMD_XCP_IFDATAS - if (packet.Payload.Length > 0) + bool xcpIfDataOk = session.XcpUseMode == 0x01 && + packet.Payload.Length == 9 && + (packet.Payload[0] == 0x00 || packet.Payload[0] == 0x01); + if (xcpIfDataOk) + { + session.XcpConfigurationMask |= 0x02; + } + else { - lock (_xcpConfigLock) _xcpIsMsbFirst = packet.Payload[0] == 0x01; + session.XcpConfigurationMask &= ~0x02; } - SendPacket(session, DctPacket.Create(0xC1, new byte[] { 0x01 }, packet.Sequence)); - LogRx(rxLog, $"[XCP] CMD_XCP_IFDATAS (0x41, CAN IDs {packet.Payload.Length}B) -> [SEND] RES_XCP_IFDATAS (0xC1, 0x01)"); + SendResponse(session, DctPacket.Create(0xC1, new byte[] { xcpIfDataOk ? (byte)0x01 : (byte)0x00 }, packet.Sequence), + $"RES_XCP_IFDATAS (0xC1, 0x{(xcpIfDataOk ? 1 : 0):X2}, CAN IDs {packet.Payload.Length}B) [XCP]"); break; case 0x42: // CMD_XCP_MEASUREMENTS - bool xcpConfigOk = TryApplyXcpMeasurements(packet.Payload, out int xcpItemCount); - SendPacket(session, DctPacket.Create(0xC2, new byte[] { xcpConfigOk ? (byte)0x01 : (byte)0x00 }, packet.Sequence)); - LogRx(rxLog, $"[XCP] CMD_XCP_MEASUREMENTS (0x42, Items {xcpItemCount}, {packet.Payload.Length}B) -> [SEND] RES_XCP_MEASUREMENTS (0xC2, 0x{(xcpConfigOk ? 1 : 0):X2})"); + int xcpItemCount = 0; + bool xcpConfigOk = session.XcpUseMode == 0x01 && + (session.XcpConfigurationMask & 0x02) != 0 && + TryApplyXcpMeasurements(packet.Payload, out xcpItemCount); + if (xcpConfigOk) + session.XcpConfigurationMask |= 0x04; + else + session.XcpConfigurationMask &= ~0x04; + SendResponse(session, DctPacket.Create(0xC2, new byte[] { xcpConfigOk ? (byte)0x01 : (byte)0x00 }, packet.Sequence), + $"RES_XCP_MEASUREMENTS (0xC2, 0x{(xcpConfigOk ? 1 : 0):X2}, Items: {xcpItemCount}) [XCP]"); break; case 0x50: // CMD_XCP_MEASUREMENT_DATA (Host ACK) + session.Acknowledge(0x50); TotalXcpAcks++; break; case 0x51: // CMD_XCP_CHARACTERISTIC_DATA - SendPacket(session, DctPacket.Create(0xD1, new byte[] { 0x01 }, packet.Sequence)); - LogRx(rxLog, $"[XCP] CMD_XCP_CHARACTERISTIC_DATA (0x51) -> [SEND] RES_XCP_CHARACTERISTIC_DATA (0xD1, 0x01)"); + SendResponse(session, DctPacket.Create(0xD1, new byte[] { 0x01 }, packet.Sequence), "RES_XCP_CHARACTERISTIC_DATA (0xD1, 0x01) [XCP]"); break; case 0x52: // CMD_XCP_REQ_CHARACTERISTIC_DATA - SendPacket(session, DctPacket.Create(0xD2, new byte[] { 0x00, 0x00 }, packet.Sequence)); - LogRx(rxLog, $"[XCP] CMD_XCP_REQ_CHARACTERISTIC_DATA (0x52) -> [SEND] RES_XCP_REQ_CHARACTERISTIC_DATA (0xD2)"); + SendResponse(session, DctPacket.Create(0xD2, new byte[] { 0x00, 0x00 }, packet.Sequence), "RES_XCP_REQ_CHARACTERISTIC_DATA (0xD2) [XCP]"); break; default: - LogRx(rxLog, $"[📥 RX <- {session.IpPort}] 0x{packet.CommandId:X2} ({cmdName}, Payload: {packet.Payload.Length}B)"); + Log($"[RX <<<] [수신 <- {session.IpPort}] 0x{packet.CommandId:X2} ({cmdName}, Payload: {packet.Payload.Length}B)"); break; } } - private void LogRx(string rxDetail, string actionLog) + private void QueueMeasurementResponse(ClientSession session, DctPacket packet, Action onSent) + { + if (!session.QueueControl(packet.Encode(), sent => + { + if (!sent) { Log($"[WARN] 계측 응답 전송 실패: {session.IpPort}, CMD=0x{packet.CommandId:X2}"); return; } + Log($"[TX >>>] [CONTROL -> {session.IpPort}] CMD=0x{packet.CommandId:X2}, payload={BitConverter.ToString(packet.Payload)}"); + onSent?.Invoke(); + })) Log($"[WARN] 계측 응답 큐 포화/종료: {session.IpPort}, CMD=0x{packet.CommandId:X2}"); + } + + private bool SendResponse(ClientSession session, DctPacket responsePkt, string description) { - if (ShowRxPackets && !string.IsNullOrEmpty(rxDetail)) + if (!SendPacket(session, responsePkt)) return false; + string logMsg = $"[TX >>>] [응답 -> {session.IpPort}] #{responsePkt.Sequence:D5} | {description}"; + if (ShowHexDump) { - Log(rxDetail); + string hex = BitConverter.ToString(responsePkt.Encode()).Replace("-", " "); + logMsg += $"\r\n └ [TX HEX]: {hex}"; } - Log(actionLog); + Log(logMsg); + return true; } private void HandleSavedLogInfo(ClientSession session, DctPacket packet) @@ -547,8 +714,8 @@ namespace DCT_test if (sub == 0x01) { int count = _maintenanceLogNames.Length; - SendPacket(session, DctPacket.Create(0xA5, new byte[] { 0x01, (byte)(count >> 8), (byte)count }, packet.Sequence)); - Log($"[P3 LOG] 파일 수 {count} 응답"); + SendResponse(session, DctPacket.Create(0xA5, new byte[] { 0x01, (byte)(count >> 8), (byte)count }, packet.Sequence), + $"RES_SAVED_LOGFILE_INFO (0xA5, 파일 수: {count}) [P3 LOG]"); } else if (sub == 0x02 && packet.Payload.Length >= 4) { @@ -561,7 +728,8 @@ namespace DCT_test response[2] = (byte)index; response[3] = (byte)name.Length; Buffer.BlockCopy(name, 0, response, 4, name.Length); - SendPacket(session, DctPacket.Create(0xA5, response, packet.Sequence)); + SendResponse(session, DctPacket.Create(0xA5, response, packet.Sequence), + $"RES_SAVED_LOGFILE_INFO (0xA5, 파일: '{_maintenanceLogNames[index]}') [P3 LOG]"); } } @@ -580,7 +748,8 @@ namespace DCT_test 0x01, (byte)(index >> 8), (byte)index, (byte)(size >> 24), (byte)(size >> 16), (byte)(size >> 8), (byte)size }; - SendPacket(session, DctPacket.Create(0xA6, response, packet.Sequence)); + SendResponse(session, DctPacket.Create(0xA6, response, packet.Sequence), + $"RES_SAVED_LOGFILE_COPY (0xA6, 총 크기: {size}B) [P3 LOG]"); } else if (sub == 0x02 && packet.Payload.Length >= 9) { @@ -598,7 +767,8 @@ namespace DCT_test response[4] = (byte)length; response[5] = status; Buffer.BlockCopy(source, (int)offset, response, 6, length); - SendPacket(session, DctPacket.Create(0xA6, response, packet.Sequence)); + SendResponse(session, DctPacket.Create(0xA6, response, packet.Sequence), + $"RES_SAVED_LOGFILE_COPY (0xA6, 오프셋: {offset}, 전송: {length}B, 상태: 0x{status:X2}) [P3 LOG]"); } } @@ -608,8 +778,8 @@ namespace DCT_test byte sub = packet.Payload[0]; if (sub == 0x04) { - SendPacket(session, DctPacket.Create(0xB1, new byte[] { 0x04, 0x00, 0x00 }, packet.Sequence)); - Log("[P3 FW] 저장 펌웨어 수 0 응답"); + SendResponse(session, DctPacket.Create(0xB1, new byte[] { 0x04, 0x00, 0x00 }, packet.Sequence), + "RES_FW_INFO (0xB1, 저장 펌웨어 수 0) [P3 FW]"); } else if (sub == 0x02 && packet.Payload.Length >= 7) { @@ -618,8 +788,8 @@ namespace DCT_test if (packet.Payload.Length < 7 + nameLength) return; session.FirmwareUploadName = Encoding.ASCII.GetString(packet.Payload, 7, nameLength); session.FirmwareReceivedBytes = 0; - SendPacket(session, DctPacket.Create(0xB1, new byte[] { 0x02, (byte)slot, 0x01 }, packet.Sequence)); - Log($"[P3 FW] '{session.FirmwareUploadName}' 메타데이터 승인"); + SendResponse(session, DctPacket.Create(0xB1, new byte[] { 0x02, (byte)slot, 0x01 }, packet.Sequence), + $"RES_FW_INFO (0xB1, 슬롯 {slot}, '{session.FirmwareUploadName}' 승인) [P3 FW]"); } else if (sub == 0x05 && packet.Payload.Length >= 7) { @@ -627,9 +797,8 @@ namespace DCT_test int dataLength = (packet.Payload[5] << 8) | packet.Payload[6]; if (dataLength < 1 || dataLength > 512 || packet.Payload.Length != 7 + dataLength) return; session.FirmwareReceivedBytes += dataLength; - SendPacket(session, DctPacket.Create(0xB1, new byte[] { 0x05, (byte)slot, 0x01 }, packet.Sequence)); - if (packet.Payload[4] == 0x02) - Log($"[P3 FW] '{session.FirmwareUploadName}' 테스트 수신 완료: {session.FirmwareReceivedBytes} bytes"); + SendResponse(session, DctPacket.Create(0xB1, new byte[] { 0x05, (byte)slot, 0x01 }, packet.Sequence), + $"RES_FW_INFO (0xB1, 슬롯 {slot}, 블록 {dataLength}B 수신, 누적: {session.FirmwareReceivedBytes}B) [P3 FW]"); } } @@ -640,14 +809,11 @@ namespace DCT_test itemCount = (payload[0] << 8) | payload[1]; if (payload.Length != 2 + itemCount * 5) return false; - var types = new byte[itemCount]; for (int i = 0; i < itemCount; i++) { byte type = payload[2 + i * 5]; if (GetXcpTypeSize(type) == 0) return false; - types[i] = type; } - lock (_xcpConfigLock) _xcpMeasurementTypes = types; return true; } @@ -663,52 +829,26 @@ namespace DCT_test } } - private static void AppendXcpValue(List output, byte type, double value, bool isMsbFirst) - { - byte[] bytes; - switch (type) - { - case 0: bytes = new byte[] { (byte)Math.Max(0, Math.Min(255, Math.Round(value))) }; break; - case 1: bytes = new byte[] { unchecked((byte)(sbyte)Math.Max(sbyte.MinValue, Math.Min(sbyte.MaxValue, Math.Round(value)))) }; break; - case 2: bytes = BitConverter.GetBytes((ushort)Math.Max(0, Math.Min(ushort.MaxValue, Math.Round(value)))); break; - case 3: bytes = BitConverter.GetBytes((short)Math.Max(short.MinValue, Math.Min(short.MaxValue, Math.Round(value)))); break; - case 4: bytes = BitConverter.GetBytes((uint)Math.Max(0, Math.Min(uint.MaxValue, Math.Round(value)))); break; - case 5: bytes = BitConverter.GetBytes((int)Math.Max(int.MinValue, Math.Min(int.MaxValue, Math.Round(value)))); break; - case 6: bytes = BitConverter.GetBytes((ulong)Math.Max(0, Math.Round(value))); break; - case 7: bytes = BitConverter.GetBytes((long)Math.Round(value)); break; - case 8: bytes = BitConverter.GetBytes(FloatToHalf((float)value)); break; - case 9: case 11: bytes = BitConverter.GetBytes((float)value); break; - case 10: bytes = BitConverter.GetBytes(value); break; - default: throw new ArgumentOutOfRangeException(nameof(type)); - } - if (bytes.Length > 1 && (BitConverter.IsLittleEndian == isMsbFirst)) Array.Reverse(bytes); - output.AddRange(bytes); - } - - private static ushort FloatToHalf(float value) - { - uint bits = BitConverter.ToUInt32(BitConverter.GetBytes(value), 0); - uint sign = (bits >> 16) & 0x8000; - int exponent = (int)((bits >> 23) & 0xFF) - 127 + 15; - uint mantissa = bits & 0x7FFFFF; - if (exponent <= 0) return (ushort)sign; - if (exponent >= 31) return (ushort)(sign | 0x7C00); - return (ushort)(sign | ((uint)exponent << 10) | (mantissa >> 13)); - } - - private void SendPacket(ClientSession session, DctPacket pkt) + private bool SendPacket(ClientSession session, DctPacket pkt) { try { - if (session.Client.Connected) + // Control responses are written by the dedicated per-client + // writer and have priority over queued telemetry. Waiting is + // bounded below Android's 3 s response budget; a timeout does + // not synchronously close the transport from this handler. + bool sent = session.SendControl(pkt.Encode(), ControlResponseTimeoutMilliseconds); + if (!sent) { - byte[] encoded = pkt.Encode(); - var stream = session.Client.GetStream(); - stream.Write(encoded, 0, encoded.Length); - session.SentPackets++; + Log($"[WARN] TCP 제어 응답 전송 시간 초과/실패 ({session.IpPort}, CMD 0x{pkt.CommandId:X2})"); } + return sent; + } + catch (Exception ex) + { + Log($"[ERROR] TCP 송신 실패 ({session.IpPort}, CMD 0x{pkt.CommandId:X2}): {ex.Message}"); + return false; } - catch { } } public static string GetCommandName(byte cmd) diff --git a/DCT_test/DCT_test/LogFileReader.cs b/DCT_test/DCT_test/LogFileReader.cs index c1f8b38..d22f769 100644 --- a/DCT_test/DCT_test/LogFileReader.cs +++ b/DCT_test/DCT_test/LogFileReader.cs @@ -1,143 +1,94 @@ using System; using System.Collections.Generic; +using System.Globalization; using System.IO; namespace DCT_test { - /// - /// 파일 로드 결과 데이터 구조체 - /// public class LogFileReadResult { public bool Success { get; set; } public string FileName { get; set; } public double FileSizeMb { get; set; } public int TotalRecords => Packets.Count; - - /// - /// 재생 시 전송될 바이너리 패킷 목록 (DctPacket.Create(cmd, payload, seq)) - /// public List Packets { get; set; } = new List(); - - /// - /// UI '송신 중인 신호 목록' 테이블에 표시할 신호 항목 목록 - /// - public List InitialSignalItems { get; set; } = new List(); - public string ErrorMessage { get; set; } } - /// - /// 로그 파일 리더 (사용자 구현 가이드 템플릿) - /// public class LogFileReader { public LogFileReadResult Load(string filePath) { - var result = new LogFileReadResult - { - FileName = Path.GetFileName(filePath) - }; - - if (!File.Exists(filePath)) - { - result.ErrorMessage = $"파일이 존재하지 않습니다: {filePath}"; - return result; - } - + var result = new LogFileReadResult { FileName = Path.GetFileName(filePath) }; try { + if (!string.Equals(Path.GetExtension(filePath), ".txt", StringComparison.OrdinalIgnoreCase)) + throw new InvalidDataException("현재는 DCT [Recv] 패킷 로그(.txt)만 지원합니다."); result.FileSizeMb = new FileInfo(filePath).Length / (1024.0 * 1024.0); - - // ========================================================================= - // [사용자 구현 가이드 영역] - // 1. filePath로부터 파일(CSV, TXT, MDF4, 바이너리 등)을 읽습니다. - // 2. 파싱한 신호명들을 result.InitialSignalItems에 추가합니다. - // 예: result.InitialSignalItems.Add(new TxSignalItem { Source = "[FILE]", Name = "신호명", Value = "0.0", Unit = "단위" }); - // 3. 각 시간/주기별 데이터를 DctPacket으로 인코딩하여 result.Packets에 추가합니다. - // 예: result.Packets.Add(DctPacket.Create(0xA3, payloadBytes, seq++)); - // ========================================================================= - - // TODO: 사용자 정의 파일 파싱 및 패킷 조립 로직을 이곳에 구현하세요. - // 20260907 add txt file - string fileExt = System.IO.Path.GetExtension(filePath); - - switch (fileExt.ToLower()) - { - case ".txt": - ParseTxtFile(filePath, result); - break; - - case ".csv": - break; - - case ".mf4": - break; - - default: - break; - } - - result.Success = result.Packets.Count > 0; - if (!result.Success && string.IsNullOrEmpty(result.ErrorMessage)) - { - result.ErrorMessage = "패킷 데이터가 생성되지 않았습니다."; - } + result.Success = ParseTxtFile(filePath, result) && result.Packets.Count > 0; + if (!result.Success && result.ErrorMessage == null) + result.ErrorMessage = "재생할 A3/A4/D0 수신 패킷이 없습니다."; } - catch (Exception ex) - { - result.Success = false; - result.ErrorMessage = $"파일 파싱 오류: {ex.Message}"; - } - + catch (Exception ex) { result.ErrorMessage = ex.Message; } return result; } public bool ParseTxtFile(string filePath, LogFileReadResult result) { - bool bRet = false; + int lineNumber = 0; + double? firstTimestamp = null; + double lastTimeOfDay = 0, dayOffset = 0, lastElapsed = 0; try { - if (File.Exists(filePath)) + foreach (string line in File.ReadLines(filePath)) { - //파일을 사용한 후 닫아주기위해 using으로 묶어준다. - StreamReader reader = new StreamReader(filePath); - string line; - - while ((line = reader.ReadLine()) != null) + lineNumber++; + if (!line.StartsWith("[Recv]", StringComparison.Ordinal)) continue; + int timestampEnd = line.IndexOf(']', 6); + if (timestampEnd < 0) throw new InvalidDataException("타임스탬프 오류"); + string timestampText = line.Substring(7, timestampEnd - 7); + if (!DateTime.TryParseExact(timestampText, "HH:mm:ss.fff", CultureInfo.InvariantCulture, + DateTimeStyles.None, out DateTime timestamp)) + throw new InvalidDataException("타임스탬프 형식 오류 (HH:mm:ss.fff)"); + double timeOfDay = timestamp.TimeOfDay.TotalMilliseconds; + if (timeOfDay < lastTimeOfDay - 12 * 60 * 60 * 1000) dayOffset += 24 * 60 * 60 * 1000; + lastTimeOfDay = timeOfDay; + string[] tokens = line.Substring(timestampEnd + 1).Split( + new[] { ' ', '\t' }, StringSplitOptions.RemoveEmptyEntries); + var frame = new List(tokens.Length); + foreach (string token in tokens) { - if (line.StartsWith("[Recv]") == true) - { - if (line.Substring(31, 2) == "A3" - || line.Substring(31, 2) == "A4" - || line.Substring(31, 2) == "D0") - { - - string data = line.Substring(22); - byte cmdId = Convert.ToByte(line.Substring(31, 2), 16); - int arrayLen = Convert.ToByte(line.Substring(25, 2), 16) << 8 | Convert.ToByte(line.Substring(28, 2), 16) - 1; - byte[] dataArray = new byte[arrayLen]; - - for (int i = 34, idx = 0; i < line.Length - 6; i += 3) - dataArray[idx++] = Convert.ToByte(line.Substring(i, 2), 16); - - DctPacket packet = DctPacket.Create(cmdId, dataArray); - - result.Packets.Add(packet); - } - } - - + if (token.Length != 2 || !byte.TryParse(token, NumberStyles.HexNumber, + CultureInfo.InvariantCulture, out byte value)) + throw new InvalidDataException("HEX 바이트 형식 오류"); + frame.Add(value); + } + if (frame.Count < 6 || frame[0] != DctPacket.START_BYTE || + frame.Count != ((frame[1] << 8) | frame[2]) + DctPacket.OVERHEAD_LENGTH) + throw new InvalidDataException("패킷 헤더/길이 오류"); + var packets = DctPacket.ParseStream(frame); + if (packets.Count != 1 || frame.Count != 0) + throw new InvalidDataException("체크섬 또는 종료 바이트 오류"); + var packet = packets[0]; + if (packet.CommandId == 0xA3 || packet.CommandId == 0xA4 || packet.CommandId == 0xD0) + { + double absolute = dayOffset + timeOfDay; + if (!firstTimestamp.HasValue) firstTimestamp = absolute; + // Preserve log gaps and midnight rollover; clamp minor clock jitter. + packet.LogElapsedMilliseconds = Math.Max(lastElapsed, absolute - firstTimestamp.Value); + lastElapsed = packet.LogElapsedMilliseconds; + result.Packets.Add(packet); } - bRet = true; } + return true; } catch (Exception ex) { - bRet = false; - result.ErrorMessage = $"ParseTxtFile error: {ex.Message}"; + result.Packets.Clear(); + result.Success = false; + result.ErrorMessage = $"로그 {lineNumber}행: {ex.Message}"; + return false; } - return bRet; } } } diff --git a/DCT_test/DCT_test/LogPlaybackClock.cs b/DCT_test/DCT_test/LogPlaybackClock.cs new file mode 100644 index 0000000..23cb6cc --- /dev/null +++ b/DCT_test/DCT_test/LogPlaybackClock.cs @@ -0,0 +1,26 @@ +using System; + +namespace DCT_test +{ + /// Virtual source-log clock. Pauses do not consume source time. + public sealed class LogPlaybackClock + { + private double lastRealMilliseconds; + public double PositionMilliseconds { get; private set; } + + public void Reset(double realMilliseconds) + { + lastRealMilliseconds = realMilliseconds; + PositionMilliseconds = 0; + } + + public void Advance(double realMilliseconds, double speed, bool paused) + { + if (!paused) PositionMilliseconds += Math.Max(0, realMilliseconds - lastRealMilliseconds) * Math.Max(0.01, speed); + lastRealMilliseconds = realMilliseconds; + } + + public double DelayUntil(double sourceMilliseconds, double speed) => + Math.Max(0, sourceMilliseconds - PositionMilliseconds) / Math.Max(0.01, speed); + } +} diff --git a/DCT_test/DCT_test/MainWindow.xaml b/DCT_test/DCT_test/MainWindow.xaml index 8d67da6..5efa03a 100644 --- a/DCT_test/DCT_test/MainWindow.xaml +++ b/DCT_test/DCT_test/MainWindow.xaml @@ -23,10 +23,10 @@ FontWeight="Bold" Foreground="#1E293B" Padding="8" Margin="0,0,0,8" Background="White" BorderBrush="#CBD5E1"> - - + -