7 Commits
v0.3.1 ... main

Author SHA1 Message Date
EJClaw Owner
71f30279d0 sync source to v0.3.9 (matches released EasyAppliance-v0.3.9.apk, verCode 12): security + reservation/job-phase/notice/confirm 2026-08-06 18:35:28 +09:00
EJClaw Owner
ffe8f93b0a sync source to v0.3.8 (matches released EasyAppliance-v0.3.8.apk, verCode 11): add stand aircon image 2026-08-06 16:35:23 +09:00
EJClaw Owner
a678a593df restore README.md (kept from previous main) 2026-08-06 15:45:55 +09:00
EJClaw Owner
9a264ba013 sync source to v0.3.7 (matches released EasyAppliance-v0.3.7.apk, verCode 10)
Brings the Gitea source up to date with the released v0.3.5-0.3.7 APKs:
real-device washer/aircon presets, current vs pre-start settings,
string/number command arg fix, all-bold typography.
2026-08-06 15:45:08 +09:00
tkrmagid
1462ee2172 feat: label sample device list in admin (v0.3.4) 2026-08-06 11:14:14 +09:00
tkrmagid
b5f135f475 feat: in-app update check + one-tap update (v0.3.3) 2026-08-06 11:03:10 +09:00
tkrmagid
067170802a feat: remove home refresh, admin list refresh, app name in OS (v0.3.2) 2026-08-05 17:16:28 +09:00
27 changed files with 978 additions and 84 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 = 4 versionCode = (project.findProperty("verCode") as String?)?.toInt() ?: 12
versionName = "0.3.1" versionName = (project.findProperty("verName") as String?) ?: "0.3.9"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
} }
@@ -61,6 +61,7 @@ android {
} }
buildFeatures { buildFeatures {
compose = true compose = true
buildConfig = true
} }
} }

View File

@@ -3,9 +3,11 @@
<uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" /> <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<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"
@@ -23,6 +25,16 @@
<category android:name="android.intent.category.LAUNCHER" /> <category android:name="android.intent.category.LAUNCHER" />
</intent-filter> </intent-filter>
</activity> </activity>
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
</provider>
</application> </application>
</manifest> </manifest>

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

