Browse Source

Improve Android Wi-Fi Direct, charts, calibration and test playback

master
seuuung 1 month ago
parent
commit
ac7571e7c2
  1. 1
      .gitignore
  2. 210
      Android/app/src/androidTest/java/com/dct/app/background/BackgroundMeasurementServiceTest.kt
  3. 22
      Android/app/src/androidTest/java/com/dct/app/presentation/dialog/ProjectDialogHoverTest.kt
  4. 42
      Android/app/src/androidTest/java/com/dct/app/presentation/monitor/components/FormulaColorUiTest.kt
  5. 258
      Android/app/src/androidTest/java/com/dct/app/presentation/monitor/components/FormulaColumnsUiTest.kt
  6. 13
      Android/app/src/androidTest/java/com/dct/app/presentation/monitor/components/GraphSettingsUiTest.kt
  7. 62
      Android/app/src/androidTest/java/com/dct/app/presentation/monitor/components/RibbonButtonUiTest.kt
  8. 331
      Android/app/src/androidTest/java/com/dct/app/presentation/monitor/components/WorkspaceTouchNavigationUiTest.kt
  9. 85
      Android/app/src/androidTest/java/com/dct/app/presentation/signaltree/SignalTableTextAlignmentUiTest.kt
  10. 163
      Android/app/src/androidTest/java/com/dct/app/presentation/signaltree/TableAutoFitUiTest.kt
  11. 90
      Android/app/src/androidTest/java/com/dct/app/presentation/signaltree/TableTextOverflowUiTest.kt
  12. 18
      Android/app/src/main/AndroidManifest.xml
  13. 15
      Android/app/src/main/java/com/dct/app/DctApplication.kt
  14. 125
      Android/app/src/main/java/com/dct/app/background/DctSessionRuntime.kt
  15. 48
      Android/app/src/main/java/com/dct/app/background/MeasurementExecutionState.kt
  16. 161
      Android/app/src/main/java/com/dct/app/background/MeasurementForegroundService.kt
  17. 9
      Android/app/src/main/java/com/dct/app/core/common/PacketDistributor.kt
  18. 137
      Android/app/src/main/java/com/dct/app/core/common/PacketPipelineMetrics.kt
  19. 19
      Android/app/src/main/java/com/dct/app/core/engine/XcpDaqStreamDecoder.kt
  20. 9
      Android/app/src/main/java/com/dct/app/core/error/DctError.kt
  21. 46
      Android/app/src/main/java/com/dct/app/core/formula/FormulaEvaluator.kt
  22. 111
      Android/app/src/main/java/com/dct/app/core/formula/FormulaTelemetryEngine.kt
  23. 28
      Android/app/src/main/java/com/dct/app/core/formula/FormulaVariableLookup.kt
  24. 145
      Android/app/src/main/java/com/dct/app/core/formula/SMLinEvaluator.kt
  25. 48
      Android/app/src/main/java/com/dct/app/core/theme/DctColors.kt
  26. 11
      Android/app/src/main/java/com/dct/app/core/theme/SignalColorPresets.kt
  27. 105
      Android/app/src/main/java/com/dct/app/data/communication/OrderedPacketWriter.kt
  28. 822
      Android/app/src/main/java/com/dct/app/data/communication/WifiDirectManager.kt
  29. 40
      Android/app/src/main/java/com/dct/app/data/communication/WifiDirectRoute.kt
  30. 55
      Android/app/src/main/java/com/dct/app/data/communication/WifiDirectState.kt
  31. 248
      Android/app/src/main/java/com/dct/app/data/communication/WifiSocketManager.kt
  32. 71
      Android/app/src/main/java/com/dct/app/data/logger/CsvMeasurementLogger.kt
  33. 3
      Android/app/src/main/java/com/dct/app/data/logger/MeasurementLogPaths.kt
  34. 58
      Android/app/src/main/java/com/dct/app/data/logger/MeasurementSafExports.kt
  35. 205
      Android/app/src/main/java/com/dct/app/data/logger/Mf4MeasurementLogger.kt
  36. 18
      Android/app/src/main/java/com/dct/app/data/logger/PcMeasurementNames.kt
  37. 220
      Android/app/src/main/java/com/dct/app/data/logger/PcOperationalLogger.kt
  38. 94
      Android/app/src/main/java/com/dct/app/data/maintenance/MaintenanceService.kt
  39. 3
      Android/app/src/main/java/com/dct/app/data/mdb/MdbSchemaParser.kt
  40. 126
      Android/app/src/main/java/com/dct/app/data/parser/A2lParser.kt
  41. 2
      Android/app/src/main/java/com/dct/app/data/protocol/DctCalibrationProtocol.kt
  42. 2
      Android/app/src/main/java/com/dct/app/data/protocol/DctPacketCodec.kt
  43. 70
      Android/app/src/main/java/com/dct/app/data/protocol/DctProtocolRequests.kt
  44. 56
      Android/app/src/main/java/com/dct/app/data/protocol/DctResponseValidator.kt
  45. 29
      Android/app/src/main/java/com/dct/app/data/protocol/DctStreamPayloadParser.kt
  46. 112
      Android/app/src/main/java/com/dct/app/data/protocol/MdbRecordDecoder.kt
  47. 51
      Android/app/src/main/java/com/dct/app/data/protocol/SlidingRingBufferParser.kt
  48. 340
      Android/app/src/main/java/com/dct/app/data/repository/DpcProjectManager.kt
  49. 28
      Android/app/src/main/java/com/dct/app/data/repository/DpcWorkspaceJson.kt
  50. 514
      Android/app/src/main/java/com/dct/app/data/repository/PacketRepositoryImpl.kt
  51. 508
      Android/app/src/main/java/com/dct/app/data/repository/PcDpcCompatibility.kt
  52. 44
      Android/app/src/main/java/com/dct/app/data/ringbuffer/TimeSeriesRingBuffer.kt
  53. 6
      Android/app/src/main/java/com/dct/app/domain/model/chart/ChartAxisConfig.kt
  54. 70
      Android/app/src/main/java/com/dct/app/domain/model/chart/ChartDrawingRules.kt
  55. 2
      Android/app/src/main/java/com/dct/app/domain/model/chart/ChartSignalSeries.kt
  56. 91
      Android/app/src/main/java/com/dct/app/domain/model/chart/DownsamplingAlgorithms.kt
  57. 18
      Android/app/src/main/java/com/dct/app/domain/model/chart/MultiAxisCoordinateTransformer.kt
  58. 61
      Android/app/src/main/java/com/dct/app/domain/model/chart/TimeAxisViewport.kt
  59. 5
      Android/app/src/main/java/com/dct/app/domain/model/chart/TimeWindowPreset.kt
  60. 5
      Android/app/src/main/java/com/dct/app/domain/model/dashboard/TileWidgetItem.kt
  61. 3
      Android/app/src/main/java/com/dct/app/domain/model/formula/FormulaDefinition.kt
  62. 40
      Android/app/src/main/java/com/dct/app/domain/model/mdb/CanSignalKey.kt
  63. 15
      Android/app/src/main/java/com/dct/app/domain/model/mdb/MdbModels.kt
  64. 3
      Android/app/src/main/java/com/dct/app/domain/model/project/DpcProject.kt
  65. 5
      Android/app/src/main/java/com/dct/app/domain/model/project/DpcWorkspaceState.kt
  66. 11
      Android/app/src/main/java/com/dct/app/domain/model/protocol/DctCommand.kt
  67. 4
      Android/app/src/main/java/com/dct/app/domain/model/protocol/DctMeasurementConfiguration.kt
  68. 13
      Android/app/src/main/java/com/dct/app/domain/model/protocol/DctPacket.kt
  69. 11
      Android/app/src/main/java/com/dct/app/domain/repository/PacketRepository.kt
  70. 550
      Android/app/src/main/java/com/dct/app/domain/usecase/chart/ChartBatchRenderer.kt
  71. 78
      Android/app/src/main/java/com/dct/app/domain/usecase/chart/ChartTelemetryResolver.kt
  72. 32
      Android/app/src/main/java/com/dct/app/domain/usecase/chart/MultiAxisChartController.kt
  73. 133
      Android/app/src/main/java/com/dct/app/presentation/chart/GraphYAxisDialog.kt
  74. 701
      Android/app/src/main/java/com/dct/app/presentation/chart/MultiXGraphCanvas.kt
  75. 6
      Android/app/src/main/java/com/dct/app/presentation/chart/MultiXGraphView.kt
  76. 549
      Android/app/src/main/java/com/dct/app/presentation/dialog/CommunicationDeviceListDialogs.kt
  77. 19
      Android/app/src/main/java/com/dct/app/presentation/dialog/ProjectFileDialogs.kt
  78. 152
      Android/app/src/main/java/com/dct/app/presentation/dialog/WifiDirectDeviceListDialog.kt
  79. 2
      Android/app/src/main/java/com/dct/app/presentation/dialog/WifiManualConnectionForm.kt
  80. 95
      Android/app/src/main/java/com/dct/app/presentation/main/BackgroundExecutionPermissionDialogs.kt
  81. 39
      Android/app/src/main/java/com/dct/app/presentation/main/DctTabletApp.kt
  82. 154
      Android/app/src/main/java/com/dct/app/presentation/main/MainActivity.kt
  83. 1281
      Android/app/src/main/java/com/dct/app/presentation/main/MainViewModel.kt
  84. 260
      Android/app/src/main/java/com/dct/app/presentation/maintenance/LogTab.kt
  85. 35
      Android/app/src/main/java/com/dct/app/presentation/maintenance/MaintenanceDialog.kt
  86. 361
      Android/app/src/main/java/com/dct/app/presentation/maintenance/UdsDidTab.kt
  87. 452
      Android/app/src/main/java/com/dct/app/presentation/maintenance/VehicleDbTab.kt
  88. 33
      Android/app/src/main/java/com/dct/app/presentation/mdi/MdiViewModel.kt
  89. 9
      Android/app/src/main/java/com/dct/app/presentation/mdi/MdiWindow.kt
  90. 13
      Android/app/src/main/java/com/dct/app/presentation/mdi/WindowReveal.kt
  91. 353
      Android/app/src/main/java/com/dct/app/presentation/monitor/DiagnosticDataMonitorScreen.kt
  92. 10
      Android/app/src/main/java/com/dct/app/presentation/monitor/EmptyCanvasGuideView.kt
  93. 53
      Android/app/src/main/java/com/dct/app/presentation/monitor/components/AlphaSignal.kt
  94. 63
      Android/app/src/main/java/com/dct/app/presentation/monitor/components/AlphanumericalWindowView.kt
  95. 53
      Android/app/src/main/java/com/dct/app/presentation/monitor/components/FormulaEditDialog.kt
  96. 223
      Android/app/src/main/java/com/dct/app/presentation/monitor/components/FormulaWindowView.kt
  97. 209
      Android/app/src/main/java/com/dct/app/presentation/monitor/components/GraphWindowView.kt
  98. 106
      Android/app/src/main/java/com/dct/app/presentation/monitor/components/MdiSubWindowFrame.kt
  99. 22
      Android/app/src/main/java/com/dct/app/presentation/monitor/components/SensorModuleWindowView.kt
  100. 39
      Android/app/src/main/java/com/dct/app/presentation/monitor/components/TwoFingerFling.kt

