diff --git a/app/build.gradle.kts b/app/build.gradle.kts index e79df7d..9ac6a06 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -24,8 +24,8 @@ android { applicationId = "kr.tkrmagid.easyappliance" minSdk = 24 targetSdk = 35 - versionCode = (project.findProperty("verCode") as String?)?.toInt() ?: 11 - versionName = (project.findProperty("verName") as String?) ?: "0.3.8" + versionCode = (project.findProperty("verCode") as String?)?.toInt() ?: 12 + versionName = (project.findProperty("verName") as String?) ?: "0.3.9" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" } diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index a89a578..2376244 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -6,7 +6,8 @@ custom display name. */ @@ -43,7 +45,9 @@ class SettingsRepository(private val context: Context) { showRemainingTime = p[KEY_SHOW_REMAINING] ?: true, showReservation = p[KEY_SHOW_RESERVATION] ?: true, showPowerControls = p[KEY_SHOW_POWER] ?: true, - smartThingsToken = p[KEY_TOKEN]?.takeIf { it.isNotBlank() }, + confirmBeforeAction = p[KEY_CONFIRM_ACTION] ?: true, + smartThingsToken = (p[KEY_TOKEN_ENC]?.let { TokenCrypto.decrypt(it) } ?: p[KEY_TOKEN]) + ?.takeIf { it.isNotBlank() }, labelOverrides = decodeMap(p[KEY_LABEL_OVERRIDES]), imageOverrides = decodeMap(p[KEY_IMAGE_OVERRIDES]), deviceMacros = decodeMacros(p[KEY_MACROS]), @@ -65,8 +69,27 @@ class SettingsRepository(private val context: Context) { suspend fun setShowPowerControls(value: Boolean) = edit { it[KEY_SHOW_POWER] = value } - suspend fun setToken(token: String) = - edit { it[KEY_TOKEN] = token.trim() } + suspend fun setConfirmBeforeAction(value: Boolean) = + edit { it[KEY_CONFIRM_ACTION] = value } + + suspend fun setToken(token: String) = edit { p -> + val t = token.trim() + if (t.isBlank()) { + p.remove(KEY_TOKEN_ENC); p.remove(KEY_TOKEN) + } else { + val enc = TokenCrypto.encrypt(t) + if (enc != null) { p[KEY_TOKEN_ENC] = enc; p.remove(KEY_TOKEN) } + else p[KEY_TOKEN] = t // last-resort fallback if the keystore is unavailable + } + } + + /** One-time move of any legacy plaintext token into encrypted storage. */ + suspend fun migratePlaintextToken() = edit { p -> + val legacy = p[KEY_TOKEN] + if (!legacy.isNullOrBlank() && p[KEY_TOKEN_ENC] == null) { + TokenCrypto.encrypt(legacy)?.let { p[KEY_TOKEN_ENC] = it; p.remove(KEY_TOKEN) } + } + } suspend fun setDeviceLabel(deviceId: String, label: String) = edit { prefs -> val map = decodeMap(prefs[KEY_LABEL_OVERRIDES]).toMutableMap() @@ -104,7 +127,9 @@ class SettingsRepository(private val context: Context) { val KEY_SHOW_REMAINING = booleanPreferencesKey("show_remaining_time") val KEY_SHOW_RESERVATION = booleanPreferencesKey("show_reservation") val KEY_SHOW_POWER = booleanPreferencesKey("show_power_controls") + val KEY_CONFIRM_ACTION = booleanPreferencesKey("confirm_before_action") val KEY_TOKEN = stringPreferencesKey("smartthings_token") + val KEY_TOKEN_ENC = stringPreferencesKey("smartthings_token_enc") val KEY_LABEL_OVERRIDES = stringPreferencesKey("label_overrides_json") val KEY_IMAGE_OVERRIDES = stringPreferencesKey("image_overrides_json") val KEY_MACROS = stringPreferencesKey("device_macros_json") diff --git a/app/src/main/java/kr/tkrmagid/easyappliance/data/TokenCrypto.kt b/app/src/main/java/kr/tkrmagid/easyappliance/data/TokenCrypto.kt new file mode 100644 index 0000000..4d7012a --- /dev/null +++ b/app/src/main/java/kr/tkrmagid/easyappliance/data/TokenCrypto.kt @@ -0,0 +1,57 @@ +package kr.tkrmagid.easyappliance.data + +import android.security.keystore.KeyGenParameterSpec +import android.security.keystore.KeyProperties +import android.util.Base64 +import java.security.KeyStore +import javax.crypto.Cipher +import javax.crypto.KeyGenerator +import javax.crypto.SecretKey +import javax.crypto.spec.GCMParameterSpec + +/** + * Encrypts the SmartThings token at rest using an AES/GCM key held in the + * AndroidKeyStore (never leaves the device, not included in backups). Returns + * null on any failure so callers can fall back gracefully. + */ +object TokenCrypto { + private const val KEYSTORE = "AndroidKeyStore" + private const val ALIAS = "st_token_key" + private const val TRANSFORM = "AES/GCM/NoPadding" + private const val IV_LEN = 12 + private const val TAG_BITS = 128 + + private fun secretKey(): SecretKey { + val ks = KeyStore.getInstance(KEYSTORE).apply { load(null) } + (ks.getEntry(ALIAS, null) as? KeyStore.SecretKeyEntry)?.let { return it.secretKey } + val gen = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, KEYSTORE) + gen.init( + KeyGenParameterSpec.Builder( + ALIAS, + KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT, + ) + .setBlockModes(KeyProperties.BLOCK_MODE_GCM) + .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE) + .build(), + ) + return gen.generateKey() + } + + /** base64(iv + ciphertext), or null if encryption is unavailable. */ + fun encrypt(plain: String): String? = runCatching { + val cipher = Cipher.getInstance(TRANSFORM) + cipher.init(Cipher.ENCRYPT_MODE, secretKey()) + val ct = cipher.doFinal(plain.toByteArray(Charsets.UTF_8)) + Base64.encodeToString(cipher.iv + ct, Base64.NO_WRAP) + }.getOrNull() + + /** Recovers the plaintext from [encrypt] output, or null if it can't be read. */ + fun decrypt(blob: String): String? = runCatching { + val data = Base64.decode(blob, Base64.NO_WRAP) + val iv = data.copyOfRange(0, IV_LEN) + val ct = data.copyOfRange(IV_LEN, data.size) + val cipher = Cipher.getInstance(TRANSFORM) + cipher.init(Cipher.DECRYPT_MODE, secretKey(), GCMParameterSpec(TAG_BITS, iv)) + String(cipher.doFinal(ct), Charsets.UTF_8) + }.getOrNull() +} diff --git a/app/src/main/java/kr/tkrmagid/easyappliance/data/smartthings/SmartThingsStatusMapper.kt b/app/src/main/java/kr/tkrmagid/easyappliance/data/smartthings/SmartThingsStatusMapper.kt index c3dc450..1d6d585 100644 --- a/app/src/main/java/kr/tkrmagid/easyappliance/data/smartthings/SmartThingsStatusMapper.kt +++ b/app/src/main/java/kr/tkrmagid/easyappliance/data/smartthings/SmartThingsStatusMapper.kt @@ -51,12 +51,19 @@ object SmartThingsStatusMapper { spin?.let { "탈수 $it" }, rinse?.let { "헹굼 ${it}회" }, ).joinToString(" · ").ifBlank { null } + val jobPhase = (attr(main, "washerOperatingState", "washerJobState")?.contentOrNull() + ?: attr(main, "samsungce.washerOperatingState", "washerJobState")?.contentOrNull()) + ?.let { washerJobPhaseLabel(it) } + val reserveMinutes = numberAttr(main, "samsungce.washerDelayEnd", "remainingTime") + val reservation = reserveMinutes?.takeIf { it > 0 }?.let { "약 ${it}분 뒤 완료 예약" } return DeviceStatus( powerOn = powerOn, operatingState = state, running = running, currentSettings = if (running) settings else null, + jobPhase = if (running) jobPhase else null, remainingMinutes = remaining, + reservation = reservation, ) } @@ -100,6 +107,19 @@ object SmartThingsStatusMapper { else -> raw } + private fun washerJobPhaseLabel(raw: String): String? = when (raw.lowercase()) { + "wash" -> "세탁 중" + "rinse" -> "헹굼 중" + "spin" -> "탈수 중" + "weightsensing" -> "무게 감지 중" + "soak" -> "불림 중" + "drying" -> "건조 중" + "cooling" -> "냉각 중" + "finish" -> "마무리 중" + "none" -> null + else -> null + } + private fun acModeLabel(raw: String): String = when (raw.lowercase()) { "cool" -> "냉방" "dry" -> "제습" diff --git a/app/src/main/java/kr/tkrmagid/easyappliance/ui/AdminScreen.kt b/app/src/main/java/kr/tkrmagid/easyappliance/ui/AdminScreen.kt index 3f7c09b..5385261 100644 --- a/app/src/main/java/kr/tkrmagid/easyappliance/ui/AdminScreen.kt +++ b/app/src/main/java/kr/tkrmagid/easyappliance/ui/AdminScreen.kt @@ -66,6 +66,7 @@ fun AdminScreen( onToggleRemaining: (Boolean) -> Unit, onToggleReservation: (Boolean) -> Unit, onTogglePowerControls: (Boolean) -> Unit, + onToggleConfirmAction: (Boolean) -> Unit, onSetLabel: (String, String) -> Unit, onSetImageRef: (String, String) -> Unit, onSetToken: (String) -> Unit, @@ -171,6 +172,8 @@ fun AdminScreen( ToggleRow("예약 정보 표시", state.settings.showReservation, onToggleReservation) HorizontalDivider() ToggleRow("켜기 / 끄기 버튼 표시", state.settings.showPowerControls, onTogglePowerControls) + HorizontalDivider() + ToggleRow("시작 / 끄기 전 확인창", state.settings.confirmBeforeAction, onToggleConfirmAction) } } diff --git a/app/src/main/java/kr/tkrmagid/easyappliance/ui/AppRoot.kt b/app/src/main/java/kr/tkrmagid/easyappliance/ui/AppRoot.kt index 0dfaba2..47f033f 100644 --- a/app/src/main/java/kr/tkrmagid/easyappliance/ui/AppRoot.kt +++ b/app/src/main/java/kr/tkrmagid/easyappliance/ui/AppRoot.kt @@ -77,6 +77,7 @@ fun AppRoot() { state = state, onTogglePower = vm::togglePower, onRunMacro = vm::runMacro, + onClearNotice = vm::clearNotice, modifier = Modifier .fillMaxSize() .adminUnlockGesture { screen = Screen.ADMIN }, @@ -89,6 +90,7 @@ fun AppRoot() { onToggleRemaining = vm::setShowRemainingTime, onToggleReservation = vm::setShowReservation, onTogglePowerControls = vm::setShowPowerControls, + onToggleConfirmAction = vm::setConfirmBeforeAction, onSetLabel = vm::setDeviceLabel, onSetImageRef = vm::setDeviceImage, onSetToken = vm::setToken, diff --git a/app/src/main/java/kr/tkrmagid/easyappliance/ui/DeviceImage.kt b/app/src/main/java/kr/tkrmagid/easyappliance/ui/DeviceImage.kt index 04a60e6..33d9bd4 100644 --- a/app/src/main/java/kr/tkrmagid/easyappliance/ui/DeviceImage.kt +++ b/app/src/main/java/kr/tkrmagid/easyappliance/ui/DeviceImage.kt @@ -16,20 +16,21 @@ import kr.tkrmagid.easyappliance.data.DeviceImages fun DeviceImage( imageRef: String, modifier: Modifier = Modifier, + contentDescription: String? = null, contentScale: ContentScale = ContentScale.Fit, ) { val builtinRes = DeviceImages.resForRef(imageRef) if (builtinRes != null) { Image( painter = painterResource(builtinRes), - contentDescription = null, + contentDescription = contentDescription, modifier = modifier, contentScale = contentScale, ) } else { AsyncImage( model = imageRef, - contentDescription = null, + contentDescription = contentDescription, modifier = modifier, contentScale = ContentScale.Crop, ) diff --git a/app/src/main/java/kr/tkrmagid/easyappliance/ui/HomeScreen.kt b/app/src/main/java/kr/tkrmagid/easyappliance/ui/HomeScreen.kt index 8869531..9e4272b 100644 --- a/app/src/main/java/kr/tkrmagid/easyappliance/ui/HomeScreen.kt +++ b/app/src/main/java/kr/tkrmagid/easyappliance/ui/HomeScreen.kt @@ -15,6 +15,7 @@ import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.PowerSettingsNew +import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.Card @@ -23,7 +24,12 @@ import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color @@ -31,6 +37,9 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import kr.tkrmagid.easyappliance.data.DeviceType import androidx.compose.material.icons.filled.PlayArrow +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale import kr.tkrmagid.easyappliance.vm.HomeUiState import kr.tkrmagid.easyappliance.vm.appDisplayName import kr.tkrmagid.easyappliance.vm.displayLabelFor @@ -44,6 +53,7 @@ fun HomeScreen( state: HomeUiState, onTogglePower: (Boolean) -> Unit, onRunMacro: () -> Unit, + onClearNotice: () -> Unit = {}, modifier: Modifier = Modifier, ) { BoxWithConstraints(modifier = modifier.fillMaxSize()) { @@ -58,6 +68,12 @@ fun HomeScreen( return@BoxWithConstraints } + // Optional confirmation before start / power actions (guards mis-taps). + var pending by remember { mutableStateOf(null) } + val request: (String, () -> Unit) -> Unit = { msg, act -> + if (state.settings.confirmBeforeAction) pending = PendingAction(msg, act) else act() + } + Column( modifier = Modifier .fillMaxSize() @@ -69,6 +85,10 @@ fun HomeScreen( modifier = Modifier.widthIn(max = contentMaxWidth).fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally, ) { + state.notice?.let { msg -> + NoticeCard(message = msg, onClose = onClearNotice) + Spacer(Modifier.height(16.dp)) + } Text( text = state.appDisplayName(), style = MaterialTheme.typography.titleLarge, @@ -78,6 +98,7 @@ fun HomeScreen( Spacer(Modifier.height(16.dp)) DeviceImage( imageRef = state.imageRefFor(device), + contentDescription = state.displayLabelFor(device), modifier = Modifier.size(if (narrow) 96.dp else 128.dp), ) Spacer(Modifier.height(12.dp)) @@ -108,22 +129,84 @@ fun HomeScreen( if (macroOn) { MacroControls( isOn = state.status?.powerOn == true, - onStart = onRunMacro, - onOff = { onTogglePower(false) }, + onStart = { request("정말 시작할까요?") { onRunMacro() } }, + onOff = { request("정말 끌까요?") { onTogglePower(false) } }, ) } else { PowerControls( isOn = state.status?.powerOn == true, stacked = narrow, - onTogglePower = onTogglePower, + onTogglePower = { on -> + request(if (on) "정말 켤까요?" else "정말 끌까요?") { onTogglePower(on) } + }, ) } } + + state.lastUpdatedAt?.let { ts -> + Spacer(Modifier.height(16.dp)) + Text( + text = "마지막 확인 ${formatTime(ts)}" + if (!state.connected) " · 오프라인" else "", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + ) + } } } + + pending?.let { p -> + ConfirmDialog( + message = p.message, + onConfirm = { pending = null; p.onConfirm() }, + onCancel = { pending = null }, + ) + } } } +private data class PendingAction(val message: String, val onConfirm: () -> Unit) + +private fun formatTime(epochMillis: Long): String = + SimpleDateFormat("a h:mm", Locale.KOREAN).format(Date(epochMillis)) + +@Composable +private fun NoticeCard(message: String, onClose: () -> Unit) { + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.errorContainer, + contentColor = MaterialTheme.colorScheme.onErrorContainer, + ), + ) { + Row( + Modifier.fillMaxWidth().padding(16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text(message, style = MaterialTheme.typography.bodyLarge, modifier = Modifier.weight(1f)) + TextButton(onClick = onClose) { Text("확인") } + } + } +} + +@Composable +private fun ConfirmDialog(message: String, onConfirm: () -> Unit, onCancel: () -> Unit) { + AlertDialog( + onDismissRequest = onCancel, + title = { Text(message, style = MaterialTheme.typography.headlineMedium) }, + confirmButton = { + Button(onClick = onConfirm, modifier = Modifier.height(60.dp)) { + Text("예", style = MaterialTheme.typography.labelLarge) + } + }, + dismissButton = { + TextButton(onClick = onCancel, modifier = Modifier.height(60.dp)) { + Text("아니오", style = MaterialTheme.typography.labelLarge) + } + }, + ) +} + @Composable private fun StatusCard(state: HomeUiState) { Card( @@ -150,6 +233,16 @@ private fun StatusCard(state: HomeUiState) { textAlign = TextAlign.Center, ) + status?.jobPhase?.let { + Spacer(Modifier.height(4.dp)) + Text( + it, + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.primary, + textAlign = TextAlign.Center, + ) + } + if (state.settings.showRemainingTime && status?.remainingMinutes != null) { Spacer(Modifier.height(12.dp)) Text( diff --git a/app/src/main/java/kr/tkrmagid/easyappliance/vm/AppViewModel.kt b/app/src/main/java/kr/tkrmagid/easyappliance/vm/AppViewModel.kt index 117691f..3a76dad 100644 --- a/app/src/main/java/kr/tkrmagid/easyappliance/vm/AppViewModel.kt +++ b/app/src/main/java/kr/tkrmagid/easyappliance/vm/AppViewModel.kt @@ -37,6 +37,10 @@ data class HomeUiState( val connected: Boolean = true, /** True after the user closes the warning with the small "x". */ val warningDismissed: Boolean = false, + /** Transient, large-text notice shown to the user (e.g. command failed). */ + val notice: String? = null, + /** When the shown status was last refreshed successfully (epoch millis). */ + val lastUpdatedAt: Long? = null, val error: String? = null, ) { val showConnectionWarning: Boolean get() = !connected && !warningDismissed @@ -56,6 +60,7 @@ class AppViewModel(app: Application) : AndroidViewModel(app) { val state: StateFlow = _state.asStateFlow() init { + viewModelScope.launch { settingsRepo.migratePlaintextToken() } viewModelScope.launch { settingsRepo.settings.collect { settings -> applySettings(settings) } } @@ -119,11 +124,20 @@ class AppViewModel(app: Application) : AndroidViewModel(app) { .onSuccess { devices -> _state.update { it.copy(devices = devices, connected = true) } } - .onFailure { - _state.update { it.copy(loading = false, connected = false) } + .onFailure { e -> + _state.update { + it.copy( + loading = false, + connected = false, + notice = if (e.isAuthError()) TOKEN_INVALID_MSG else it.notice, + ) + } } } + private fun Throwable.isAuthError(): Boolean = + this is retrofit2.HttpException && (code() == 401 || code() == 403) + private suspend fun resolveSelectionAndStatus(settings: AppSettings) { val devices = _state.value.devices val selected = devices.firstOrNull { it.id == settings.selectedDeviceId } @@ -150,15 +164,24 @@ class AppViewModel(app: Application) : AndroidViewModel(app) { status = status, connected = !forcedWarning, warningDismissed = if (!forcedWarning) false else it.warningDismissed, + lastUpdatedAt = System.currentTimeMillis(), error = null, ) } } - .onFailure { - _state.update { it.copy(loading = false, connected = false) } + .onFailure { e -> + _state.update { + it.copy( + loading = false, + connected = false, + notice = if (e.isAuthError()) TOKEN_INVALID_MSG else it.notice, + ) + } } } + fun clearNotice() { _state.update { it.copy(notice = null) } } + /** Refresh selected device status; also used by the periodic poll. */ fun refresh() { val id = _state.value.selected?.id ?: return @@ -200,7 +223,8 @@ class AppViewModel(app: Application) : AndroidViewModel(app) { val id = _state.value.selected?.id ?: return viewModelScope.launch { runCatching { deviceRepo.setPower(id, on) } - .onFailure { _state.update { s -> s.copy(connected = false) } } + .onSuccess { _state.update { s -> s.copy(notice = null) } } + .onFailure { e -> _state.update { s -> s.copy(connected = false, notice = commandFailMsg(e)) } } loadStatus(id) } } @@ -211,11 +235,15 @@ class AppViewModel(app: Application) : AndroidViewModel(app) { val macro = _state.value.settings.deviceMacros[id] ?: return viewModelScope.launch { runCatching { deviceRepo.runMacro(id, macro.steps) } - .onFailure { _state.update { s -> s.copy(connected = false) } } + .onSuccess { _state.update { s -> s.copy(notice = null) } } + .onFailure { e -> _state.update { s -> s.copy(connected = false, notice = commandFailMsg(e)) } } loadStatus(id) } } + private fun commandFailMsg(e: Throwable): String = + if (e.isAuthError()) TOKEN_INVALID_MSG else "명령을 보내지 못했어요. 잠시 후 다시 시도해 주세요." + fun setDeviceMacro(deviceId: String, macro: DeviceMacro) { viewModelScope.launch { settingsRepo.setDeviceMacro(deviceId, macro) } } @@ -224,6 +252,7 @@ class AppViewModel(app: Application) : AndroidViewModel(app) { fun setShowRemainingTime(value: Boolean) { viewModelScope.launch { settingsRepo.setShowRemainingTime(value) } } fun setShowReservation(value: Boolean) { viewModelScope.launch { settingsRepo.setShowReservation(value) } } fun setShowPowerControls(value: Boolean) { viewModelScope.launch { settingsRepo.setShowPowerControls(value) } } + fun setConfirmBeforeAction(value: Boolean) { viewModelScope.launch { settingsRepo.setConfirmBeforeAction(value) } } fun setToken(token: String) { viewModelScope.launch { settingsRepo.setToken(token) } } fun setDeviceLabel(deviceId: String, label: String) { viewModelScope.launch { settingsRepo.setDeviceLabel(deviceId, label) } } fun setDeviceImage(deviceId: String, imageRef: String) { viewModelScope.launch { settingsRepo.setDeviceImage(deviceId, imageRef) } } @@ -240,6 +269,9 @@ fun HomeUiState.imageRefFor(device: Device): String = /** The device's macro, or null if none configured. */ fun HomeUiState.macroFor(device: Device): DeviceMacro? = settings.deviceMacros[device.id] +private const val TOKEN_INVALID_MSG = + "SmartThings 토큰이 올바르지 않습니다. 관리자 화면에서 토큰을 다시 확인해 주세요." + const val DEFAULT_APP_NAME = "쉬운 가전 리모컨" /** App-level display name shown as the home header. */ diff --git a/app/src/test/java/kr/tkrmagid/easyappliance/MockDeviceRepositoryTest.kt b/app/src/test/java/kr/tkrmagid/easyappliance/MockDeviceRepositoryTest.kt index 24621df..c9af3d9 100644 --- a/app/src/test/java/kr/tkrmagid/easyappliance/MockDeviceRepositoryTest.kt +++ b/app/src/test/java/kr/tkrmagid/easyappliance/MockDeviceRepositoryTest.kt @@ -28,6 +28,7 @@ class MockDeviceRepositoryTest { assertEquals(32, on.remainingMinutes) assertTrue(on.running) assertEquals("물 온도 40도 · 탈수 강 · 헹굼 3회", on.currentSettings) + assertEquals("헹굼 중", on.jobPhase) repo.setPower("washer-1", false) val off = repo.getStatus("washer-1") diff --git a/app/src/test/java/kr/tkrmagid/easyappliance/SmartThingsStatusMapperTest.kt b/app/src/test/java/kr/tkrmagid/easyappliance/SmartThingsStatusMapperTest.kt index d45ae2b..f39708c 100644 --- a/app/src/test/java/kr/tkrmagid/easyappliance/SmartThingsStatusMapperTest.kt +++ b/app/src/test/java/kr/tkrmagid/easyappliance/SmartThingsStatusMapperTest.kt @@ -19,8 +19,9 @@ class SmartThingsStatusMapperTest { """ {"components":{"main":{ "switch":{"switch":{"value":"on"}}, - "washerOperatingState":{"machineState":{"value":"run"}}, + "washerOperatingState":{"machineState":{"value":"run"},"washerJobState":{"value":"rinse"}}, "samsungce.washerOperatingState":{"remainingTime":{"value":1980}}, + "samsungce.washerDelayEnd":{"remainingTime":{"value":0}}, "custom.washerWaterTemperature":{"washerWaterTemperature":{"value":"60"}}, "custom.washerSpinLevel":{"washerSpinLevel":{"value":"extraHigh"}}, "custom.washerRinseCycles":{"washerRinseCycles":{"value":"3"}} @@ -33,6 +34,23 @@ class SmartThingsStatusMapperTest { assertEquals(33, result.remainingMinutes) assertTrue(result.running) assertEquals("물 온도 60도 · 탈수 최강 · 헹굼 3회", result.currentSettings) + assertEquals("헹굼 중", result.jobPhase) + assertNull(result.reservation) + } + + @Test + fun mapsWasherReservation() { + val status = obj( + """ + {"components":{"main":{ + "switch":{"switch":{"value":"on"}}, + "washerOperatingState":{"machineState":{"value":"stop"}}, + "samsungce.washerDelayEnd":{"remainingTime":{"value":120}} + }}} + """.trimIndent(), + ) + val result = SmartThingsStatusMapper.map(status, DeviceType.WASHER) + assertEquals("약 120분 뒤 완료 예약", result.reservation) } @Test