@@ -36,12 +36,17 @@ class MockDeviceRepository : DeviceRepository {
"washer-1" -> DeviceStatus( "washer-1" -> DeviceStatus(
powerOn = on, powerOn = on,
operatingState = if (on) "운전 중" else "꺼짐", operatingState = if (on) "운전 중" else "꺼짐",
running = on,
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,
) )
"aircon-1" -> DeviceStatus( "aircon-1" -> DeviceStatus(
powerOn = on, powerOn = on,
operatingState = if (on) "냉방 중" else "꺼짐", operatingState = if (on) "냉방 중" else "꺼짐",
running = on,
currentSettings = if (on) "냉방 · 바람 자동" else null,
currentTemperature = 27, currentTemperature = 27,
targetTemperature = if (on) 24 else null, targetTemperature = if (on) 24 else null,
) )

View File

@@ -24,4 +24,11 @@ data class MacroStep(
data class DeviceMacro( data class DeviceMacro(
val enabled: Boolean = false, val enabled: Boolean = false,
val steps: List<MacroStep> = emptyList(), val steps: List<MacroStep> = emptyList(),
) ) {
/**
* One-line human summary of the configured start options, e.g.
* "물 온도 40도 · 수위 높음". Empty when nothing is configured.
*/
fun summary(): String =
steps.mapNotNull { it.label.takeIf { l -> l.isNotBlank() } }.joinToString(" · ")
}

View File

@@ -28,6 +28,15 @@ data class DeviceStatus(
val powerOn: Boolean = false, val powerOn: Boolean = false,
/** Human-readable run state, e.g. "운전 중", "정지", "일시정지". */ /** Human-readable run state, e.g. "운전 중", "정지", "일시정지". */
val operatingState: String? = null, val operatingState: String? = null,
/** True when a cycle is actually in progress (run/pause), not just powered. */
val running: Boolean = false,
/**
* Essential current settings while running, e.g. "물 온도 40도 · 수위 높음".
* Only the few important fields; null when not running / not reported.
*/
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

@@ -49,16 +49,32 @@ class SmartThingsDeviceRepository(
StCommand( StCommand(
capability = step.capability, capability = step.capability,
command = step.command, command = step.command,
arguments = step.args.map { it.toJsonElement() }, arguments = step.args.map { encodeArg(step.command, it) },
) )
} + StCommand(capability = "switch", command = "on") } + StCommand(capability = "switch", command = "on")
api.sendCommands(deviceId, StCommandsRequest(commands)) api.sendCommands(deviceId, StCommandsRequest(commands))
} }
private fun String.toJsonElement(): JsonElement = /**
toIntOrNull()?.let { JsonPrimitive(it) } * SmartThings commands are type-strict. Most appliance commands take string
?: toDoubleOrNull()?.let { JsonPrimitive(it) } * enums (e.g. washer temperature "40", fan "1") and MUST be sent as strings —
?: JsonPrimitive(this) * only setpoint-style commands take a number. So default to string and send a
* number only for the known numeric commands.
*/
private fun encodeArg(command: String, raw: String): JsonElement =
if (command in NUMERIC_COMMANDS) {
raw.toDoubleOrNull()?.let { d ->
if (d % 1.0 == 0.0) JsonPrimitive(d.toInt()) else JsonPrimitive(d)
} ?: JsonPrimitive(raw)
} else {
JsonPrimitive(raw)
}
private companion object {
val NUMERIC_COMMANDS = setOf(
"setCoolingSetpoint", "setHeatingSetpoint", "setThermostatSetpoint",
)
}
private fun inferType(caps: Set<String>, label: String?, name: String?): DeviceType { private fun inferType(caps: Set<String>, label: String?, name: String?): DeviceType {
val text = "${label.orEmpty()} ${name.orEmpty()}" val text = "${label.orEmpty()} ${name.orEmpty()}"

View File

@@ -37,12 +37,33 @@ object SmartThingsStatusMapper {
"stop" -> "정지" "stop" -> "정지"
else -> if (powerOn) "켜짐" else "꺼짐" else -> if (powerOn) "켜짐" else "꺼짐"
} }
val running = machine == "run" || machine == "pause"
val remaining = numberAttr(main, "samsungce.washerOperatingState", "remainingTime") val remaining = numberAttr(main, "samsungce.washerOperatingState", "remainingTime")
?.let { secondsToMinutes(it) } ?.let { secondsToMinutes(it) }
val temp = attr(main, "custom.washerWaterTemperature", "washerWaterTemperature")?.contentOrNull()
?.let { washerTempLabel(it) }
val spin = attr(main, "custom.washerSpinLevel", "washerSpinLevel")?.contentOrNull()
?.let { washerSpinLabel(it) }
val rinse = attr(main, "custom.washerRinseCycles", "washerRinseCycles")?.contentOrNull()
?.toIntOrNull()
val settings = listOfNotNull(
temp?.let { "물 온도 $it" },
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( return DeviceStatus(
powerOn = powerOn, powerOn = powerOn,
operatingState = state, operatingState = state,
running = running,
currentSettings = if (running) settings else null,
jobPhase = if (running) jobPhase else null,
remainingMinutes = remaining, remainingMinutes = remaining,
reservation = reservation,
) )
} }
@@ -50,14 +71,71 @@ object SmartThingsStatusMapper {
val current = numberAttr(main, "temperatureMeasurement", "temperature") val current = numberAttr(main, "temperatureMeasurement", "temperature")
val target = numberAttr(main, "thermostatCoolingSetpoint", "coolingSetpoint") val target = numberAttr(main, "thermostatCoolingSetpoint", "coolingSetpoint")
?: numberAttr(main, "custom.thermostatSetpointControl", "coolingSetpoint") ?: numberAttr(main, "custom.thermostatSetpointControl", "coolingSetpoint")
val mode = attr(main, "airConditionerMode", "airConditionerMode")?.contentOrNull()
?.let { acModeLabel(it) }
val fan = attr(main, "airConditionerFanMode", "fanMode")?.contentOrNull()
?.let { acFanLabel(it) }
val settings = listOfNotNull(
mode,
fan?.let { "바람 $it" },
).joinToString(" · ").ifBlank { null }
return DeviceStatus( return DeviceStatus(
powerOn = powerOn, powerOn = powerOn,
operatingState = if (powerOn) "냉방 중" else "꺼짐", operatingState = if (powerOn) (mode?.let { "$it" } ?: "켜짐") else "꺼짐",
running = powerOn,
currentSettings = if (powerOn) settings else null,
currentTemperature = current, currentTemperature = current,
targetTemperature = if (powerOn) target else null, targetTemperature = if (powerOn) target else null,
) )
} }
private fun washerTempLabel(raw: String): String = when (raw.lowercase()) {
"cold", "tapcold" -> "냉수"
"hot" -> "온수"
"none" -> "없음"
else -> raw.toIntOrNull()?.let { "${it}" } ?: raw
}
private fun washerSpinLabel(raw: String): String = when (raw.lowercase()) {
"nospin" -> "탈수없음"
"rinsehold" -> "헹굼정지"
"extralow" -> "매우약"
"low" -> ""
"medium" -> ""
"high" -> ""
"extrahigh" -> "최강"
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" -> "제습"
"wind" -> "송풍"
"heat" -> "난방"
"aicomfort" -> "AI쾌적"
"auto" -> "자동"
else -> raw
}
private fun acFanLabel(raw: String): String = when (raw.lowercase()) {
"auto" -> "자동"
"max" -> "최대"
else -> raw.toIntOrNull()?.let { "${it}" } ?: raw
}
private fun attr(main: JsonObject, capability: String, attribute: String): JsonPrimitive? = private fun attr(main: JsonObject, capability: String, attribute: String): JsonPrimitive? =
runCatching { runCatching {
main[capability]?.jsonObject?.get(attribute)?.jsonObject?.get("value") as? JsonPrimitive main[capability]?.jsonObject?.get(attribute)?.jsonObject?.get("value") as? JsonPrimitive

View File

@@ -0,0 +1,59 @@
package kr.tkrmagid.easyappliance.data.update
import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import retrofit2.Retrofit
import retrofit2.http.GET
import retrofit2.http.Path
import retrofit2.http.Query
import java.util.concurrent.TimeUnit
@Serializable
data class GiteaRelease(
@SerialName("tag_name") val tagName: String,
val name: String = "",
val body: String = "",
val draft: Boolean = false,
val prerelease: Boolean = false,
val assets: List<GiteaAsset> = emptyList(),
)
@Serializable
data class GiteaAsset(
val name: String,
@SerialName("browser_download_url") val downloadUrl: String,
)
interface GiteaApi {
@GET("api/v1/repos/{owner}/{repo}/releases")
suspend fun releases(
@Path("owner") owner: String,
@Path("repo") repo: String,
@Query("limit") limit: Int = 5,
): List<GiteaRelease>
}
/** Anonymous client for the public Gitea repo that hosts our releases. */
object GiteaClient {
private const val BASE_URL = "https://git.tkrmagid.kr/"
const val OWNER = "tkrmagid"
const val REPO = "washing_machine_app"
fun create(): GiteaApi {
val json = Json { ignoreUnknownKeys = true }
val client = OkHttpClient.Builder()
.connectTimeout(15, TimeUnit.SECONDS)
.readTimeout(15, TimeUnit.SECONDS)
.build()
return Retrofit.Builder()
.baseUrl(BASE_URL)
.client(client)
.addConverterFactory(json.asConverterFactory("application/json".toMediaType()))
.build()
.create(GiteaApi::class.java)
}
}

View File

@@ -0,0 +1,53 @@
package kr.tkrmagid.easyappliance.data.update
/** A newer release available for install. */
data class UpdateInfo(
val versionName: String,
val apkUrl: String,
val notes: String,
)
class UpdateRepository(private val api: GiteaApi = GiteaClient.create()) {
/**
* Returns the newest release that is strictly newer than [currentVersion]
* and ships an .apk asset, or null if none / on error.
*/
suspend fun findUpdate(currentVersion: String): UpdateInfo? {
val releases = runCatching {
api.releases(GiteaClient.OWNER, GiteaClient.REPO, limit = 10)
}.getOrElse { return null }
return releases
.asSequence()
.filter { !it.draft }
.mapNotNull { release ->
val apk = release.assets.firstOrNull { it.name.endsWith(".apk", ignoreCase = true) }
?: return@mapNotNull null
UpdateInfo(
versionName = release.tagName.removePrefix("v"),
apkUrl = apk.downloadUrl,
notes = release.body.ifBlank { release.name },
)
}
.filter { compareVersions(it.versionName, currentVersion) > 0 }
.toList()
.reduceOrNull { a, b -> if (compareVersions(b.versionName, a.versionName) > 0) b else a }
}
}
private fun String.toComparableKey(): List<Int> =
removePrefix("v").split(".", "-").mapNotNull { it.toIntOrNull() }
/** Semver-ish compare: returns >0 if [a] newer than [b], 0 if equal, <0 if older. */
fun compareVersions(a: String, b: String): Int {
val pa = a.toComparableKey()
val pb = b.toComparableKey()
val n = maxOf(pa.size, pb.size)
for (i in 0 until n) {
val x = pa.getOrElse(i) { 0 }
val y = pb.getOrElse(i) { 0 }
if (x != y) return x - y
}
return 0
}

View File

@@ -0,0 +1,52 @@
package kr.tkrmagid.easyappliance.data.update
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.os.Build
import android.provider.Settings
import androidx.core.content.FileProvider
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient
import okhttp3.Request
import java.io.File
/** Downloads a release APK and launches the system installer. */
object Updater {
/** True if the app may install packages (Android <8 always, else user-granted). */
fun canInstall(context: Context): Boolean =
Build.VERSION.SDK_INT < Build.VERSION_CODES.O ||
context.packageManager.canRequestPackageInstalls()
/** Send the user to the "install unknown apps" settings for this app. */
fun openInstallPermissionSettings(context: Context) {
val intent = Intent(
Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES,
Uri.parse("package:${context.packageName}"),
).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
context.startActivity(intent)
}
suspend fun downloadApk(context: Context, url: String): File = withContext(Dispatchers.IO) {
val dir = File(context.cacheDir, "updates").apply { mkdirs() }
val file = File(dir, "update.apk")
val client = OkHttpClient()
client.newCall(Request.Builder().url(url).build()).execute().use { resp ->
if (!resp.isSuccessful) error("download failed: ${resp.code}")
val body = resp.body ?: error("empty body")
body.byteStream().use { input -> file.outputStream().use { input.copyTo(it) } }
}
file
}
fun installApk(context: Context, file: File) {
val uri = FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", file)
val intent = Intent(Intent.ACTION_VIEW).apply {
setDataAndType(uri, "application/vnd.android.package-archive")
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_ACTIVITY_NEW_TASK)
}
context.startActivity(intent)
}
}

View File

@@ -18,10 +18,13 @@ import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.selection.selectable import androidx.compose.foundation.selection.selectable
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.Card import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults import androidx.compose.material3.CardDefaults
import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextField
@@ -58,10 +61,12 @@ import kr.tkrmagid.easyappliance.vm.macroFor
fun AdminScreen( fun AdminScreen(
state: HomeUiState, state: HomeUiState,
onSelectDevice: (String) -> Unit, onSelectDevice: (String) -> Unit,
onRefreshDevices: () -> Unit,
onSetAppName: (String) -> Unit, onSetAppName: (String) -> Unit,
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,
@@ -111,6 +116,24 @@ fun AdminScreen(
} }
} }
if (!state.usingRealApi) {
Spacer(Modifier.height(12.dp))
Text(
"지금 목록은 예시(샘플) 기기입니다. 실제 기기를 보려면 아래 'SmartThings 연결'에서 토큰을 입력한 뒤 '목록 새로고침'을 누르세요.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.error,
)
}
Spacer(Modifier.height(12.dp))
OutlinedButton(
onClick = onRefreshDevices,
modifier = Modifier.fillMaxWidth().height(60.dp),
) {
Icon(Icons.Filled.Refresh, contentDescription = null, modifier = Modifier.size(26.dp))
Text(" 목록 새로고침", style = MaterialTheme.typography.bodyLarge)
}
if (selected != null) { if (selected != null) {
Spacer(Modifier.height(28.dp)) Spacer(Modifier.height(28.dp))
SectionTitle("기기 이름 바꾸기") SectionTitle("기기 이름 바꾸기")
@@ -149,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

@@ -1,5 +1,9 @@
package kr.tkrmagid.easyappliance.ui package kr.tkrmagid.easyappliance.ui
import android.app.ActivityManager
import android.content.Context
import android.content.ContextWrapper
import androidx.activity.ComponentActivity
import androidx.activity.compose.BackHandler import androidx.activity.compose.BackHandler
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
@@ -12,17 +16,25 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.lifecycle.Lifecycle import androidx.lifecycle.Lifecycle
import androidx.lifecycle.compose.LifecycleEventEffect import androidx.lifecycle.compose.LifecycleEventEffect
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import kr.tkrmagid.easyappliance.data.koreanLabel import kr.tkrmagid.easyappliance.data.koreanLabel
import kr.tkrmagid.easyappliance.vm.AppViewModel import kr.tkrmagid.easyappliance.vm.AppViewModel
import kr.tkrmagid.easyappliance.vm.appDisplayName
import kr.tkrmagid.easyappliance.vm.displayLabelFor import kr.tkrmagid.easyappliance.vm.displayLabelFor
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
private enum class Screen { HOME, ADMIN } private enum class Screen { HOME, ADMIN }
private tailrec fun Context.findActivity(): ComponentActivity? = when (this) {
is ComponentActivity -> this
is ContextWrapper -> baseContext.findActivity()
else -> null
}
private const val POLL_OK_MS = 15_000L private const val POLL_OK_MS = 15_000L
private const val POLL_OFFLINE_MS = 5_000L private const val POLL_OFFLINE_MS = 5_000L
@@ -32,6 +44,16 @@ fun AppRoot() {
val state by vm.state.collectAsStateWithLifecycle() val state by vm.state.collectAsStateWithLifecycle()
var screen by rememberSaveable { mutableStateOf(Screen.HOME) } var screen by rememberSaveable { mutableStateOf(Screen.HOME) }
// Reflect the custom app name in the OS: the label shown in the Recents/task
// switcher updates at runtime (the launcher icon label stays fixed by Android).
val context = LocalContext.current
val appName = state.appDisplayName()
LaunchedEffect(appName) {
context.findActivity()?.setTaskDescription(
ActivityManager.TaskDescription(appName),
)
}
// Refresh when returning to the foreground... // Refresh when returning to the foreground...
LifecycleEventEffect(Lifecycle.Event.ON_RESUME) { vm.refresh() } LifecycleEventEffect(Lifecycle.Event.ON_RESUME) { vm.refresh() }
// ...and poll while composed; retry faster while disconnected so the warning // ...and poll while composed; retry faster while disconnected so the warning
@@ -55,7 +77,7 @@ fun AppRoot() {
state = state, state = state,
onTogglePower = vm::togglePower, onTogglePower = vm::togglePower,
onRunMacro = vm::runMacro, onRunMacro = vm::runMacro,
onRefresh = vm::refresh, onClearNotice = vm::clearNotice,
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.adminUnlockGesture { screen = Screen.ADMIN }, .adminUnlockGesture { screen = Screen.ADMIN },
@@ -63,10 +85,12 @@ fun AppRoot() {
Screen.ADMIN -> AdminScreen( Screen.ADMIN -> AdminScreen(
state = state, state = state,
onSelectDevice = vm::selectDevice, onSelectDevice = vm::selectDevice,
onRefreshDevices = vm::refreshDevices,
onSetAppName = vm::setAppName, onSetAppName = vm::setAppName,
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,
@@ -85,6 +109,16 @@ fun AppRoot() {
onDismiss = vm::dismissWarning, onDismiss = vm::dismissWarning,
) )
} }
state.update?.let { update ->
UpdateDialog(
update = update,
downloading = state.updateDownloading,
message = state.updateMessage,
onUpdate = vm::startUpdate,
onDismiss = vm::dismissUpdate,
)
}
} }
} }
} }

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,7 +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.material.icons.filled.Refresh 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,9 +23,13 @@ import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator 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.OutlinedButton
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
@@ -33,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
@@ -46,7 +53,7 @@ fun HomeScreen(
state: HomeUiState, state: HomeUiState,
onTogglePower: (Boolean) -> Unit, onTogglePower: (Boolean) -> Unit,
onRunMacro: () -> Unit, onRunMacro: () -> Unit,
onRefresh: () -> Unit, onClearNotice: () -> Unit = {},
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
BoxWithConstraints(modifier = modifier.fillMaxSize()) { BoxWithConstraints(modifier = modifier.fillMaxSize()) {
@@ -61,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()
@@ -72,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,
@@ -81,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))
@@ -93,37 +111,102 @@ fun HomeScreen(
StatusCard(state) StatusCard(state)
val status = state.status
val preStart = state.macroFor(device)
?.takeIf { it.enabled && it.steps.isNotEmpty() }
?.summary()
if (status?.running == true && !status.currentSettings.isNullOrBlank()) {
Spacer(Modifier.height(16.dp))
SelectedOptionsCard(title = "현재 작동 설정", summary = status.currentSettings)
} else if (status?.running != true && preStart != null) {
Spacer(Modifier.height(16.dp))
SelectedOptionsCard(title = "시작 설정", summary = preStart)
}
if (state.settings.showPowerControls) { if (state.settings.showPowerControls) {
Spacer(Modifier.height(28.dp)) Spacer(Modifier.height(28.dp))
val macroOn = state.macroFor(device)?.enabled == true val macroOn = state.macroFor(device)?.enabled == true
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) }
},
) )
} }
} }
Spacer(Modifier.height(20.dp)) state.lastUpdatedAt?.let { ts ->
OutlinedButton( Spacer(Modifier.height(16.dp))
onClick = onRefresh, Text(
modifier = Modifier.fillMaxWidth().height(BigButtonHeight), text = "마지막 확인 ${formatTime(ts)}" + if (!state.connected) " · 오프라인" else "",
) { style = MaterialTheme.typography.bodyMedium,
Icon(Icons.Filled.Refresh, contentDescription = null, modifier = Modifier.size(30.dp)) color = MaterialTheme.colorScheme.onSurfaceVariant,
Text(" 새로고침", style = MaterialTheme.typography.labelLarge) 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(
@@ -181,6 +274,33 @@ private fun StatusCard(state: HomeUiState) {
} }
} }
@Composable
private fun SelectedOptionsCard(title: String, summary: String) {
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant),
elevation = CardDefaults.cardElevation(defaultElevation = 1.dp),
) {
Column(
modifier = Modifier.fillMaxWidth().padding(20.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(
title,
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.primary,
textAlign = TextAlign.Center,
)
Spacer(Modifier.height(8.dp))
Text(
summary,
style = MaterialTheme.typography.titleLarge,
textAlign = TextAlign.Center,
)
}
}
}
@Composable @Composable
private fun PowerControls(isOn: Boolean, stacked: Boolean, onTogglePower: (Boolean) -> Unit) { private fun PowerControls(isOn: Boolean, stacked: Boolean, onTogglePower: (Boolean) -> Unit) {
val onButton: @Composable (Modifier) -> Unit = { m -> val onButton: @Composable (Modifier) -> Unit = { m ->

View File

@@ -34,23 +34,67 @@ import kr.tkrmagid.easyappliance.data.DeviceMacro
import kr.tkrmagid.easyappliance.data.DeviceType import kr.tkrmagid.easyappliance.data.DeviceType
import kr.tkrmagid.easyappliance.data.MacroStep import kr.tkrmagid.easyappliance.data.MacroStep
// Capabilities/commands and supported values below match the user's real
// Samsung washer/aircon (read live from the SmartThings API).
private const val TEMP_CAP = "custom.washerWaterTemperature" private const val TEMP_CAP = "custom.washerWaterTemperature"
private const val TEMP_CMD = "setWasherWaterTemperature" private const val TEMP_CMD = "setWasherWaterTemperature"
private const val LEVEL_CAP = "custom.washerWaterLevel" private const val SPIN_CAP = "custom.washerSpinLevel"
private const val LEVEL_CMD = "setWasherWaterLevel" private const val SPIN_CMD = "setWasherSpinLevel"
private const val RINSE_CAP = "custom.washerRinseCycles"
private const val RINSE_CMD = "setWasherRinseCycles"
private const val AC_MODE_CAP = "airConditionerMode"
private const val AC_MODE_CMD = "setAirConditionerMode"
private const val AC_TEMP_CAP = "thermostatCoolingSetpoint"
private const val AC_TEMP_CMD = "setCoolingSetpoint"
private const val AC_FAN_CAP = "airConditionerFanMode"
private const val AC_FAN_CMD = "setFanMode"
private data class Preset(val value: String, val label: String) private data class Preset(val value: String, val label: String)
private val TEMP_PRESETS = listOf( private val TEMP_PRESETS = listOf(
Preset("cold", "냉수"), Preset("cold", "냉수"),
Preset("20", "20도"),
Preset("30", "30도"), Preset("30", "30도"),
Preset("40", "40도"), Preset("40", "40도"),
Preset("60", "60도"), Preset("60", "60도"),
Preset("90", "90도"),
) )
private val LEVEL_PRESETS = listOf( private val SPIN_PRESETS = listOf(
Preset("low", ""), Preset("noSpin", "탈수없"),
Preset("medium", "중간"), Preset("low", ""),
Preset("high", "높음"), Preset("medium", ""),
Preset("high", ""),
Preset("extraHigh", "최강"),
)
private val RINSE_PRESETS = listOf(
Preset("1", "1회"),
Preset("2", "2회"),
Preset("3", "3회"),
Preset("4", "4회"),
Preset("5", "5회"),
)
private val AC_MODE_PRESETS = listOf(
Preset("cool", "냉방"),
Preset("dry", "제습"),
Preset("wind", "송풍"),
Preset("aIComfort", "AI쾌적"),
)
private val AC_TEMP_PRESETS = listOf(
Preset("18", "18도"),
Preset("20", "20도"),
Preset("22", "22도"),
Preset("24", "24도"),
Preset("26", "26도"),
Preset("28", "28도"),
)
private val AC_FAN_PRESETS = listOf(
Preset("auto", "자동"),
Preset("1", "1단"),
Preset("2", "2단"),
Preset("3", "3단"),
Preset("4", "4단"),
Preset("max", "최대"),
) )
@OptIn(ExperimentalLayoutApi::class) @OptIn(ExperimentalLayoutApi::class)
@@ -80,45 +124,24 @@ fun MacroEditor(device: Device, macro: DeviceMacro, onChange: (DeviceMacro) -> U
if (!macro.enabled) return@Column if (!macro.enabled) return@Column
if (device.type == DeviceType.WASHER) { if (device.type == DeviceType.WASHER) {
Spacer(Modifier.height(8.dp)) PresetGroup("물 온도", TEMP_CAP, TEMP_CMD, "물 온도", TEMP_PRESETS, macro, onChange)
Text("물 온도", style = MaterialTheme.typography.bodyMedium) PresetGroup("탈수 세기", SPIN_CAP, SPIN_CMD, "탈수", SPIN_PRESETS, macro, onChange)
FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) { PresetGroup("헹굼 횟수", RINSE_CAP, RINSE_CMD, "헹굼", RINSE_PRESETS, macro, onChange)
TEMP_PRESETS.forEach { p ->
val selected = macro.steps.any { it.capability == TEMP_CAP && it.args == listOf(p.value) }
FilterChip(
selected = selected,
onClick = {
onChange(
macro.upsert(
MacroStep(TEMP_CAP, TEMP_CMD, listOf(p.value), "물 온도 ${p.label}"),
),
)
},
label = { Text(p.label) },
)
}
}
Spacer(Modifier.height(8.dp))
Text("수위(물 높이)", style = MaterialTheme.typography.bodyMedium)
FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
LEVEL_PRESETS.forEach { p ->
val selected = macro.steps.any { it.capability == LEVEL_CAP && it.args == listOf(p.value) }
FilterChip(
selected = selected,
onClick = {
onChange(
macro.upsert(
MacroStep(LEVEL_CAP, LEVEL_CMD, listOf(p.value), "수위 ${p.label}"),
),
)
},
label = { Text(p.label) },
)
}
}
Spacer(Modifier.height(4.dp)) Spacer(Modifier.height(4.dp))
Text( Text(
"삼성 세탁기 기준 예시 명령입니다. 실제 기기에서 동작하지 않으면 아래 '직접 추가'로 조정하세요.", "연결된 세탁기가 실제로 지원하는 설정입니다. 물높이(수위)는 세탁기가 자동으로 맞추므로 직접 설정하지 않습니다.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
if (device.type == DeviceType.AIRCONDITIONER) {
PresetGroup("운전 모드", AC_MODE_CAP, AC_MODE_CMD, "", AC_MODE_PRESETS, macro, onChange)
PresetGroup("설정 온도", AC_TEMP_CAP, AC_TEMP_CMD, "설정 온도", AC_TEMP_PRESETS, macro, onChange)
PresetGroup("바람 세기", AC_FAN_CAP, AC_FAN_CMD, "바람 세기", AC_FAN_PRESETS, macro, onChange)
Spacer(Modifier.height(4.dp))
Text(
"※ 연결된 에어컨이 실제로 지원하는 설정입니다.",
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
) )
@@ -157,6 +180,34 @@ fun MacroEditor(device: Device, macro: DeviceMacro, onChange: (DeviceMacro) -> U
} }
} }
@OptIn(ExperimentalLayoutApi::class)
@Composable
private fun PresetGroup(
title: String,
capability: String,
command: String,
labelPrefix: String,
presets: List<Preset>,
macro: DeviceMacro,
onChange: (DeviceMacro) -> Unit,
) {
Spacer(Modifier.height(8.dp))
Text(title, style = MaterialTheme.typography.bodyMedium)
FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
presets.forEach { p ->
val selected = macro.steps.any { it.capability == capability && it.args == listOf(p.value) }
val stepLabel = if (labelPrefix.isBlank()) p.label else "$labelPrefix ${p.label}"
FilterChip(
selected = selected,
onClick = {
onChange(macro.upsert(MacroStep(capability, command, listOf(p.value), stepLabel)))
},
label = { Text(p.label) },
)
}
}
}
@Composable @Composable
private fun AdvancedStepAdder(onAdd: (MacroStep) -> Unit) { private fun AdvancedStepAdder(onAdd: (MacroStep) -> Unit) {
var cap by remember { mutableStateOf("") } var cap by remember { mutableStateOf("") }

View File

@@ -0,0 +1,68 @@
package kr.tkrmagid.easyappliance.ui
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.size
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.Button
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import kr.tkrmagid.easyappliance.data.update.UpdateInfo
/** Shown on launch when a newer release exists. */
@Composable
fun UpdateDialog(
update: UpdateInfo,
downloading: Boolean,
message: String?,
onUpdate: () -> Unit,
onDismiss: () -> Unit,
) {
AlertDialog(
onDismissRequest = { if (!downloading) onDismiss() },
title = { Text("새 버전이 있습니다", style = MaterialTheme.typography.titleLarge) },
text = {
Column {
Text(
"버전 ${update.versionName} 로 업데이트할 수 있습니다.",
style = MaterialTheme.typography.bodyLarge,
)
if (downloading) {
Spacer(Modifier.height(16.dp))
Row(verticalAlignment = Alignment.CenterVertically) {
CircularProgressIndicator(modifier = Modifier.size(28.dp))
Text(" 다운로드 중...", style = MaterialTheme.typography.bodyLarge)
}
}
if (message != null) {
Spacer(Modifier.height(12.dp))
Text(
message,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.error,
)
}
}
},
confirmButton = {
Button(onClick = onUpdate, enabled = !downloading) {
Text("업데이트", style = MaterialTheme.typography.labelLarge)
}
},
dismissButton = {
if (!downloading) {
TextButton(onClick = onDismiss) {
Text("나중에", style = MaterialTheme.typography.bodyLarge)
}
}
},
)
}

View File

@@ -6,12 +6,14 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
// Larger-than-default type scale so text stays readable for older users. // Larger-than-default type scale so text stays readable for older users.
// Every style is Bold so all text stays thick and easy to read.
val ElderlyTypography = Typography( val ElderlyTypography = Typography(
displayLarge = TextStyle(fontWeight = FontWeight.Bold, fontSize = 48.sp, lineHeight = 56.sp), displayLarge = TextStyle(fontWeight = FontWeight.Bold, fontSize = 48.sp, lineHeight = 56.sp),
headlineLarge = TextStyle(fontWeight = FontWeight.Bold, fontSize = 40.sp, lineHeight = 48.sp), headlineLarge = TextStyle(fontWeight = FontWeight.Bold, fontSize = 40.sp, lineHeight = 48.sp),
headlineMedium = TextStyle(fontWeight = FontWeight.Bold, fontSize = 32.sp, lineHeight = 40.sp), headlineMedium = TextStyle(fontWeight = FontWeight.Bold, fontSize = 32.sp, lineHeight = 40.sp),
titleLarge = TextStyle(fontWeight = FontWeight.Bold, fontSize = 28.sp, lineHeight = 34.sp), titleLarge = TextStyle(fontWeight = FontWeight.Bold, fontSize = 28.sp, lineHeight = 34.sp),
bodyLarge = TextStyle(fontWeight = FontWeight.Normal, fontSize = 22.sp, lineHeight = 30.sp), titleMedium = TextStyle(fontWeight = FontWeight.Bold, fontSize = 24.sp, lineHeight = 30.sp),
bodyMedium = TextStyle(fontWeight = FontWeight.Normal, fontSize = 20.sp, lineHeight = 28.sp), bodyLarge = TextStyle(fontWeight = FontWeight.Bold, fontSize = 22.sp, lineHeight = 30.sp),
bodyMedium = TextStyle(fontWeight = FontWeight.Bold, fontSize = 20.sp, lineHeight = 28.sp),
labelLarge = TextStyle(fontWeight = FontWeight.Bold, fontSize = 24.sp, lineHeight = 30.sp), labelLarge = TextStyle(fontWeight = FontWeight.Bold, fontSize = 24.sp, lineHeight = 30.sp),
) )

View File

@@ -11,8 +11,12 @@ import kr.tkrmagid.easyappliance.data.MockDeviceRepository
import kr.tkrmagid.easyappliance.data.DeviceImages import kr.tkrmagid.easyappliance.data.DeviceImages
import kr.tkrmagid.easyappliance.data.DeviceMacro import kr.tkrmagid.easyappliance.data.DeviceMacro
import kr.tkrmagid.easyappliance.data.SettingsRepository import kr.tkrmagid.easyappliance.data.SettingsRepository
import kr.tkrmagid.easyappliance.BuildConfig
import kr.tkrmagid.easyappliance.data.smartthings.SmartThingsClient import kr.tkrmagid.easyappliance.data.smartthings.SmartThingsClient
import kr.tkrmagid.easyappliance.data.smartthings.SmartThingsDeviceRepository import kr.tkrmagid.easyappliance.data.smartthings.SmartThingsDeviceRepository
import kr.tkrmagid.easyappliance.data.update.UpdateInfo
import kr.tkrmagid.easyappliance.data.update.UpdateRepository
import kr.tkrmagid.easyappliance.data.update.Updater
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
@@ -25,11 +29,18 @@ data class HomeUiState(
val selected: Device? = null, val selected: Device? = null,
val status: DeviceStatus? = null, val status: DeviceStatus? = null,
val settings: AppSettings = AppSettings(), val settings: AppSettings = AppSettings(),
val update: UpdateInfo? = null,
val updateDownloading: Boolean = false,
val updateMessage: String? = null,
val usingRealApi: Boolean = false, val usingRealApi: Boolean = false,
/** False when the selected device / API cannot be reached. */ /** False when the selected device / API cannot be reached. */
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
@@ -38,6 +49,7 @@ data class HomeUiState(
class AppViewModel(app: Application) : AndroidViewModel(app) { class AppViewModel(app: Application) : AndroidViewModel(app) {
private val settingsRepo = SettingsRepository(app) private val settingsRepo = SettingsRepository(app)
private val updateRepo = UpdateRepository()
private var deviceRepo: DeviceRepository = MockDeviceRepository() private var deviceRepo: DeviceRepository = MockDeviceRepository()
private var activeToken: String? = null private var activeToken: String? = null
private var lastSelectedId: String? = null private var lastSelectedId: String? = null
@@ -48,9 +60,44 @@ 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) }
} }
checkForUpdate()
}
/** Check the release repo for a newer version (called on app start). */
fun checkForUpdate() {
viewModelScope.launch {
val info = updateRepo.findUpdate(BuildConfig.VERSION_NAME)
_state.update { it.copy(update = info) }
}
}
/** Download the update APK and launch the system installer. */
fun startUpdate() {
val info = _state.value.update ?: return
val ctx = getApplication<Application>()
if (!Updater.canInstall(ctx)) {
Updater.openInstallPermissionSettings(ctx)
_state.update { it.copy(updateMessage = "설치 권한을 허용한 뒤 다시 눌러주세요.") }
return
}
viewModelScope.launch {
_state.update { it.copy(updateDownloading = true, updateMessage = null) }
val file = runCatching { Updater.downloadApk(ctx, info.apkUrl) }.getOrNull()
_state.update { it.copy(updateDownloading = false) }
if (file == null) {
_state.update { it.copy(updateMessage = "다운로드에 실패했습니다. 인터넷 연결을 확인해주세요.") }
return@launch
}
Updater.installApk(ctx, file)
}
}
fun dismissUpdate() {
_state.update { it.copy(update = null, updateMessage = null) }
} }
private suspend fun applySettings(settings: AppSettings) { private suspend fun applySettings(settings: AppSettings) {
@@ -77,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 }
@@ -108,21 +164,38 @@ 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
viewModelScope.launch { loadStatus(id) } viewModelScope.launch { loadStatus(id) }
} }
/** Reload the device list from the repository (admin "목록 새로고침"). */
fun refreshDevices() {
viewModelScope.launch {
loadDevices()
resolveSelectionAndStatus(_state.value.settings)
}
}
/** "다시 연결하기" — clear preview flag and re-attempt list + status. */ /** "다시 연결하기" — clear preview flag and re-attempt list + status. */
fun retryConnection() { fun retryConnection() {
forcedWarning = false forcedWarning = false
@@ -150,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)
} }
} }
@@ -161,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) }
} }
@@ -174,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) } }
@@ -190,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

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<paths>
<cache-path name="update_cache" path="updates/" />
<external-cache-path name="update_ext_cache" path="updates/" />
</paths>