1
.gitignore

@ -16,6 +16,7 @@
# ------------------------------------------------------------------------------
Android/.gradle/
Android/**/.gradle/
Android/.gradle-tmp/
Android/build/
Android/**/build/
Android/.kotlin/

210
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<DctApplication>()
@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<Socket>()
val peerError = AtomicReference<Throwable>()
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<MutableStateFlow<DeviceConnectionState>>(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<CopyOnWriteArrayList<CsvMeasurementLogger>>(session.viewModel, "activeCsvLoggers").add(logger)
field<CopyOnWriteArrayList<Mf4MeasurementLogger>>(session.viewModel, "activeMf4Loggers").add(mf4)
val ui = field<MutableStateFlow<MainUiState>>(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 <T> 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:")
}
}

22
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()

42
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) }
}
}

258
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<FormulaDefinition>()
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<FormulaDefinition>()
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<String, String>()
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) }
}
}

13
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<Double, Double>? = 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() {

62
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<String>()
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) }
}
}

331
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

85
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<TextLayoutResult>()
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)
}
}
}

163
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<TextLayoutResult>()
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)
}
}

90
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<TextLayoutResult>()
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))
}
}

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

@ -3,6 +3,12 @@
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_CONNECTED_DEVICE" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<!-- Continuous local equipment measurement cannot use FCM during Doze. User opt-in only. -->
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" android:maxSdkVersion="32" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" android:maxSdkVersion="29" />
@ -12,9 +18,11 @@
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
<uses-permission android:name="android.permission.CHANGE_WIFI_STATE" />
<uses-permission android:name="android.permission.CHANGE_WIFI_MULTICAST_STATE" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<uses-permission android:name="android.permission.NEARBY_WIFI_DEVICES" tools:targetApi="tiramisu" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" android:maxSdkVersion="32" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" android:maxSdkVersion="32" />
<uses-permission android:name="android.permission.NEARBY_WIFI_DEVICES"
android:usesPermissionFlags="neverForLocation" tools:targetApi="tiramisu" />
<uses-feature android:name="android.hardware.wifi.direct" android:required="false" />
<!-- Bluetooth Classic & BLE -->
<uses-permission android:name="android.permission.BLUETOOTH" android:maxSdkVersion="30" />
@ -23,6 +31,7 @@
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" tools:targetApi="s" />
<application
android:name=".DctApplication"
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
@ -30,6 +39,9 @@
android:supportsRtl="true"
android:requestLegacyExternalStorage="true"
android:theme="@style/Theme.DCT">
<service android:name=".background.MeasurementForegroundService"
android:exported="false" android:stopWithTask="false"
android:foregroundServiceType="connectedDevice" />
<activity
android:name=".presentation.main.MainActivity"
android:exported="true"

