184 changed files with 18481 additions and 3731 deletions
@ -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:") |
||||
|
} |
||||
|
} |
||||
@ -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) } |
||||
|
} |
||||
|
} |
||||
@ -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) } |
||||
|
} |
||||
|
} |
||||
@ -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) } |
||||
|
} |
||||
|
} |
||||
@ -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) |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -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) |
||||
|
} |
||||
|
} |
||||
@ -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)) |
||||
|
} |
||||
|
} |
||||
@ -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 |
||||
|
} |
||||
|
} |
||||
@ -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 } |
||||
|
} |
||||
@ -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) |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -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() |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -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 |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -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}") |
||||
|
} |
||||
@ -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) |
||||
|
} |
||||
|
} |
||||
@ -1,109 +1,74 @@ |
|||||
package com.dct.app.core.formula |
package com.dct.app.core.formula |
||||
|
|
||||
import java.util.Locale |
import java.util.Locale |
||||
|
import java.util.concurrent.ConcurrentHashMap |
||||
|
|
||||
/** |
/** PC CSMLINParser-compatible interpolation; syntax and points are compiled once. */ |
||||
* Sensor Module LIN 구간 선형 보간 엔진 |
|
||||
* PC 레거시 CSMLINParser 호환: LIN("01-01"; x1; y1; x2; y2; ...; xn; yn) |
|
||||
*/ |
|
||||
object SMLinEvaluator { |
object SMLinEvaluator { |
||||
|
|
||||
data class Point(val x: Double, val y: Double) |
data class Point(val x: Double, val y: Double) |
||||
|
private data class Compiled(val channels: List<String>, val points: List<Point>) |
||||
/** |
private val cache = ConcurrentHashMap<String, Compiled>() |
||||
* LIN 수식 표현식을 파싱하고 주어진 변수 맵(텔레메트리)을 기준으로 보간값을 계산합니다. |
private val invalid = Compiled(emptyList(), emptyList()) |
||||
* @param expr LIN 수식 문자열 (예: "LIN(\"01-01\"; 0.5; 0.0; 2.5; 50.0; 4.5; 100.0)") |
|
||||
* @param variables 실시간 신호/센서 텔레메트리 맵 |
private fun compile(expr: String): Compiled = cache.computeIfAbsent(expr) { |
||||
* @return 보간 계산 결과 Double (오류 시 Double.NaN) |
val trimmed = it.trim() |
||||
*/ |
if (!trimmed.startsWith("LIN(", ignoreCase = true) || !trimmed.endsWith(")")) return@computeIfAbsent invalid |
||||
fun evaluate(expr: String, variables: Map<String, Double>): Double { |
val parts = trimmed.substring(4, trimmed.length - 1).split(';').map(String::trim) |
||||
val trimmed = expr.trim() |
if (parts.size < 5 || (parts.size - 1) % 2 != 0) return@computeIfAbsent invalid |
||||
if (!trimmed.startsWith("LIN(", ignoreCase = true) || !trimmed.endsWith(")")) { |
val raw = parts[0].removeSurrounding("\"").removeSurrounding("'").trim() |
||||
return Double.NaN |
if (raw.isEmpty() || raw.contains('"') || raw.contains('\'')) return@computeIfAbsent invalid |
||||
} |
val digits = raw.filter(Char::isDigit) |
||||
|
val channels = linkedSetOf(raw, normalizeChannelName(raw)) |
||||
val inner = trimmed.substring(4, trimmed.length - 1).trim() |
if (digits.length == 4) channels.add("sm_${digits.take(2)}_${digits.takeLast(2)}") |
||||
if (!inner.contains(';')) return Double.NaN |
channels.add(raw.replace(" ", "")) |
||||
val parts = inner.split(';').map { it.trim() } |
channels.add(raw.replace("ch", "", ignoreCase = true).trim()) |
||||
|
if (digits.length == 4) { |
||||
if (parts.size < 5 || (parts.size - 1) % 2 != 0) { |
channels.add("${digits.take(2)}-${digits.takeLast(2)}") |
||||
return Double.NaN |
channels.add("${digits.take(2)}-${digits.takeLast(2)} ch") |
||||
} |
} |
||||
|
val points = ArrayList<Point>() |
||||
// 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>() |
|
||||
for (i in 1 until parts.size step 2) { |
for (i in 1 until parts.size step 2) { |
||||
val x = parts[i].toDoubleOrNull() ?: return Double.NaN |
val x = parts[i].toDoubleOrNull() ?: return@computeIfAbsent invalid |
||||
val y = parts[i + 1].toDoubleOrNull() ?: return Double.NaN |
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)) |
points.add(Point(x, y)) |
||||
} |
} |
||||
|
Compiled(channels.toList(), points) |
||||
if (points.size < 2) return Double.NaN |
|
||||
if (points.zipWithNext().any { (left, right) -> right.x <= left.x }) return Double.NaN |
|
||||
|
|
||||
// 4. PC CSMLINParser와 동일하게 입력된 순서의 구간을 사용한다. |
|
||||
val len = points.size |
|
||||
|
|
||||
if (sensorValue <= points.first().x) { |
|
||||
return points.first().y |
|
||||
} |
} |
||||
if (sensorValue >= points.last().x) { |
|
||||
return points.last().y |
|
||||
} |
|
||||
|
|
||||
for (i in 0 until len - 1) { |
|
||||
val p1 = points[i] |
|
||||
val p2 = points[i + 1] |
|
||||
|
|
||||
if (sensorValue >= p1.x && sensorValue <= p2.x) { |
fun validate(expr: String): Boolean = compile(expr).points.isNotEmpty() |
||||
val slope = (p2.y - p1.y) / (p2.x - p1.x) |
internal fun dependencies(expr: String): Set<String> = compile(expr).channels.toSet() |
||||
return p1.y + slope * (sensorValue - p1.x) |
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 { |
fun normalizeChannelName(raw: String): String { |
||||
val clean = raw.trim() |
val clean = raw.trim() |
||||
if (clean.matches(Regex("""^\d{2}-\d{2}\s*ch$""", RegexOption.IGNORE_CASE))) { |
if (clean.matches(Regex("""^\d{2}-\d{2}\s*ch$""", RegexOption.IGNORE_CASE))) return clean.lowercase(Locale.ROOT) |
||||
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" |
||||
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" |
|
||||
} |
|
||||
return clean |
return clean |
||||
} |
} |
||||
} |
} |
||||
|
|||||
@ -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) } |
||||
|
} |
||||
@ -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() |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -1,434 +1,504 @@ |
|||||
package com.dct.app.data.communication |
package com.dct.app.data.communication |
||||
|
|
||||
import android.annotation.SuppressLint |
import android.annotation.SuppressLint |
||||
import android.content.BroadcastReceiver |
import android.content.* |
||||
import android.content.Context |
import android.content.pm.PackageManager |
||||
import android.content.Intent |
import android.location.LocationManager |
||||
import android.content.IntentFilter |
import android.net.ConnectivityManager |
||||
import android.net.wifi.p2p.WifiP2pConfig |
import android.net.Network |
||||
import android.net.wifi.p2p.WifiP2pDevice |
import android.net.NetworkCapabilities |
||||
import android.net.wifi.p2p.WifiP2pDeviceList |
import android.net.NetworkRequest |
||||
import android.net.wifi.p2p.WifiP2pGroup |
import android.net.LinkProperties |
||||
import android.net.wifi.p2p.WifiP2pInfo |
import android.net.wifi.WifiManager |
||||
import android.net.wifi.p2p.WifiP2pManager |
import android.net.wifi.WpsInfo |
||||
|
import android.net.wifi.p2p.* |
||||
import android.os.Build |
import android.os.Build |
||||
import android.os.Looper |
import android.os.Looper |
||||
import kotlinx.coroutines.CoroutineScope |
import android.util.Log |
||||
import kotlinx.coroutines.Dispatchers |
import androidx.core.content.ContextCompat |
||||
import kotlinx.coroutines.Job |
import androidx.core.location.LocationManagerCompat |
||||
import kotlinx.coroutines.flow.MutableStateFlow |
import kotlinx.coroutines.* |
||||
import kotlinx.coroutines.flow.StateFlow |
import kotlinx.coroutines.flow.* |
||||
import kotlinx.coroutines.flow.asStateFlow |
import java.net.NetworkInterface |
||||
import kotlinx.coroutines.isActive |
|
||||
import kotlinx.coroutines.launch |
/** ViewModel-owned OS P2P controller. Commands/callbacks serialize on Main; no Activity context. */ |
||||
import java.net.DatagramPacket |
class WifiDirectManager(context: Context) { |
||||
import java.net.DatagramSocket |
private val context = context.applicationContext |
||||
import java.net.InetSocketAddress |
private val connectivityManager = this.context.getSystemService(ConnectivityManager::class.java) |
||||
|
private val manager = this.context.getSystemService(WifiP2pManager::class.java) |
||||
data class DiscoveredLanServer( |
private val supported = this.context.packageManager.hasSystemFeature(PackageManager.FEATURE_WIFI_DIRECT) && manager != null |
||||
val deviceName: String, |
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate) |
||||
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 |
|
||||
private var channel: WifiP2pManager.Channel? = null |
private var channel: WifiP2pManager.Channel? = null |
||||
private var receiver: BroadcastReceiver? = null |
private var registered = false |
||||
|
private var closed = false |
||||
private val _isP2pEnabled = MutableStateFlow(false) |
private var epoch = 0L |
||||
val isP2pEnabled: StateFlow<Boolean> = _isP2pEnabled.asStateFlow() |
private var query = 0L |
||||
|
private var operation = 0L |
||||
private val _isSearching = MutableStateFlow(false) |
private var discovery = 0L |
||||
val isSearching: StateFlow<Boolean> = _isSearching.asStateFlow() |
private var deadline: Job? = null |
||||
|
private var discoveryDeadline: Job? = null |
||||
private val _peers = MutableStateFlow<List<WifiP2pDevice>>(emptyList()) |
private var recovery: Job? = null |
||||
val peers: StateFlow<List<WifiP2pDevice>> = _peers.asStateFlow() |
private var recoveryAttempts = 0 |
||||
|
private var syncWatch: Job? = null |
||||
private val _lanServers = MutableStateFlow<List<DiscoveredLanServer>>(emptyList()) |
private var confirmedQuery = 0L |
||||
val lanServers: StateFlow<List<DiscoveredLanServer>> = _lanServers.asStateFlow() |
private var networkRegistered = false |
||||
|
private val networks = java.util.concurrent.ConcurrentHashMap<Network, LinkProperties>() |
||||
private val _connectionInfo = MutableStateFlow<WifiP2pInfo?>(null) |
private val networkCallback = object : ConnectivityManager.NetworkCallback() { |
||||
val connectionInfo: StateFlow<WifiP2pInfo?> = _connectionInfo.asStateFlow() |
override fun onLinkPropertiesChanged(network: Network, properties: LinkProperties) { |
||||
|
networks[network] = properties |
||||
private val _groupInfo = MutableStateFlow<WifiP2pGroup?>(null) |
} |
||||
val groupInfo: StateFlow<WifiP2pGroup?> = _groupInfo.asStateFlow() |
override fun onLost(network: Network) { networks.remove(network) } |
||||
|
} |
||||
private val _thisDevice = MutableStateFlow<WifiP2pDevice?>(null) |
private var cancelAccepted = false |
||||
val thisDevice: StateFlow<WifiP2pDevice?> = _thisDevice.asStateFlow() |
private var removalAccepted = false |
||||
|
private val syncWaiters = mutableListOf<() -> Unit>() |
||||
private val _connectedDeviceName = MutableStateFlow<String?>(null) |
private val _uiState = MutableStateFlow(WifiDirectUiState(supported = supported)) |
||||
val connectedDeviceName: StateFlow<String?> = _connectedDeviceName.asStateFlow() |
val uiState: StateFlow<WifiDirectUiState> = _uiState.asStateFlow() |
||||
|
|
||||
private val _connectedHostIp = MutableStateFlow<String?>(null) |
private fun publish(change: (WifiDirectUiState) -> WifiDirectUiState) { |
||||
val connectedHostIp: StateFlow<String?> = _connectedHostIp.asStateFlow() |
val old = _uiState.value |
||||
|
_uiState.value = change(old) |
||||
private val _lastErrorReason = MutableStateFlow<Int?>(null) |
if (old.phase != _uiState.value.phase || old.link?.key != _uiState.value.link?.key) |
||||
val lastErrorReason: StateFlow<Int?> = _lastErrorReason.asStateFlow() |
Log.i(TAG, "P2P state=${_uiState.value.phase}, group=${_uiState.value.link?.key}, error=${_uiState.value.error}") |
||||
|
} |
||||
private var pendingIpCallback: ((String) -> Unit)? = null |
|
||||
private var udpDiscoveryJob: Job? = null |
fun start(): Job = scope.launch { |
||||
private val scope = CoroutineScope(Dispatchers.IO) |
if (closed || registered) return@launch |
||||
|
if (!supported) { |
||||
init { |
publish { it.copy(phase = WifiDirectPhase.UNSUPPORTED, error = "이 기기는 Wi-Fi Direct를 지원하지 않습니다.") } |
||||
wifiP2pManager?.let { manager -> |
return@launch |
||||
channel = manager.initialize(context, Looper.getMainLooper()) { |
} |
||||
// Channel lost |
val filter = IntentFilter().apply { |
||||
} |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
/** |
|
||||
* BroadcastReceiver 등록 및 UDP LAN 디스커버리 시작 |
|
||||
*/ |
|
||||
fun registerReceiver() { |
|
||||
if (receiver != null) return |
|
||||
|
|
||||
val intentFilter = IntentFilter().apply { |
|
||||
addAction(WifiP2pManager.WIFI_P2P_STATE_CHANGED_ACTION) |
addAction(WifiP2pManager.WIFI_P2P_STATE_CHANGED_ACTION) |
||||
addAction(WifiP2pManager.WIFI_P2P_PEERS_CHANGED_ACTION) |
addAction(WifiP2pManager.WIFI_P2P_PEERS_CHANGED_ACTION) |
||||
addAction(WifiP2pManager.WIFI_P2P_CONNECTION_CHANGED_ACTION) |
addAction(WifiP2pManager.WIFI_P2P_CONNECTION_CHANGED_ACTION) |
||||
addAction(WifiP2pManager.WIFI_P2P_THIS_DEVICE_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 { |
} else { |
||||
@Suppress("DEPRECATION") |
// API 26-28: sticky P2P broadcasts are authoritative; initial fallback only. |
||||
intent.getParcelableExtra(WifiP2pManager.EXTRA_WIFI_P2P_DEVICE) |
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 { |
private fun updateRequirements() { |
||||
context.registerReceiver(receiver, intentFilter) |
val granted = WifiDirectRequirements.runtimePermissions(Build.VERSION.SDK_INT).all { |
||||
refreshConnectionAndPeers() |
ContextCompat.checkSelfPermission(context, it) == PackageManager.PERMISSION_GRANTED |
||||
} catch (_: Exception) {} |
|
||||
|
|
||||
startUdpDiscovery() |
|
||||
} |
} |
||||
|
val location = context.getSystemService(LocationManager::class.java)?.let { |
||||
private var multicastLock: android.net.wifi.WifiManager.MulticastLock? = null |
LocationManagerCompat.isLocationEnabled(it) |
||||
|
} == true |
||||
private fun startUdpDiscovery() { |
publish { it.copy(permissionGranted = granted, locationEnabled = location, |
||||
if (udpDiscoveryJob != null) return |
phase = if (!granted) WifiDirectPhase.PERMISSION_REQUIRED else it.phase, |
||||
try { |
synchronizedWithOs = if (!granted) false else it.synchronizedWithOs, |
||||
val wm = context.applicationContext.getSystemService(android.content.Context.WIFI_SERVICE) as? android.net.wifi.WifiManager |
error = if (!granted) "주변 Wi-Fi 기기 검색 및 연결 권한이 필요합니다." else it.error) } |
||||
multicastLock = wm?.createMulticastLock("DctUdpDiscoveryLock")?.apply { |
if ((!granted || !location) && _uiState.value.discovering) stopPeerDiscovery() |
||||
setReferenceCounted(true) |
if (!granted) { |
||||
acquire() |
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( |
private fun setEnabled(enabled: Boolean) { |
||||
deviceName = srvName, |
if (!enabled) { |
||||
hostIp = srvIp, |
operation++; query++; discovery++ |
||||
port = srvPort, |
deadline?.cancel(); discoveryDeadline?.cancel() |
||||
lastSeen = System.currentTimeMillis() |
syncWaiters.clear() |
||||
) |
publish { it.copy(enabled = false, phase = WifiDirectPhase.DISABLED, link = null, |
||||
|
target = null, discovering = false, peers = emptyList(), synchronizedWithOs = true, |
||||
val current = _lanServers.value.filter { |
error = "Wi-Fi가 꺼져 있습니다. Wi-Fi를 켜 주세요.") } |
||||
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 |
|
||||
} |
|
||||
} else { |
} else { |
||||
_connectedDeviceName.value = null |
val changed = !_uiState.value.enabled |
||||
_connectedHostIp.value = null |
publish { it.copy(enabled = true, error = if (changed) null else it.error) } |
||||
_groupInfo.value = null |
if (changed && _uiState.value.permissionGranted) synchronize() |
||||
pendingIpCallback = null |
|
||||
} |
} |
||||
} |
} |
||||
|
|
||||
wifiP2pManager?.requestGroupInfo(ch) { group: WifiP2pGroup? -> |
private val receiver = object : BroadcastReceiver() { |
||||
_groupInfo.value = group |
override fun onReceive(context: Context?, intent: Intent?) { |
||||
if (group != null) { |
if (closed) return |
||||
if (_connectedDeviceName.value == null) { |
when (intent?.action) { |
||||
val isOwner = _connectionInfo.value?.isGroupOwner == true |
WifiP2pManager.WIFI_P2P_STATE_CHANGED_ACTION -> |
||||
if (isOwner) { |
setEnabled(intent.getIntExtra(WifiP2pManager.EXTRA_WIFI_STATE, -1) == WifiP2pManager.WIFI_P2P_STATE_ENABLED) |
||||
val firstClient = group.clientList.firstOrNull() |
WifiP2pManager.WIFI_P2P_PEERS_CHANGED_ACTION -> requestPeers() |
||||
if (firstClient != null) { |
WifiP2pManager.WIFI_P2P_CONNECTION_CHANGED_ACTION -> synchronize() |
||||
_connectedDeviceName.value = firstClient.deviceName.ifBlank { firstClient.deviceAddress } |
WifiP2pManager.WIFI_P2P_THIS_DEVICE_CHANGED_ACTION -> { |
||||
} |
val device = if (Build.VERSION.SDK_INT >= 33) |
||||
} else { |
intent.getParcelableExtra(WifiP2pManager.EXTRA_WIFI_P2P_DEVICE, WifiP2pDevice::class.java) |
||||
val owner = group.owner |
else { |
||||
if (owner != null) { |
@Suppress("DEPRECATION") |
||||
_connectedDeviceName.value = owner.deviceName.ifBlank { owner.deviceAddress } |
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 |
|
||||
} |
} |
||||
} |
} |
||||
|
|
||||
/** |
/** Publish info/group atomically, and ignore callbacks from superseded queries/channels. */ |
||||
* 앱 실행 시 이미 Wi-Fi Direct 그룹이 연결되어 있는지 확인하고 콜백 반환 (isGroupOwner 포함) |
|
||||
*/ |
|
||||
@SuppressLint("MissingPermission") |
@SuppressLint("MissingPermission") |
||||
fun checkExistingConnection( |
private fun synchronize(after: (() -> Unit)? = null) { |
||||
onConnected: (deviceName: String, hostIp: String, isGroupOwner: Boolean) -> Unit |
if (after != null) syncWaiters.add(after) |
||||
) { |
|
||||
val ch = channel ?: return |
val ch = channel ?: return |
||||
wifiP2pManager?.requestConnectionInfo(ch) { info: WifiP2pInfo? -> |
if (!_uiState.value.permissionGranted) return |
||||
if (info?.groupFormed == true) { |
val revision = ++query |
||||
_connectionInfo.value = info |
val generation = epoch |
||||
val hostIp = info.groupOwnerAddress?.hostAddress ?: "192.168.49.1" |
// Lost callbacks or temporarily inconsistent snapshots must not leave a permanent spinner. |
||||
_connectedHostIp.value = hostIp |
if (syncWatch?.isActive != true) syncWatch = scope.launch { |
||||
val isOwner = info.isGroupOwner |
repeat(5) { |
||||
|
delay(1000) |
||||
wifiP2pManager.requestGroupInfo(ch) { group: WifiP2pGroup? -> |
if (closed || generation != epoch || confirmedQuery >= revision) return@launch |
||||
_groupInfo.value = group |
synchronize() |
||||
val devName = if (isOwner) { |
} |
||||
group?.clientList?.firstOrNull()?.let { it.deviceName.ifBlank { it.deviceAddress } } ?: "Wi-Fi Direct Client" |
if (confirmedQuery < revision && generation == epoch) { |
||||
} else { |
publish { it.copy(synchronizedWithOs = false, phase = WifiDirectPhase.ERROR, |
||||
group?.owner?.let { it.deviceName.ifBlank { it.deviceAddress } } ?: "Wi-Fi Direct Owner" |
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") |
@SuppressLint("MissingPermission") |
||||
fun checkExistingConnection( |
private fun requestPeers() { |
||||
onConnected: (deviceName: String, hostIp: String) -> Unit |
updateRequirements() |
||||
) { |
if (!_uiState.value.permissionGranted || !_uiState.value.locationEnabled) return |
||||
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 = {} |
|
||||
) { |
|
||||
val ch = channel ?: return |
val ch = channel ?: return |
||||
_isSearching.value = true |
val generation = epoch |
||||
_lastErrorReason.value = null |
val token = operation |
||||
wifiP2pManager?.discoverPeers(ch, object : WifiP2pManager.ActionListener { |
guarded("기기 목록 확인") { |
||||
override fun onSuccess() { |
manager!!.requestPeers(ch) { list -> |
||||
_isSearching.value = true |
if (generation != epoch || token != operation || closed) return@requestPeers |
||||
_lastErrorReason.value = null |
val peers = list.deviceList.map { WifiP2pDevice(it) }.distinctBy { it.deviceAddress } |
||||
onSuccess() |
.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 |
/** Discovery errors cannot replace an OS link or an in-flight connection operation. */ |
||||
onFailure(reasonCode) |
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) |
||||
} |
} |
||||
}) |
|
||||
} |
} |
||||
|
|
||||
/** |
private fun listener(label: String, valid: () -> Boolean, discoveryOnly: Boolean = false, success: () -> Unit = {}, failure: () -> Unit = {}) = |
||||
* 피어 탐색 정지 |
object : WifiP2pManager.ActionListener { |
||||
*/ |
override fun onSuccess() { if (!closed && valid()) success() } |
||||
fun stopPeerDiscovery() { |
|
||||
val ch = channel ?: return |
|
||||
wifiP2pManager?.stopPeerDiscovery(ch, object : WifiP2pManager.ActionListener { |
|
||||
override fun onSuccess() { |
|
||||
_isSearching.value = false |
|
||||
} |
|
||||
override fun onFailure(reason: Int) { |
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 } |
|
||||
} |
} |
||||
|
closed = true |
||||
override fun onFailure(reasonCode: Int) { |
epoch++; query++; operation++; discovery++ |
||||
_connectedDeviceName.value = null |
syncWaiters.clear() |
||||
_connectedHostIp.value = null |
scope.cancel() |
||||
pendingIpCallback = null |
if (networkRegistered) { |
||||
onFailure(reasonCode) |
connectivityManager?.unregisterNetworkCallback(networkCallback) |
||||
|
networkRegistered = false |
||||
} |
} |
||||
}) |
if (registered) { context.unregisterReceiver(receiver); registered = false } |
||||
} |
channel?.close() |
||||
|
channel = null |
||||
/** |
|
||||
* 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 |
|
||||
} |
} |
||||
|
|
||||
// 1. 진행 중인 연결 요청 취소 |
companion object { |
||||
wifiP2pManager?.cancelConnect(ch, object : WifiP2pManager.ActionListener { |
private const val TAG = "WifiDirectManager" |
||||
override fun onSuccess() {} |
private val pendingPhases = setOf(WifiDirectPhase.CONNECTING, WifiDirectPhase.CANCELLING, WifiDirectPhase.DISCONNECTING) |
||||
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() |
|
||||
} |
|
||||
}) |
|
||||
} |
} |
||||
} |
} |
||||
|
|||||
@ -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) |
||||
@ -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()) |
||||
@ -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 |
||||
|
} |
||||
|
} |
||||
@ -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}" |
||||
|
} |
||||
@ -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++ |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -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() |
||||
|
} |
||||
@ -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) } |
||||
|
} |
||||
@ -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) |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -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("닫기") } |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -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("나중에") } } |
||||
|
) |
||||
|
} |
||||
|
} |
||||
File diff suppressed because it is too large
@ -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("취소") } } |
||||
|
) |
||||
|
} |
||||
|
} |
||||
@ -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) |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -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("취소") } } |
||||
|
) |
||||
|
} |
||||
|
} |
||||
@ -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) |
||||
|
} |
||||
Some files were not shown because too many files changed in this diff
Loading…
Reference in new issue