View File

@@ -0,0 +1,37 @@
package kr.tkrmagid.easyappliance
import kr.tkrmagid.easyappliance.data.DeviceMacro
import kr.tkrmagid.easyappliance.data.MacroStep
import org.junit.Assert.assertEquals
import org.junit.Test
class MacroTest {
@Test
fun summaryJoinsStepLabels() {
val macro = DeviceMacro(
enabled = true,
steps = listOf(
MacroStep("custom.washerWaterTemperature", "setWasherWaterTemperature", listOf("40"), "물 온도 40도"),
MacroStep("custom.washerWaterLevel", "setWasherWaterLevel", listOf("high"), "수위 높음"),
),
)
assertEquals("물 온도 40도 · 수위 높음", macro.summary())
}
@Test
fun summaryIsEmptyWhenNoSteps() {
assertEquals("", DeviceMacro(enabled = true).summary())
}
@Test
fun summarySkipsBlankLabels() {
val macro = DeviceMacro(
steps = listOf(
MacroStep("thermostatCoolingSetpoint", "setCoolingSetpoint", listOf("24"), "설정 온도 24도"),
MacroStep("airConditionerFanMode", "setFanMode", listOf("high"), ""),
),
)
assertEquals("설정 온도 24도", macro.summary())
}
}

View File