15
Android/app/src/main/java/com/dct/app/DctApplication.kt

@ -0,0 +1,15 @@
package com.dct.app
import android.app.Application
import com.dct.app.background.DctSessionRuntime
/** One process-wide session survives Activity recreation and a return from the launcher. */
class DctApplication : Application() {
val session: DctSessionRuntime by lazy { DctSessionRuntime(this) }
override fun onCreate() {
super.onCreate()
// Start the action log once per process, not per Activity recreation.
session.operationalLogger
}
}

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

@ -0,0 +1,125 @@
package com.dct.app.background
import android.content.Context
import android.content.Intent
import android.util.Log
import androidx.core.content.ContextCompat
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.ViewModelStore
import androidx.lifecycle.ViewModelStoreOwner
import com.dct.app.core.common.DefaultDispatcherProvider
import com.dct.app.core.common.EventBus
import com.dct.app.core.common.PacketPipelineMetrics
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.main.MainViewModel
import com.dct.app.presentation.mdi.MdiViewModel
import com.dct.app.presentation.signaltree.SignalTreeViewModel
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
/** Shared session owner: UI and foreground service never create competing sockets or decoders. */
class DctSessionRuntime(context: Context) : ViewModelStoreOwner {
private val appContext = context.applicationContext
override val viewModelStore = ViewModelStore()
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
val operationalLogger = com.dct.app.data.logger.PcOperationalLogger(
directory = {
val preferred = java.io.File(com.dct.app.data.repository.DpcProjectManager.ensureDctWorkDirectories(appContext), "Log")
val candidates = listOfNotNull(preferred,
appContext.getExternalFilesDir(null)?.let { java.io.File(it, "Documents/DCT Work/Log") },
java.io.File(appContext.filesDir, "Documents/DCT Work/Log"))
candidates.firstOrNull { (it.isDirectory || it.mkdirs()) && it.canWrite() }
?: throw java.io.IOException("Log 폴더에 접근할 수 없습니다.")
},
onFailure = { Log.e("PcOperationalLogger", "운영 로그 저장 실패 (통신·계측은 유지)", it) }
)
private var observing = false
private var uiVisible = false
@Volatile private var serviceRequested = false
private val _serviceRunning = MutableStateFlow(false)
val serviceRunning: StateFlow<Boolean> = _serviceRunning.asStateFlow()
val viewModel: MainViewModel by lazy {
val factory = object : ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(modelClass: Class<T>): 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<MeasurementExecutionState> by lazy {
combine(viewModel.uiState, viewModel.packetRepository.connectionState,
viewModel.wifiSocketManager.isConnected, viewModel.wifiDirectState) { ui, connection, socket, p2p ->
MeasurementExecutionState.from(ui, connection, socket, p2p.phase, p2p.link != null)
}.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 }
}

48
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)
}
}
}

161
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()
}
}
}

9
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<DctPacket>(
replay = 0,
extraBufferCapacity = 256,
onBufferOverflow = BufferOverflow.DROP_OLDEST
onBufferOverflow = BufferOverflow.SUSPEND
)
val packetStream: SharedFlow<DctPacket> = _packetStream.asSharedFlow()
fun dispatchPacket(packet: DctPacket) {
_packetStream.tryEmit(packet)
suspend fun dispatchPacket(packet: DctPacket) {
_packetStream.emit(packet)
}
}

137
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
}
}
}

19
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}"

9
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 데이터베이스 오류

46
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<String, Double>): 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<Token> =
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<String> {
if (expression.trim().startsWith("LIN(", ignoreCase = true)) {
return SMLinEvaluator.dependencies(expression)
}
return compile(expression).filterIsInstance<Token.Variable>().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<Token>): Boolean {
@ -280,18 +288,15 @@ object FormulaEvaluator {
return output
}
private fun evaluateRpn(rpn: List<Token>, variables: Map<String, Double>): Double {
private fun evaluateRpn(rpn: List<Token>, resolve: (String) -> Double?): Double {
val stack = ArrayDeque<Double>()
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)
}
}

111
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<FormulaDefinition>? = null
private var lookup = FormulaVariableLookup(emptyMap())
private val consumers = HashMap<String, MutableSet<Int>>()
private var rank = emptyMap<Int, Int>()
private var blocked = emptySet<Int>()
private var constants = emptyList<Int>()
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<FormulaDefinition>, values: MutableMap<String, Double>, updated: MutableSet<String>) {
val hasPacketInputs = updated.isNotEmpty()
val changed = definitions != formulas
if (changed) rebuild(formulas, values)
lookup.bind(values, updated)
val affected = linkedSetOf<Int>()
val pending = ArrayDeque<Int>()
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<FormulaDefinition>, values: MutableMap<String, Double>) {
definitions?.flatMap(::aliases)?.forEach(values::remove)
definitions = formulas
lookup = FormulaVariableLookup(values)
consumers.clear()
val owners = HashMap<String, Int>()
val foldedOwners = HashMap<String, Int>()
val ambiguous = hashSetOf<Int>()
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<Int>() }
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<Int>()
formulas.indices.filter { counts[it] == 0 }.forEach(queue::add)
val ordered = mutableMapOf<Int, Int>()
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<Int>()
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}")
}

28
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<String, Double>) {
private val aliases = HashMap<String, String>()
private var indexed = false
fun bind(values: Map<String, Double>, updated: Set<String>) {
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)
}
}

