2 Commits
v0.3.7 ... main

16 changed files with 293 additions and 19 deletions

View File

@@ -24,8 +24,8 @@ android {
applicationId = "kr.tkrmagid.easyappliance" applicationId = "kr.tkrmagid.easyappliance"
minSdk = 24 minSdk = 24
targetSdk = 35 targetSdk = 35
versionCode = (project.findProperty("verCode") as String?)?.toInt() ?: 10 versionCode = (project.findProperty("verCode") as String?)?.toInt() ?: 12
versionName = (project.findProperty("verName") as String?) ?: "0.3.7" versionName = (project.findProperty("verName") as String?) ?: "0.3.9"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
} }

View File

@@ -6,7 +6,8 @@
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" /> <uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
<application <application
android:allowBackup="true" android:allowBackup="false"
android:usesCleartextTraffic="false"
android:icon="@mipmap/ic_launcher" android:icon="@mipmap/ic_launcher"
android:label="@string/app_name" android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher" android:roundIcon="@mipmap/ic_launcher"

View File

@@ -15,7 +15,8 @@ object DeviceImages {
val builtins: List<Builtin> = listOf( val builtins: List<Builtin> = listOf(
Builtin("washer", "세탁기", R.drawable.img_washer), Builtin("washer", "세탁기", R.drawable.img_washer),
Builtin("aircon", "에어컨", R.drawable.img_aircon), Builtin("aircon", "벽걸이 에어컨", R.drawable.img_aircon),
Builtin("aircon_stand", "스탠드 에어컨", R.drawable.img_aircon_stand),
Builtin("fridge", "냉장고", R.drawable.img_fridge), Builtin("fridge", "냉장고", R.drawable.img_fridge),
Builtin("tv", "TV", R.drawable.img_tv), Builtin("tv", "TV", R.drawable.img_tv),
Builtin("device", "기타 기기", R.drawable.img_device), Builtin("device", "기타 기기", R.drawable.img_device),

View File

@@ -38,6 +38,7 @@ class MockDeviceRepository : DeviceRepository {
operatingState = if (on) "운전 중" else "꺼짐", operatingState = if (on) "운전 중" else "꺼짐",
running = on, running = on,
currentSettings = if (on) "물 온도 40도 · 탈수 강 · 헹굼 3회" else null, currentSettings = if (on) "물 온도 40도 · 탈수 강 · 헹굼 3회" else null,
jobPhase = if (on) "헹굼 중" else null,
remainingMinutes = if (on) 32 else null, remainingMinutes = if (on) 32 else null,
reservation = null, reservation = null,
) )

View File

@@ -35,6 +35,8 @@ data class DeviceStatus(
* Only the few important fields; null when not running / not reported. * Only the few important fields; null when not running / not reported.
*/ */
val currentSettings: String? = null, val currentSettings: String? = null,
/** Current job phase while running, e.g. "세탁 중", "헹굼 중", "탈수 중". */
val jobPhase: String? = null,
/** Remaining minutes for a running cycle, if reported. */ /** Remaining minutes for a running cycle, if reported. */
val remainingMinutes: Int? = null, val remainingMinutes: Int? = null,
/** Reservation / scheduled-start description, if set. */ /** Reservation / scheduled-start description, if set. */

View File

@@ -23,6 +23,8 @@ data class AppSettings(
val showRemainingTime: Boolean = true, val showRemainingTime: Boolean = true,
val showReservation: Boolean = true, val showReservation: Boolean = true,
val showPowerControls: Boolean = true, val showPowerControls: Boolean = true,
/** Ask for confirmation before start / power off (guards against mis-taps). */
val confirmBeforeAction: Boolean = true,
/** SmartThings Personal Access Token; null/blank means use sample data. */ /** SmartThings Personal Access Token; null/blank means use sample data. */
val smartThingsToken: String? = null, val smartThingsToken: String? = null,
/** deviceId -> custom display name. */ /** deviceId -> custom display name. */
@@ -43,7 +45,9 @@ class SettingsRepository(private val context: Context) {
showRemainingTime = p[KEY_SHOW_REMAINING] ?: true, showRemainingTime = p[KEY_SHOW_REMAINING] ?: true,
showReservation = p[KEY_SHOW_RESERVATION] ?: true, showReservation = p[KEY_SHOW_RESERVATION] ?: true,
showPowerControls = p[KEY_SHOW_POWER] ?: 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]), labelOverrides = decodeMap(p[KEY_LABEL_OVERRIDES]),
imageOverrides = decodeMap(p[KEY_IMAGE_OVERRIDES]), imageOverrides = decodeMap(p[KEY_IMAGE_OVERRIDES]),
deviceMacros = decodeMacros(p[KEY_MACROS]), deviceMacros = decodeMacros(p[KEY_MACROS]),
@@ -65,8 +69,27 @@ class SettingsRepository(private val context: Context) {
suspend fun setShowPowerControls(value: Boolean) = suspend fun setShowPowerControls(value: Boolean) =
edit { it[KEY_SHOW_POWER] = value } edit { it[KEY_SHOW_POWER] = value }
suspend fun setToken(token: String) = suspend fun setConfirmBeforeAction(value: Boolean) =
edit { it[KEY_TOKEN] = token.trim() } 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 -> suspend fun setDeviceLabel(deviceId: String, label: String) = edit { prefs ->
val map = decodeMap(prefs[KEY_LABEL_OVERRIDES]).toMutableMap() 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_REMAINING = booleanPreferencesKey("show_remaining_time")
val KEY_SHOW_RESERVATION = booleanPreferencesKey("show_reservation") val KEY_SHOW_RESERVATION = booleanPreferencesKey("show_reservation")
val KEY_SHOW_POWER = booleanPreferencesKey("show_power_controls") 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 = stringPreferencesKey("smartthings_token")
val KEY_TOKEN_ENC = stringPreferencesKey("smartthings_token_enc")
val KEY_LABEL_OVERRIDES = stringPreferencesKey("label_overrides_json") val KEY_LABEL_OVERRIDES = stringPreferencesKey("label_overrides_json")
val KEY_IMAGE_OVERRIDES = stringPreferencesKey("image_overrides_json") val KEY_IMAGE_OVERRIDES = stringPreferencesKey("image_overrides_json")
val KEY_MACROS = stringPreferencesKey("device_macros_json") val KEY_MACROS = stringPreferencesKey("device_macros_json")

View File

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

View File

@@ -51,12 +51,19 @@ object SmartThingsStatusMapper {
spin?.let { "탈수 $it" }, spin?.let { "탈수 $it" },
rinse?.let { "헹굼 ${it}" }, rinse?.let { "헹굼 ${it}" },
).joinToString(" · ").ifBlank { null } ).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( return DeviceStatus(
powerOn = powerOn, powerOn = powerOn,
operatingState = state, operatingState = state,
running = running, running = running,
currentSettings = if (running) settings else null, currentSettings = if (running) settings else null,
jobPhase = if (running) jobPhase else null,
remainingMinutes = remaining, remainingMinutes = remaining,
reservation = reservation,
) )
} }
@@ -100,6 +107,19 @@ object SmartThingsStatusMapper {
else -> raw 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()) { private fun acModeLabel(raw: String): String = when (raw.lowercase()) {
"cool" -> "냉방" "cool" -> "냉방"
"dry" -> "제습" "dry" -> "제습"

View File

@@ -66,6 +66,7 @@ fun AdminScreen(
onToggleRemaining: (Boolean) -> Unit, onToggleRemaining: (Boolean) -> Unit,
onToggleReservation: (Boolean) -> Unit, onToggleReservation: (Boolean) -> Unit,
onTogglePowerControls: (Boolean) -> Unit, onTogglePowerControls: (Boolean) -> Unit,
onToggleConfirmAction: (Boolean) -> Unit,
onSetLabel: (String, String) -> Unit, onSetLabel: (String, String) -> Unit,
onSetImageRef: (String, String) -> Unit, onSetImageRef: (String, String) -> Unit,
onSetToken: (String) -> Unit, onSetToken: (String) -> Unit,
@@ -171,6 +172,8 @@ fun AdminScreen(
ToggleRow("예약 정보 표시", state.settings.showReservation, onToggleReservation) ToggleRow("예약 정보 표시", state.settings.showReservation, onToggleReservation)
HorizontalDivider() HorizontalDivider()
ToggleRow("켜기 / 끄기 버튼 표시", state.settings.showPowerControls, onTogglePowerControls) ToggleRow("켜기 / 끄기 버튼 표시", state.settings.showPowerControls, onTogglePowerControls)
HorizontalDivider()
ToggleRow("시작 / 끄기 전 확인창", state.settings.confirmBeforeAction, onToggleConfirmAction)
} }
} }

View File

@@ -77,6 +77,7 @@ fun AppRoot() {
state = state, state = state,
onTogglePower = vm::togglePower, onTogglePower = vm::togglePower,
onRunMacro = vm::runMacro, onRunMacro = vm::runMacro,
onClearNotice = vm::clearNotice,
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.adminUnlockGesture { screen = Screen.ADMIN }, .adminUnlockGesture { screen = Screen.ADMIN },
@@ -89,6 +90,7 @@ fun AppRoot() {
onToggleRemaining = vm::setShowRemainingTime, onToggleRemaining = vm::setShowRemainingTime,
onToggleReservation = vm::setShowReservation, onToggleReservation = vm::setShowReservation,
onTogglePowerControls = vm::setShowPowerControls, onTogglePowerControls = vm::setShowPowerControls,
onToggleConfirmAction = vm::setConfirmBeforeAction,
onSetLabel = vm::setDeviceLabel, onSetLabel = vm::setDeviceLabel,
onSetImageRef = vm::setDeviceImage, onSetImageRef = vm::setDeviceImage,
onSetToken = vm::setToken, onSetToken = vm::setToken,

View File

@@ -16,20 +16,21 @@ import kr.tkrmagid.easyappliance.data.DeviceImages
fun DeviceImage( fun DeviceImage(
imageRef: String, imageRef: String,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
contentDescription: String? = null,
contentScale: ContentScale = ContentScale.Fit, contentScale: ContentScale = ContentScale.Fit,
) { ) {
val builtinRes = DeviceImages.resForRef(imageRef) val builtinRes = DeviceImages.resForRef(imageRef)
if (builtinRes != null) { if (builtinRes != null) {
Image( Image(
painter = painterResource(builtinRes), painter = painterResource(builtinRes),
contentDescription = null, contentDescription = contentDescription,
modifier = modifier, modifier = modifier,
contentScale = contentScale, contentScale = contentScale,
) )
} else { } else {
AsyncImage( AsyncImage(
model = imageRef, model = imageRef,
contentDescription = null, contentDescription = contentDescription,
modifier = modifier, modifier = modifier,
contentScale = ContentScale.Crop, contentScale = ContentScale.Crop,
) )

View File

@@ -15,6 +15,7 @@ import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.PowerSettingsNew import androidx.compose.material.icons.filled.PowerSettingsNew
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Card import androidx.compose.material3.Card
@@ -23,7 +24,12 @@ import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable 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.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
@@ -31,6 +37,9 @@ import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import kr.tkrmagid.easyappliance.data.DeviceType import kr.tkrmagid.easyappliance.data.DeviceType
import androidx.compose.material.icons.filled.PlayArrow 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.HomeUiState
import kr.tkrmagid.easyappliance.vm.appDisplayName import kr.tkrmagid.easyappliance.vm.appDisplayName
import kr.tkrmagid.easyappliance.vm.displayLabelFor import kr.tkrmagid.easyappliance.vm.displayLabelFor
@@ -44,6 +53,7 @@ fun HomeScreen(
state: HomeUiState, state: HomeUiState,
onTogglePower: (Boolean) -> Unit, onTogglePower: (Boolean) -> Unit,
onRunMacro: () -> Unit, onRunMacro: () -> Unit,
onClearNotice: () -> Unit = {},
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
BoxWithConstraints(modifier = modifier.fillMaxSize()) { BoxWithConstraints(modifier = modifier.fillMaxSize()) {
@@ -58,6 +68,12 @@ fun HomeScreen(
return@BoxWithConstraints return@BoxWithConstraints
} }
// Optional confirmation before start / power actions (guards mis-taps).
var pending by remember { mutableStateOf<PendingAction?>(null) }
val request: (String, () -> Unit) -> Unit = { msg, act ->
if (state.settings.confirmBeforeAction) pending = PendingAction(msg, act) else act()
}
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
@@ -69,6 +85,10 @@ fun HomeScreen(
modifier = Modifier.widthIn(max = contentMaxWidth).fillMaxWidth(), modifier = Modifier.widthIn(max = contentMaxWidth).fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
) { ) {
state.notice?.let { msg ->
NoticeCard(message = msg, onClose = onClearNotice)
Spacer(Modifier.height(16.dp))
}
Text( Text(
text = state.appDisplayName(), text = state.appDisplayName(),
style = MaterialTheme.typography.titleLarge, style = MaterialTheme.typography.titleLarge,
@@ -78,6 +98,7 @@ fun HomeScreen(
Spacer(Modifier.height(16.dp)) Spacer(Modifier.height(16.dp))
DeviceImage( DeviceImage(
imageRef = state.imageRefFor(device), imageRef = state.imageRefFor(device),
contentDescription = state.displayLabelFor(device),
modifier = Modifier.size(if (narrow) 96.dp else 128.dp), modifier = Modifier.size(if (narrow) 96.dp else 128.dp),
) )
Spacer(Modifier.height(12.dp)) Spacer(Modifier.height(12.dp))
@@ -108,22 +129,84 @@ fun HomeScreen(
if (macroOn) { if (macroOn) {
MacroControls( MacroControls(
isOn = state.status?.powerOn == true, isOn = state.status?.powerOn == true,
onStart = onRunMacro, onStart = { request("정말 시작할까요?") { onRunMacro() } },
onOff = { onTogglePower(false) }, onOff = { request("정말 끌까요?") { onTogglePower(false) } },
) )
} else { } else {
PowerControls( PowerControls(
isOn = state.status?.powerOn == true, isOn = state.status?.powerOn == true,
stacked = narrow, 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 @Composable
private fun StatusCard(state: HomeUiState) { private fun StatusCard(state: HomeUiState) {
Card( Card(
@@ -150,6 +233,16 @@ private fun StatusCard(state: HomeUiState) {
textAlign = TextAlign.Center, 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) { if (state.settings.showRemainingTime && status?.remainingMinutes != null) {
Spacer(Modifier.height(12.dp)) Spacer(Modifier.height(12.dp))
Text( Text(

View File

@@ -37,6 +37,10 @@ data class HomeUiState(
val connected: Boolean = true, val connected: Boolean = true,
/** True after the user closes the warning with the small "x". */ /** True after the user closes the warning with the small "x". */
val warningDismissed: Boolean = false, 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 error: String? = null,
) { ) {
val showConnectionWarning: Boolean get() = !connected && !warningDismissed val showConnectionWarning: Boolean get() = !connected && !warningDismissed
@@ -56,6 +60,7 @@ class AppViewModel(app: Application) : AndroidViewModel(app) {
val state: StateFlow<HomeUiState> = _state.asStateFlow() val state: StateFlow<HomeUiState> = _state.asStateFlow()
init { init {
viewModelScope.launch { settingsRepo.migratePlaintextToken() }
viewModelScope.launch { viewModelScope.launch {
settingsRepo.settings.collect { settings -> applySettings(settings) } settingsRepo.settings.collect { settings -> applySettings(settings) }
} }
@@ -119,11 +124,20 @@ class AppViewModel(app: Application) : AndroidViewModel(app) {
.onSuccess { devices -> .onSuccess { devices ->
_state.update { it.copy(devices = devices, connected = true) } _state.update { it.copy(devices = devices, connected = true) }
} }
.onFailure { .onFailure { e ->
_state.update { it.copy(loading = false, connected = false) } _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) { private suspend fun resolveSelectionAndStatus(settings: AppSettings) {
val devices = _state.value.devices val devices = _state.value.devices
val selected = devices.firstOrNull { it.id == settings.selectedDeviceId } val selected = devices.firstOrNull { it.id == settings.selectedDeviceId }
@@ -150,15 +164,24 @@ class AppViewModel(app: Application) : AndroidViewModel(app) {
status = status, status = status,
connected = !forcedWarning, connected = !forcedWarning,
warningDismissed = if (!forcedWarning) false else it.warningDismissed, warningDismissed = if (!forcedWarning) false else it.warningDismissed,
lastUpdatedAt = System.currentTimeMillis(),
error = null, error = null,
) )
} }
} }
.onFailure { .onFailure { e ->
_state.update { it.copy(loading = false, connected = false) } _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. */ /** Refresh selected device status; also used by the periodic poll. */
fun refresh() { fun refresh() {
val id = _state.value.selected?.id ?: return val id = _state.value.selected?.id ?: return
@@ -200,7 +223,8 @@ class AppViewModel(app: Application) : AndroidViewModel(app) {
val id = _state.value.selected?.id ?: return val id = _state.value.selected?.id ?: return
viewModelScope.launch { viewModelScope.launch {
runCatching { deviceRepo.setPower(id, on) } 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) loadStatus(id)
} }
} }
@@ -211,11 +235,15 @@ class AppViewModel(app: Application) : AndroidViewModel(app) {
val macro = _state.value.settings.deviceMacros[id] ?: return val macro = _state.value.settings.deviceMacros[id] ?: return
viewModelScope.launch { viewModelScope.launch {
runCatching { deviceRepo.runMacro(id, macro.steps) } 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) loadStatus(id)
} }
} }
private fun commandFailMsg(e: Throwable): String =
if (e.isAuthError()) TOKEN_INVALID_MSG else "명령을 보내지 못했어요. 잠시 후 다시 시도해 주세요."
fun setDeviceMacro(deviceId: String, macro: DeviceMacro) { fun setDeviceMacro(deviceId: String, macro: DeviceMacro) {
viewModelScope.launch { settingsRepo.setDeviceMacro(deviceId, macro) } 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 setShowRemainingTime(value: Boolean) { viewModelScope.launch { settingsRepo.setShowRemainingTime(value) } }
fun setShowReservation(value: Boolean) { viewModelScope.launch { settingsRepo.setShowReservation(value) } } fun setShowReservation(value: Boolean) { viewModelScope.launch { settingsRepo.setShowReservation(value) } }
fun setShowPowerControls(value: Boolean) { viewModelScope.launch { settingsRepo.setShowPowerControls(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 setToken(token: String) { viewModelScope.launch { settingsRepo.setToken(token) } }
fun setDeviceLabel(deviceId: String, label: String) { viewModelScope.launch { settingsRepo.setDeviceLabel(deviceId, label) } } fun setDeviceLabel(deviceId: String, label: String) { viewModelScope.launch { settingsRepo.setDeviceLabel(deviceId, label) } }
fun setDeviceImage(deviceId: String, imageRef: String) { viewModelScope.launch { settingsRepo.setDeviceImage(deviceId, imageRef) } } 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. */ /** The device's macro, or null if none configured. */
fun HomeUiState.macroFor(device: Device): DeviceMacro? = settings.deviceMacros[device.id] fun HomeUiState.macroFor(device: Device): DeviceMacro? = settings.deviceMacros[device.id]
private const val TOKEN_INVALID_MSG =
"SmartThings 토큰이 올바르지 않습니다. 관리자 화면에서 토큰을 다시 확인해 주세요."
const val DEFAULT_APP_NAME = "쉬운 가전 리모컨" const val DEFAULT_APP_NAME = "쉬운 가전 리모컨"
/** App-level display name shown as the home header. */ /** App-level display name shown as the home header. */

View File

@@ -0,0 +1,17 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="96dp" android:height="96dp"
android:viewportWidth="48" android:viewportHeight="48">
<!-- tall standing body -->
<path android:fillColor="#1565C0"
android:pathData="M18,5 h12 a3,3 0 0 1 3,3 v32 a3,3 0 0 1 -3,3 h-12 a3,3 0 0 1 -3,-3 v-32 a3,3 0 0 1 3,-3 z" />
<!-- top intake vent -->
<path android:fillColor="#90CAF9" android:pathData="M19.5,9 h9 v3 h-9 z" />
<!-- display strip -->
<path android:fillColor="#FFFFFF" android:pathData="M21,15 h6 v2 h-6 z" />
<!-- lower air outlet -->
<path android:fillColor="#FFFFFF" android:pathData="M18.5,34 h11 v2.4 h-11 z" />
<!-- airflow curls under the outlet -->
<path android:fillColor="#00000000"
android:strokeColor="#42A5F5" android:strokeWidth="1.3"
android:pathData="M20,39 q1.5,2.5 3,0 M25,39 q1.5,2.5 3,0" />
</vector>

View File

@@ -28,6 +28,7 @@ class MockDeviceRepositoryTest {
assertEquals(32, on.remainingMinutes) assertEquals(32, on.remainingMinutes)
assertTrue(on.running) assertTrue(on.running)
assertEquals("물 온도 40도 · 탈수 강 · 헹굼 3회", on.currentSettings) assertEquals("물 온도 40도 · 탈수 강 · 헹굼 3회", on.currentSettings)
assertEquals("헹굼 중", on.jobPhase)
repo.setPower("washer-1", false) repo.setPower("washer-1", false)
val off = repo.getStatus("washer-1") val off = repo.getStatus("washer-1")

View File

@@ -19,8 +19,9 @@ class SmartThingsStatusMapperTest {
""" """
{"components":{"main":{ {"components":{"main":{
"switch":{"switch":{"value":"on"}}, "switch":{"switch":{"value":"on"}},
"washerOperatingState":{"machineState":{"value":"run"}}, "washerOperatingState":{"machineState":{"value":"run"},"washerJobState":{"value":"rinse"}},
"samsungce.washerOperatingState":{"remainingTime":{"value":1980}}, "samsungce.washerOperatingState":{"remainingTime":{"value":1980}},
"samsungce.washerDelayEnd":{"remainingTime":{"value":0}},
"custom.washerWaterTemperature":{"washerWaterTemperature":{"value":"60"}}, "custom.washerWaterTemperature":{"washerWaterTemperature":{"value":"60"}},
"custom.washerSpinLevel":{"washerSpinLevel":{"value":"extraHigh"}}, "custom.washerSpinLevel":{"washerSpinLevel":{"value":"extraHigh"}},
"custom.washerRinseCycles":{"washerRinseCycles":{"value":"3"}} "custom.washerRinseCycles":{"washerRinseCycles":{"value":"3"}}
@@ -33,6 +34,23 @@ class SmartThingsStatusMapperTest {
assertEquals(33, result.remainingMinutes) assertEquals(33, result.remainingMinutes)
assertTrue(result.running) assertTrue(result.running)
assertEquals("물 온도 60도 · 탈수 최강 · 헹굼 3회", result.currentSettings) 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 @Test