@@ -26,9 +26,15 @@ class MockDeviceRepositoryTest {
assertTrue(on.powerOn) assertTrue(on.powerOn)
assertEquals("운전 중", on.operatingState) assertEquals("운전 중", on.operatingState)
assertEquals(32, on.remainingMinutes) assertEquals(32, on.remainingMinutes)
assertTrue(on.running)
assertEquals("물 온도 40도 · 탈수 강 · 헹굼 3회", on.currentSettings)
assertEquals("헹굼 중", on.jobPhase)
repo.setPower("washer-1", false) repo.setPower("washer-1", false)
assertFalse(repo.getStatus("washer-1").powerOn) val off = repo.getStatus("washer-1")
assertFalse(off.powerOn)
assertFalse(off.running)
assertEquals(null, off.currentSettings)
} }
@Test @Test

View File

@@ -19,8 +19,12 @@ 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.washerSpinLevel":{"washerSpinLevel":{"value":"extraHigh"}},
"custom.washerRinseCycles":{"washerRinseCycles":{"value":"3"}}
}}} }}}
""".trimIndent(), """.trimIndent(),
) )
@@ -28,6 +32,43 @@ class SmartThingsStatusMapperTest {
assertTrue(result.powerOn) assertTrue(result.powerOn)
assertEquals("운전 중", result.operatingState) assertEquals("운전 중", result.operatingState)
assertEquals(33, result.remainingMinutes) 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
fun stoppedWasherHasNoCurrentSettings() {
val status = obj(
"""
{"components":{"main":{
"switch":{"switch":{"value":"on"}},
"washerOperatingState":{"machineState":{"value":"stop"}},
"custom.washerWaterTemperature":{"washerWaterTemperature":{"value":"40"}},
"custom.washerSpinLevel":{"washerSpinLevel":{"value":"high"}}
}}}
""".trimIndent(),
)
val result = SmartThingsStatusMapper.map(status, DeviceType.WASHER)
assertEquals("정지", result.operatingState)
assertTrue(!result.running)
assertNull(result.currentSettings)
} }
@Test @Test
@@ -36,6 +77,8 @@ class SmartThingsStatusMapperTest {
""" """
{"components":{"main":{ {"components":{"main":{
"switch":{"switch":{"value":"on"}}, "switch":{"switch":{"value":"on"}},
"airConditionerMode":{"airConditionerMode":{"value":"cool"}},
"airConditionerFanMode":{"fanMode":{"value":"auto"}},
"temperatureMeasurement":{"temperature":{"value":27}}, "temperatureMeasurement":{"temperature":{"value":27}},
"thermostatCoolingSetpoint":{"coolingSetpoint":{"value":24}} "thermostatCoolingSetpoint":{"coolingSetpoint":{"value":24}}
}}} }}}
@@ -46,6 +89,7 @@ class SmartThingsStatusMapperTest {
assertEquals("냉방 중", result.operatingState) assertEquals("냉방 중", result.operatingState)
assertEquals(27, result.currentTemperature) assertEquals(27, result.currentTemperature)
assertEquals(24, result.targetTemperature) assertEquals(24, result.targetTemperature)
assertEquals("냉방 · 바람 자동", result.currentSettings)
} }
@Test @Test

View File

@@ -0,0 +1,27 @@
package kr.tkrmagid.easyappliance
import kr.tkrmagid.easyappliance.data.update.compareVersions
import org.junit.Assert.assertTrue
import org.junit.Test
class VersionCompareTest {
@Test
fun newerIsGreater() {
assertTrue(compareVersions("0.3.3", "0.3.2") > 0)
assertTrue(compareVersions("0.4.0", "0.3.9") > 0)
assertTrue(compareVersions("1.0.0", "0.9.9") > 0)
}
@Test
fun equalIsZero() {
assertTrue(compareVersions("0.3.2", "0.3.2") == 0)
assertTrue(compareVersions("v0.3.2", "0.3.2") == 0)
}
@Test
fun olderIsNegative() {
assertTrue(compareVersions("0.3.1", "0.3.2") < 0)
assertTrue(compareVersions("0.3", "0.3.1") < 0)
}
}