145
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<String, Double>): 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<String>, val points: List<Point>)
private val cache = ConcurrentHashMap<String, Compiled>()
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<Point>()
val points = ArrayList<Point>()
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))
}
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
Compiled(channels.toList(), points)
}
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<String> = compile(expr).channels.toSet()
internal fun clearCache() = cache.clear()
fun evaluate(expr: String, variables: Map<String, Double>): 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
}
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
}
return 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
}
}

48
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()

11
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<Long> = 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) }
}

105
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<Boolean> = 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<Request>(
maxOf(8, capacity / 4),
onUndeliveredElement = { it.result.complete(false) }
)
private val streamQueue = Channel<Request>(
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()
}
}
}

822
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<Boolean> = _isP2pEnabled.asStateFlow()
private val _isSearching = MutableStateFlow(false)
val isSearching: StateFlow<Boolean> = _isSearching.asStateFlow()
private val _peers = MutableStateFlow<List<WifiP2pDevice>>(emptyList())
val peers: StateFlow<List<WifiP2pDevice>> = _peers.asStateFlow()
private val _lanServers = MutableStateFlow<List<DiscoveredLanServer>>(emptyList())
val lanServers: StateFlow<List<DiscoveredLanServer>> = _lanServers.asStateFlow()
private val _connectionInfo = MutableStateFlow<WifiP2pInfo?>(null)
val connectionInfo: StateFlow<WifiP2pInfo?> = _connectionInfo.asStateFlow()
private val _groupInfo = MutableStateFlow<WifiP2pGroup?>(null)
val groupInfo: StateFlow<WifiP2pGroup?> = _groupInfo.asStateFlow()
private val _thisDevice = MutableStateFlow<WifiP2pDevice?>(null)
val thisDevice: StateFlow<WifiP2pDevice?> = _thisDevice.asStateFlow()
private val _connectedDeviceName = MutableStateFlow<String?>(null)
val connectedDeviceName: StateFlow<String?> = _connectedDeviceName.asStateFlow()
private val _connectedHostIp = MutableStateFlow<String?>(null)
val connectedHostIp: StateFlow<String?> = _connectedHostIp.asStateFlow()
private val _lastErrorReason = MutableStateFlow<Int?>(null)
val lastErrorReason: StateFlow<Int?> = _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
}
}
}
/**
* BroadcastReceiver 등록 및 UDP LAN 디스커버리 시작
*/
fun registerReceiver() {
if (receiver != null) return
val intentFilter = IntentFilter().apply {
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<Network, LinkProperties>()
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<WifiDirectUiState> = _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}")
}
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)
}
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()
}
}
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()
}
/** 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)
}
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)
// 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)
}
_thisDevice.value = thisDev
if (_uiState.value.permissionGranted) {
if (Build.VERSION.SDK_INT >= 29) manager!!.requestDeviceInfo(ch) { device ->
if (generation == epoch && !closed) publish { it.copy(thisDevice = device) }
}
synchronize()
requestPeers()
}
}
}
try {
context.registerReceiver(receiver, intentFilter)
refreshConnectionAndPeers()
} catch (_: Exception) {}
startUdpDiscovery()
private fun updateRequirements() {
val granted = WifiDirectRequirements.runtimePermissions(Build.VERSION.SDK_INT).all {
ContextCompat.checkSelfPermission(context, it) == PackageManager.PERMISSION_GRANTED
}
private var multicastLock: android.net.wifi.WifiManager.MulticastLock? = null
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()
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()
}
} catch (_: Throwable) {}
udpDiscoveryJob = scope.launch {
var socket: DatagramSocket? = null
try {
socket = DatagramSocket(null).apply {
reuseAddress = true
bind(InetSocketAddress(6005))
soTimeout = 3000
}
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
val server = DiscoveredLanServer(
deviceName = srvName,
hostIp = srvIp,
port = srvPort,
lastSeen = System.currentTimeMillis()
)
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 stopUdpDiscovery() {
udpDiscoveryJob?.cancel()
udpDiscoveryJob = null
try {
if (multicastLock?.isHeld == true) {
multicastLock?.release()
}
multicastLock = null
} catch (_: Throwable) {}
}
/**
* 연결 상태, 그룹 정보, 피어 목록을 일괄 요청하여 동기화
*/
@SuppressLint("MissingPermission")
fun refreshConnectionAndPeers() {
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
}
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 {
_connectedDeviceName.value = null
_connectedHostIp.value = null
_groupInfo.value = null
pendingIpCallback = null
val changed = !_uiState.value.enabled
publish { it.copy(enabled = true, error = if (changed) null else it.error) }
if (changed && _uiState.value.permissionGranted) synchronize()
}
}
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 }
}
} else {
val owner = group.owner
if (owner != null) {
_connectedDeviceName.value = owner.deviceName.ifBlank { owner.deviceAddress }
}
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<WifiP2pDevice>(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()
}
wifiP2pManager?.requestPeers(ch) { peerList: WifiP2pDeviceList? ->
val deviceList = peerList?.deviceList?.toList() ?: emptyList()
_peers.value = deviceList
}
}
/**
* 앱 실행 시 이미 Wi-Fi Direct 그룹이 연결되어 있는지 확인하고 콜백 반환 (isGroupOwner 포함)
*/
/** Publish info/group atomically, and ignore callbacks from superseded queries/channels. */
@SuppressLint("MissingPermission")
fun checkExistingConnection(
onConnected: (deviceName: String, hostIp: String, isGroupOwner: Boolean) -> Unit
) {
private fun synchronize(after: (() -> Unit)? = null) {
if (after != null) syncWaiters.add(after)
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"
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 상태 응답을 확인하지 못했습니다. 새로고침해 주세요.") }
}
}
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
}
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() }
}
_connectedDeviceName.value = devName
onConnected(devName, hostIp, isOwner)
}
}
}
}
@SuppressLint("MissingPermission")
fun checkExistingConnection(
onConnected: (deviceName: String, hostIp: String) -> Unit
) {
checkExistingConnection { dev, ip, _ -> onConnected(dev, ip) }
}
/**
* BroadcastReceiver 해제
*/
fun unregisterReceiver() {
stopUdpDiscovery()
receiver?.let {
try {
context.unregisterReceiver(it)
} catch (_: Exception) {}
receiver = null
}
}
/**
* 주변 Wi-Fi Direct 피어 실제 탐색 시작 (일시적 BUSY 시 1회 자동 재시도)
*/
@SuppressLint("MissingPermission")
fun discoverPeers(
isRetry: Boolean = false,
onSuccess: () -> Unit = {},
onFailure: (Int) -> Unit = {}
) {
private fun requestPeers() {
updateRequirements()
if (!_uiState.value.permissionGranted || !_uiState.value.locationEnabled) return
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()
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()
}
}
}
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) } }))
}
}
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() }))
}
}
/** 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 }
}
guarded("연결 요청") {
manager!!.connect(ch, config, listener("연결 요청", { token == operation },
success = { requestPeers(); synchronize() },
failure = { deadline?.cancel(); synchronize() }))
}
}
}
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()
}
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()
}
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)
/** 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)
}
})
}
/**
* 피어 탐색 정지
*/
fun stopPeerDiscovery() {
val ch = channel ?: return
wifiP2pManager?.stopPeerDiscovery(ch, object : WifiP2pManager.ActionListener {
override fun onSuccess() {
_isSearching.value = false
}
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) {
_isSearching.value = false
}
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()
}
}
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}") }
}
}
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)
}
/** 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) }
}
/**
* 선택한 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
}
pendingIpCallback = onSuccessWithIp
wifiP2pManager?.connect(ch, config, object : WifiP2pManager.ActionListener {
override fun onSuccess() {
_connectedDeviceName.value = device.deviceName.ifBlank { device.deviceAddress }
}
override fun onFailure(reasonCode: Int) {
_connectedDeviceName.value = null
_connectedHostIp.value = null
pendingIpCallback = null
onFailure(reasonCode)
closed = true
epoch++; query++; operation++; discovery++
syncWaiters.clear()
scope.cancel()
if (networkRegistered) {
connectivityManager?.unregisterNetworkCallback(networkCallback)
networkRegistered = false
}
})
}
/**
* 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
if (registered) { context.unregisterReceiver(receiver); registered = false }
channel?.close()
channel = null
}
// 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()
}
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)
}
}

40
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)

55
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<WifiP2pDevice>
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<WifiP2pDevice> = 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<String> = 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())

248
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<DctPacket>(
replay = 0,
extraBufferCapacity = 256,
onBufferOverflow = BufferOverflow.DROP_OLDEST
onBufferOverflow = BufferOverflow.SUSPEND
)
val packetStream: SharedFlow<DctPacket> = _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 {
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 {
val route = wifiDirectRouteProvider?.invoke()
if (route != null && route.isGroupOwner != isGroupOwner) {
throw IOException("Wi-Fi Direct 그룹 역할이 변경되었습니다. 다시 연결해 주세요.")
}
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)
}, onFailure = { failConnection(generation, it, onFailure) }, route = route)
if (listening) onWaiting("TCP 서버가 포트 $port 에서 장비 접속을 기다리는 중입니다.")
return@launch
} else {
// 2. 태블릿이 Client인 경우: 상대 Group Owner IP로 ClientSocket 접속 시도
// (만약 접속 실패 시 상대방이 태블릿으로 접속할 수 있도록 서버 리스너도 안전망으로 가동)
try {
connectClientInternal(hostIp, port, generation)
onConnected(false, hostIp)
// 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 writer = synchronized(this) {
val socket = activeSocket ?: return false
if (!socket.isConnected || socket.isClosed) return false
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()
if (packet.transportSessionId != 0L && packet.transportSessionId != currentSessionId) return false
packetWriter ?: return false
}
false
return writer.send(codec.encode(packet))
}
/**
* 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

71
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<Pair<String, String>>, // List of (SignalName, Unit)
val customFileName: String? = null
val customFileName: String? = null,
private val pcColumns: List<PcCsvColumn>? = 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], ...
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<String>) {
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(" [")
append(unit)
append("]")
}
if (unit.isNotBlank()) append(" [$unit]")
}
}
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<Double?>) {
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()

3
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()
}

58
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
}
}

205
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<ChannelMeta>()
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<Double?>) {
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())

18
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}"
}

220
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<Entry>(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<Long, Connection>()
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++
}
}
}

94
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<Unit> = 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<Unit> = 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<RemoteLogFile> {
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
}
}

3
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") ?: ""

126
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<Int> = 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<SignalDefinition> {
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<String, CompuMethod>()
val vtabs = mutableMapOf<String, Map<Double, String>>()
@ -188,6 +220,7 @@ object A2lParser {
val measurements = mutableListOf<A2lMeasurement>()
val rawCharacteristics = mutableListOf<RawCharacteristic>()
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<Int>()
@ -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<String, RecordLayout> {
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)
}
}

2
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)) { "정수 데이터만 전송할 수 있습니다." }

2
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.
}
/**

70
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<SignalDefinition>): 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" }

56
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()) { "장비가 요청을 거절했거나 상태 응답이 잘못되었습니다." }

29
Android/app/src/main/java/com/dct/app/data/protocol/DctStreamPayloadParser.kt

@ -47,8 +47,8 @@ object DctStreamPayloadParser {
val frames = ArrayList<CanFrame>(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
}
}

112
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<DecodedSignal>) : DecodeResult
/** 정상 신호는 반영되었지만 MDB 정의와 payload 길이가 다른 경우 warning을 남깁니다. */
data class Success(
val signals: List<DecodedSignal>,
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<SignalDefinition>): 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<SignalDefinition>, 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<SignalDefinition>) {
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 신호 바이트 범위가 중복됩니다.")
fun decode(recordId: Int, data: ByteArray): DecodeResult = decodePrepared(recordId, data, records[recordId])
}
occupied[offset] = true
fun decode(recordId: Int, data: ByteArray, definitions: List<SignalDefinition>): DecodeResult =
PreparedDefinitions(definitions).decode(recordId, data)
private fun prepare(recordId: Int, definitions: List<SignalDefinition>): 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<DecodedSignal>(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)
}
}

51
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<DctPacket>()
private var sequenceCounter = 0
@Synchronized
@ -41,9 +39,20 @@ class SlidingRingBufferParser(
val result = mutableListOf<DctPacket>()
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<DctPacket>) {
// 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

340
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_<address> 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<com.dct.app.domain.model.project.DpcSensorModuleConfig>()
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<com.dct.app.domain.model.project.DpcSensorModuleChannel>()
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<com.dct.app.domain.model.formula.FormulaDefinition>()
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<com.dct.app.domain.model.project.DpcSignalBindingState> =
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)
}
}
}

28
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)
.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) } }

514
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<Byte, CompletableDeferred<DctPacket>>()
/** Ambiguous response codes are blocked for this session until the late reply is drained. */
private val responseQuarantineSession = ConcurrentHashMap<Byte, Long>()
private var heartbeatJob: Job? = null
private val 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<DctPacket>(dispatchQueueCapacity.coerceAtLeast(1))
private var xcpIsMsbFirst = false
@Volatile
private var pendingInitialConfiguration: DctMeasurementConfiguration? = null
private val _isConnected = MutableStateFlow(false)
override val isConnected: StateFlow<Boolean> = _isConnected.asStateFlow()
@ -54,9 +78,29 @@ class PacketRepositoryImpl(
override val packetStream: SharedFlow<DctPacket> = 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<Unit> = withContext(ioDispatcher) {
// Consume the snapshot once. MainViewModel supplies it immediately before
// each Wi-Fi callback, so a failed/stale session cannot leak its settings
// into a later reconnect.
val initialConfiguration = pendingInitialConfiguration.also { pendingInitialConfiguration = null }
transition(DeviceConnectionState.CONNECTING)
_isStreaming.value = false
if (!hasLiveTransport()) {
@ -82,13 +130,94 @@ class PacketRepositoryImpl(
}
transition(DeviceConnectionState.HANDSHAKING)
val handshake = listOf(
DctProtocolRequests.enable(),
DctProtocolRequests.version(),
// 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<DctPacket>()
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 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(versionReq, versionResult)
} catch (e: IllegalArgumentException) {
transition(DeviceConnectionState.ERROR)
return@withContext failure("연결 초기화 0x01: ${e.message}")
}
// 5. 후속 핸드셰이크: 0x03 (HW MAC ID), 0x04 (Time Sync)
val postHandshake = listOf(
DctProtocolRequests.hardwareId(),
DctProtocolRequests.syncTime()
)
for (request in handshake) {
for (request in postHandshake) {
val result = sendCommand(request)
if (result !is DctResult.Success || result.data == null) {
transition(DeviceConnectionState.ERROR)
@ -106,9 +235,16 @@ class PacketRepositoryImpl(
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<Unit> = 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<DctPacket?> = withContext(ioDispatcher) {
sendCommandWithTimeout(command, responseTimeoutMs)
}
/**
* Wire protocol에는 요청 sequence가 없으므로 같은 응답 코드의 늦은 패킷을
* 재시도 응답과 구분할 수 없다. 오래 걸릴 수 있는 제어 명령은 처음부터
* 충분히 기다려 정상 응답을 격리 상태로 만들지 않는다.
*/
private suspend fun sendCommandWithTimeout(
command: DctPacket,
timeoutMs: Long
): DctResult<DctPacket?> = 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<DctPacket>()
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<DctPacket> {
// 1. 상단 공통: 차종 코드 (0x06)
if (configuration.vehicleCode.isNotBlank()) {
add(packet("차종 설정") {
DctProtocolRequests.carType(configuration.vehicleCode, configuration.maker)
})
}
add(packet("CAN 모드") {
DctProtocolRequests.canMode(configuration.isHighSpeedCan, configuration.isOldModel)
// 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)
})
add(packet("CAN DBC") { DctProtocolRequests.canDbcInfo(configuration.canMessages) })
if (configuration.sensorModules.isNotEmpty()) {
} 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)
})
// 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) })
}
} 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<ByteArray> = 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)
)
if (offered) metrics.ackEnqueued.incrementAndGet()
else {
metrics.ackRejected.incrementAndGet()
android.util.Log.w(
"DctPacketRepository",
"스트림 ACK enqueue 실패: session=${packet.transportSessionId}, " +
"command=${DctCommand.toHexString(packet.command)}"
)
DctCommand.SEND_SM_DATA.code -> sendLive(
DctProtocolRequests.smStreamAck(
com.dct.app.data.protocol.DctStreamPayloadParser.parseSensorStream(packet.payload) != null
)
)
DctCommand.SEND_XCP_MEASUREMENT_DATA.code -> sendLive(
DctProtocolRequests.xcpStreamAck(
com.dct.app.data.protocol.DctStreamPayloadParser.parseXcpStream(packet.payload) != null
}
}
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"
)
} 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<DctPacket>()
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
}
}

508
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<DpcWindowState>()
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<DpcWindowState>
) {
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<String>()
val hidden = mutableSetOf<String>()
val colors = mutableMapOf<String, Long>()
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<DpcWindowState>
) {
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<DpcSignalBindingState> {
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<DpcSignalBindingState>,
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_<index> for A2L
// measurements. Resolve those against the loaded A2L metadata before
// applying the MDB `sig_<reclid>_<index>` 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>): 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 <T, R> Iterable<T>.mapNotNullIndexed(transform: (Int, T) -> R?): List<R> {
val result = ArrayList<R>()
forEachIndexed { index, value -> transform(index, value)?.let(result::add) }
return result
}
private fun Float.roundToInt(): Int = kotlin.math.round(this).toInt()
}

44
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은 가장 최근 샘플

6
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,

70
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<Double>): String {
val step = ticks.zipWithNext { a, b -> b - a }.filter { it > 0 && it.isFinite() }.minOrNull()
?: return chartAxisInputText(value)

2
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)

91
Android/app/src/main/java/com/dct/app/domain/model/chart/DownsamplingAlgorithms.kt

@ -122,84 +122,51 @@ object DownsamplingAlgorithms {
val result = ArrayList<Pair<Double, Double>>(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<Double, Double>? = null
var minInBucket: Pair<Double, Double>? = null
var maxInBucket: Pair<Double, Double>? = null
var lastInBucket: Pair<Double, Double>? = 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()

18
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) 역변환
*/

61
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

5
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
}

5
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,

3
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 = ""
)

40
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_<idx>_<name>`, while
* newer ones use `can_<messageId>_<name>`; 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>): SignalDefinition? =
signals.firstOrNull { matches(key, it) }
}

15
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 {

3
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
)
/**

5
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
)

11
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)
}

4
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<SignalDefinition> = emptyList(),
val selectedMdbSignals: List<SignalDefinition> = mdbSignals,
val a2lSignals: List<SignalDefinition> = emptyList(),
val xcpIfData: A2lParser.XcpIfData? = null,
val canMessages: List<DctCanMessageConfiguration> = emptyList(),
val sensorModules: List<DpcSensorModuleConfig> = emptyList(),
val isHighSpeedCan: Boolean = true,

13
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)

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

@ -33,6 +33,17 @@ interface PacketRepository {
*/
suspend fun connect(target: String = ""): DctResult<Unit>
/**
* PC 핸드셰이크의 0x09/0x12 설정 교환에 사용할 초기 프로젝트 설정을
* 다음 connect 호출에 전달합니다. 프로젝트를 아직 READY 상태로 만들기
* 전에는 configureMeasurement를 호출할 수 없으므로, 연결 전에 이 값을
* 별도로 주입하는 방식으로 초기 CAN/SM payload를 완성합니다.
*
* 기본 구현은 연결 대상이 설정을 지원하지 않는 테스트/대체 레포지토리에서도
* 기존 API가 그대로 동작하도록 no-op입니다.
*/
fun setInitialMeasurementConfiguration(configuration: DctMeasurementConfiguration) {}
/**
* 연결을 종료합니다.
*/

550
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<String, ChartSignalSeries>()
private val seriesOrder = java.util.concurrent.CopyOnWriteArrayList<String>()
private val _axesMap = ConcurrentHashMap<AxisPosition, ChartAxisConfig>()
/**
* 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<AxisPosition, AxisNavigationBaseline>()
private val _seriesListFlow = MutableStateFlow<List<ChartSignalSeries>>(emptyList())
override val seriesListFlow: StateFlow<List<ChartSignalSeries>> = _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<ChartSignalSeries>,
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<ChartSignalSeries>) {
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(

78
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<SignalDefinition> = emptyList()
private var a2lSource: List<SignalDefinition> = emptyList()
private var mdbSignals: List<SignalDefinition> = emptyList()
private val aliases = mutableMapOf<Pair<String, String>, List<String>>()
/** A replacement file may reuse names/IDs, so cached matches cannot outlive its definitions. */
fun updateSources(
mdb: MdbDatabase?,
can: List<SignalDefinition>,
a2l: List<SignalDefinition>
) {
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<String, Double>,
updatedSignals: Set<String>
): 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<String> = 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)
}
}
}

32
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)

133
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<String?>(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(16.dp))
// Auto Scale Checkbox
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth()
) {
Checkbox(
checked = autoScale,
onCheckedChange = { autoScale = it }
)
Spacer(modifier = Modifier.width(4.dp))
Spacer(modifier = Modifier.height(6.dp))
Text(
text = "Auto Scale",
fontSize = 13.sp,
color = Color(0xFF334155)
text = "Enter integer minimum and maximum.",
fontSize = 11.sp,
color = Color(0xFF64748B)
)
}
Spacer(modifier = Modifier.height(14.dp))
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()
}

701
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<ChartAxisConfig>, List<ChartAxisConfig>> {
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 = {
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<ChartAxisConfig>,
rightAxes: List<ChartAxisConfig>,
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<SeriesRenderData>,
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<Float>, availableWidth: Float): List<Float> {
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<SeriesRenderData>,
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,25 +1229,33 @@ 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) {
for ((index, series) in seriesList.withIndex()) {
val seriesColor = series.color.toComposeColor()
drawLine(
@ -735,13 +1266,23 @@ private fun DrawScope.drawRightLegendBox(
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
}
}
}
/**
* 상단 범례 (Top)
@ -750,33 +1291,34 @@ private fun DrawScope.drawTopLegendBox(
seriesList: List<SeriesRenderData>,
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
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,
@ -786,12 +1328,19 @@ private fun DrawScope.drawTopLegendBox(
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,27 +1366,27 @@ 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()
val itemWidth = itemWidths.getOrElse(i) { 0f }
drawLine(
color = seriesColor,
@ -846,11 +1396,19 @@ private fun DrawScope.drawBottomLegendBox(
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,26 +1423,27 @@ 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) {
@ -910,9 +1469,17 @@ private fun DrawScope.drawBottomLegendBox(
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
)
}
}
}

6
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() }
)
}

549
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<String>()
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",

19
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))

152
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<String?>(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("닫기") }
}
}
}
}
}

2
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)
}
}

95
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("나중에") } }
)
}
}

39
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(),

154
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<String>()
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)
override fun onStart() {
super.onStart()
(application as com.dct.app.DctApplication).session.onUiStarted()
}
permissions.add(android.Manifest.permission.ACCESS_FINE_LOCATION)
permissions.add(android.Manifest.permission.ACCESS_COARSE_LOCATION)
val needed = permissions.filter {
checkSelfPermission(it) != android.content.pm.PackageManager.PERMISSION_GRANTED
override fun onStop() {
(application as com.dct.app.DctApplication).session.onUiStopped()
super.onStop()
}
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))
}
}
)
}

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

File diff suppressed because it is too large

260
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<RemoteLogFile>,
enabled: Boolean,
service: MaintenanceService,
onRefresh: () -> Unit,
onDownload: (RemoteLogFile) -> Unit
) {
val scope = rememberCoroutineScope()
val selectedIndices = remember { mutableStateMapOf<Int, Boolean>() }
var isDeleting by remember { mutableStateOf(false) }
var deleteStatusMessage by remember { mutableStateOf<String?>(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("취소") } }
)
}
}

35
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<RemoteLogFile>,
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) {

361
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<Int, String>() }
val didChecked = remember {
mutableStateMapOf<Int, Boolean>().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)
}
}
}
}
}
}
}

452
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<String>() }
val selectedCarIndices = remember { mutableStateMapOf<Int, Boolean>() }
var confirmDeleteCarType by remember { mutableStateOf(false) }
// DB 관리 상태
val databases = remember { mutableStateListOf<String>() }
val selectedDbIndices = remember { mutableStateMapOf<Int, Boolean>() }
var activeDbName by remember { mutableStateOf<String?>(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("취소") } }
)
}
}

33
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,

9
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 서브윈도우 타입
*/

13
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)
}

353
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<android.widget.Toast?>(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<List<java.io.File>>(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<Uri?>(null) }
var pendingNewProjectDocument by remember { mutableStateOf<Uri?>(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,19 +251,25 @@ 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)
}
}
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 ->
uri?.let {
@ -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, 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 = name,
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, 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 = name,
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,43 +406,32 @@ 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)
}
@ -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 = {
if (!isProjectLoading) {
projectDialogTitle = "New project"
pendingNewProjectDocument = null
newProjectState = com.dct.app.data.repository.DpcProjectManager.createDefaultProject(context)
showNewProjectDialog = true
}
},
onPropertyProjectClick = {
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<Pair<String, Long>?>(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()) {
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
)
}
}
} else {
EmptyCanvasGuideView(
onNewProject = { showNewProjectDialog = true },
onNewProject = {
if (!isProjectLoading) showNewProjectDialog = true
},
onOpenProject = {
if (!isStoragePermissionGranted(context)) {
showPermissionRationaleDialog = true
} else {
requestOpenProject()
}
savedProjectFiles = com.dct.app.data.repository.DpcProjectManager.getSavedProjectsInConfiguration(context)
showOpenProjectDialog = true
}
)
}
} 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))
@ -1050,15 +1214,23 @@ fun DiagnosticDataMonitorScreen(
viewModel.openCanDbcFromUri(context, proj.canDbcFileUri, fallbackFileName = proj.canDbcFileName)
}
}
}
// Figma node 1:740 New Project / Property for project 모달 다이얼로그
if (showNewProjectDialog) {
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 ->
if (!isProjectLoading) {
showOpenProjectDialog = false
viewModel.openProjectFromFile(context, file)
}
},
onBrowseOther = {
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))

10
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",

53
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, Double>): 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<String>, can: List<SignalDefinition>, mdb: List<SignalDefinition>,
a2l: List<SignalDefinition>, formulas: List<FormulaDefinition>,
modules: List<DpcSensorModuleConfig>
): List<AlphaSignal> = ids.mapNotNull { id ->
val sig = findSignalById(id, can, mdb, a2l, formulas, modules) ?: return@mapNotNull null
): List<AlphaSignal> = resolveAlphaSignalSelection(ids, can, mdb, a2l, formulas, modules).signals
data class AlphaSignalSelection(
val signals: List<AlphaSignal>,
val unresolvedIds: List<String>
)
/** Resolve saved aliases and missing references in one pass, shared by header and body. */
internal fun resolveAlphaSignalSelection(
ids: List<String>, can: List<SignalDefinition>, mdb: List<SignalDefinition>,
a2l: List<SignalDefinition>, formulas: List<FormulaDefinition>,
modules: List<DpcSensorModuleConfig>
): AlphaSignalSelection {
val unresolved = mutableListOf<String>()
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_") -> "can_${sig.idx}"
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) -> "can_${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<AlphaSignal>, values: Map<String, Double>
): Map<String, Double?> = 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<Map<String, Double>>.alphaDisplayValues(signals: List<AlphaSignal>): Flow<Map<String, Double?>> =
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() }

63
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<String>,
liveTelemetryValues: Map<String, Double>,
canSignals: List<SignalDefinition>,
mdbSignals: List<SignalDefinition>,
a2lSignals: List<SignalDefinition> = emptyList(),
formulas: List<com.dct.app.domain.model.formula.FormulaDefinition> = emptyList(),
sensorModules: List<com.dct.app.domain.model.project.DpcSensorModuleConfig> = emptyList(),
selection: AlphaSignalSelection,
telemetryValues: StateFlow<Map<String, Double>>,
isTileMode: Boolean = true,
hiddenSignalIds: Set<String> = emptySet(),
highlightColors: Map<String, Long> = 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,7 +138,15 @@ fun AlphanumericalWindowContent(
)
}
}
} else if (isTileMode) {
} else {
// 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
@ -153,37 +158,41 @@ fun AlphanumericalWindowContent(
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 = entry.value(liveTelemetryValues),
value = remember(entry, rawValue) { entry.formatValue(rawValue) },
highlightColor = hlColor,
onSignalDrop = onSignalDrop
)
}
}
} else {
val tableListState = rememberLazyListState()
LazyColumn(
state = tableListState,
modifier = Modifier
.fillMaxSize()
.padding(4.dp),
.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 = entry.value(liveTelemetryValues),
value = remember(entry, rawValue) { entry.formatValue(rawValue) },
highlightColor = hlColor,
onSignalDrop = onSignalDrop
)
}
}
}
}
if (unresolvedIds.isNotEmpty()) {
Text(
@ -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(

53
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<String?>(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(
}
}
}
if (showColorPicker) {
SignalColorPickerDialog(initialColor = color,
onConfirm = { color = it; showColorPicker = false },
onDismiss = { showColorPicker = false })
}
}

223
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<FormulaDefinition?>(null) }
var isAddingNew by remember { mutableStateOf(false) }
var deletingFormulaId by remember { mutableStateOf<String?>(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,32 +185,42 @@ 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))
) {
Box(Modifier.fillMaxSize().clipToBounds()
.horizontalScroll(horizontalScroll).testTag("FormulaViewport_${formula.id}")) {
Row(
modifier = Modifier.width(tableWidth).fillMaxHeight()
.onGloballyPositioned { coords ->
itemBounds = coords.boundsInRoot()
// Use the unclipped origin when the row is horizontally scrolled.
itemOrigin = coords.positionInRoot()
}
.pointerInput(formula) {
detectDragGesturesAfterLongPress(
@ -184,7 +234,7 @@ fun FormulaWindowContent(
isCan = false,
isA2l = false
),
itemBounds.topLeft + localOffset
itemOrigin + localOffset
)
},
onDrag = { change, dragAmount ->
@ -204,70 +254,61 @@ fun FormulaWindowContent(
.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))
FormulaTableCell(widthName, "FormulaCell_name_${formula.id}") {
Text(
text = formula.name,
fontSize = 11.sp,
fontWeight = FontWeight.Medium,
color = Color(0xFF212529),
maxLines = 1,
overflow = TextOverflow.Ellipsis
overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(horizontal = 6.dp)
)
}
Box(modifier = Modifier.width(1.dp).fillMaxHeight().background(Color(0xFFE9ECEF)))
// 2. Value
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.width(80.dp).padding(horizontal = 6.dp),
maxLines = 1
modifier = Modifier.padding(horizontal = 6.dp),
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Box(modifier = Modifier.width(1.dp).fillMaxHeight().background(Color(0xFFE9ECEF)))
// 3. Expression
}
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.weight(1f).padding(horizontal = 6.dp),
modifier = Modifier.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)
) {
Icon(Icons.Default.Delete, contentDescription = "Delete", modifier = Modifier.size(13.dp), tint = Color(0xFFE53935))
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)))
}
}

209
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<List<String>>(emptyList()) }
var selectedSignalForSubmenu by remember(expanded) { mutableStateOf<SeriesRenderData?>(null) }
DropdownMenu(
expanded = expanded,
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()
},
offset = offset,
properties = PopupProperties(focusable = true)
) {
Surface(
modifier = Modifier
.background(Color.White)
.border(1.dp, Color(0xFFCBD5E1), RoundedCornerShape(4.dp))
.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)
) {
val path = currentMenuPath.joinToString("/")
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("Manual", fontSize = 12.sp, fontWeight = if (!snapshot.isXAxisAuto) FontWeight.Bold else FontWeight.Normal) },
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 && 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
@ -498,6 +553,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)
)
}
}
@Composable
private fun SubMenuHeader(title: String, onBack: () -> Unit) {
@ -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 }

106
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<SeriesRenderData?>(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<SeriesRenderData?>(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(

22
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))

39
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
}
}
}

Some files were not shown because too many files changed in this diff

Loading…
Cancel
Save