Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
71f30279d0 | ||
|
|
ffe8f93b0a | ||
|
|
a678a593df | ||
|
|
9a264ba013 | ||
|
|
1462ee2172 | ||
|
|
b5f135f475 | ||
|
|
067170802a |
@@ -24,8 +24,8 @@ android {
|
||||
applicationId = "kr.tkrmagid.easyappliance"
|
||||
minSdk = 24
|
||||
targetSdk = 35
|
||||
versionCode = 4
|
||||
versionName = "0.3.1"
|
||||
versionCode = (project.findProperty("verCode") as String?)?.toInt() ?: 12
|
||||
versionName = (project.findProperty("verName") as String?) ?: "0.3.9"
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
}
|
||||
|
||||
@@ -61,6 +61,7 @@ android {
|
||||
}
|
||||
buildFeatures {
|
||||
compose = true
|
||||
buildConfig = true
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,9 +3,11 @@
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
|
||||
|
||||
<application
|
||||
android:allowBackup="true"
|
||||
android:allowBackup="false"
|
||||
android:usesCleartextTraffic="false"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
android:roundIcon="@mipmap/ic_launcher"
|
||||
@@ -23,6 +25,16 @@
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</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>
|
||||
|
||||
</manifest>
|
||||
|
||||
@@ -15,7 +15,8 @@ object DeviceImages {
|
||||
|
||||
val builtins: List<Builtin> = listOf(
|
||||
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("tv", "TV", R.drawable.img_tv),
|
||||
Builtin("device", "기타 기기", R.drawable.img_device),
|
||||
|
||||
@@ -36,12 +36,17 @@ class MockDeviceRepository : DeviceRepository {
|
||||
"washer-1" -> DeviceStatus(
|
||||
powerOn = on,
|
||||
operatingState = if (on) "운전 중" else "꺼짐",
|
||||
running = on,
|
||||
currentSettings = if (on) "물 온도 40도 · 탈수 강 · 헹굼 3회" else null,
|
||||
jobPhase = if (on) "헹굼 중" else null,
|
||||
remainingMinutes = if (on) 32 else null,
|
||||
reservation = null,
|
||||
)
|
||||
"aircon-1" -> DeviceStatus(
|
||||
powerOn = on,
|
||||
operatingState = if (on) "냉방 중" else "꺼짐",
|
||||
running = on,
|
||||
currentSettings = if (on) "냉방 · 바람 자동" else null,
|
||||
currentTemperature = 27,
|
||||
targetTemperature = if (on) 24 else null,
|
||||
)
|
||||
|
||||
@@ -24,4 +24,11 @@ data class MacroStep(
|
||||
data class DeviceMacro(
|
||||
val enabled: Boolean = false,
|
||||
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(" · ")
|
||||
}
|
||||
|
||||
@@ -28,6 +28,15 @@ data class DeviceStatus(
|
||||
val powerOn: Boolean = false,
|
||||
/** Human-readable run state, e.g. "운전 중", "정지", "일시정지". */
|
||||
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. */
|
||||
val remainingMinutes: Int? = null,
|
||||
/** Reservation / scheduled-start description, if set. */
|
||||
|
||||
@@ -23,6 +23,8 @@ data class AppSettings(
|
||||
val showRemainingTime: Boolean = true,
|
||||
val showReservation: 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. */
|
||||
val smartThingsToken: String? = null,
|
||||
/** deviceId -> custom display name. */
|
||||
@@ -43,7 +45,9 @@ class SettingsRepository(private val context: Context) {
|
||||
showRemainingTime = p[KEY_SHOW_REMAINING] ?: true,
|
||||
showReservation = p[KEY_SHOW_RESERVATION] ?: true,
|
||||
showPowerControls = p[KEY_SHOW_POWER] ?: true,
|
||||
smartThingsToken = p[KEY_TOKEN]?.takeIf { it.isNotBlank() },
|
||||
confirmBeforeAction = p[KEY_CONFIRM_ACTION] ?: true,
|
||||
smartThingsToken = (p[KEY_TOKEN_ENC]?.let { TokenCrypto.decrypt(it) } ?: p[KEY_TOKEN])
|
||||
?.takeIf { it.isNotBlank() },
|
||||
labelOverrides = decodeMap(p[KEY_LABEL_OVERRIDES]),
|
||||
imageOverrides = decodeMap(p[KEY_IMAGE_OVERRIDES]),
|
||||
deviceMacros = decodeMacros(p[KEY_MACROS]),
|
||||
@@ -65,8 +69,27 @@ class SettingsRepository(private val context: Context) {
|
||||
suspend fun setShowPowerControls(value: Boolean) =
|
||||
edit { it[KEY_SHOW_POWER] = value }
|
||||
|
||||
suspend fun setToken(token: String) =
|
||||
edit { it[KEY_TOKEN] = token.trim() }
|
||||
suspend fun setConfirmBeforeAction(value: Boolean) =
|
||||
edit { it[KEY_CONFIRM_ACTION] = value }
|
||||
|
||||
suspend fun setToken(token: String) = edit { p ->
|
||||
val t = token.trim()
|
||||
if (t.isBlank()) {
|
||||
p.remove(KEY_TOKEN_ENC); p.remove(KEY_TOKEN)
|
||||
} else {
|
||||
val enc = TokenCrypto.encrypt(t)
|
||||
if (enc != null) { p[KEY_TOKEN_ENC] = enc; p.remove(KEY_TOKEN) }
|
||||
else p[KEY_TOKEN] = t // last-resort fallback if the keystore is unavailable
|
||||
}
|
||||
}
|
||||
|
||||
/** One-time move of any legacy plaintext token into encrypted storage. */
|
||||
suspend fun migratePlaintextToken() = edit { p ->
|
||||
val legacy = p[KEY_TOKEN]
|
||||
if (!legacy.isNullOrBlank() && p[KEY_TOKEN_ENC] == null) {
|
||||
TokenCrypto.encrypt(legacy)?.let { p[KEY_TOKEN_ENC] = it; p.remove(KEY_TOKEN) }
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun setDeviceLabel(deviceId: String, label: String) = edit { prefs ->
|
||||
val map = decodeMap(prefs[KEY_LABEL_OVERRIDES]).toMutableMap()
|
||||
@@ -104,7 +127,9 @@ class SettingsRepository(private val context: Context) {
|
||||
val KEY_SHOW_REMAINING = booleanPreferencesKey("show_remaining_time")
|
||||
val KEY_SHOW_RESERVATION = booleanPreferencesKey("show_reservation")
|
||||
val KEY_SHOW_POWER = booleanPreferencesKey("show_power_controls")
|
||||
val KEY_CONFIRM_ACTION = booleanPreferencesKey("confirm_before_action")
|
||||
val KEY_TOKEN = stringPreferencesKey("smartthings_token")
|
||||
val KEY_TOKEN_ENC = stringPreferencesKey("smartthings_token_enc")
|
||||
val KEY_LABEL_OVERRIDES = stringPreferencesKey("label_overrides_json")
|
||||
val KEY_IMAGE_OVERRIDES = stringPreferencesKey("image_overrides_json")
|
||||
val KEY_MACROS = stringPreferencesKey("device_macros_json")
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -49,16 +49,32 @@ class SmartThingsDeviceRepository(
|
||||
StCommand(
|
||||
capability = step.capability,
|
||||
command = step.command,
|
||||
arguments = step.args.map { it.toJsonElement() },
|
||||
arguments = step.args.map { encodeArg(step.command, it) },
|
||||
)
|
||||
} + StCommand(capability = "switch", command = "on")
|
||||
api.sendCommands(deviceId, StCommandsRequest(commands))
|
||||
}
|
||||
|
||||
private fun String.toJsonElement(): JsonElement =
|
||||
toIntOrNull()?.let { JsonPrimitive(it) }
|
||||
?: toDoubleOrNull()?.let { JsonPrimitive(it) }
|
||||
?: JsonPrimitive(this)
|
||||
/**
|
||||
* SmartThings commands are type-strict. Most appliance commands take string
|
||||
* enums (e.g. washer temperature "40", fan "1") and MUST be sent as strings —
|
||||
* 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 {
|
||||
val text = "${label.orEmpty()} ${name.orEmpty()}"
|
||||
|
||||
@@ -37,12 +37,33 @@ object SmartThingsStatusMapper {
|
||||
"stop" -> "정지"
|
||||
else -> if (powerOn) "켜짐" else "꺼짐"
|
||||
}
|
||||
val running = machine == "run" || machine == "pause"
|
||||
val remaining = numberAttr(main, "samsungce.washerOperatingState", "remainingTime")
|
||||
?.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(
|
||||
powerOn = powerOn,
|
||||
operatingState = state,
|
||||
running = running,
|
||||
currentSettings = if (running) settings else null,
|
||||
jobPhase = if (running) jobPhase else null,
|
||||
remainingMinutes = remaining,
|
||||
reservation = reservation,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -50,14 +71,71 @@ object SmartThingsStatusMapper {
|
||||
val current = numberAttr(main, "temperatureMeasurement", "temperature")
|
||||
val target = numberAttr(main, "thermostatCoolingSetpoint", "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(
|
||||
powerOn = powerOn,
|
||||
operatingState = if (powerOn) "냉방 중" else "꺼짐",
|
||||
operatingState = if (powerOn) (mode?.let { "$it 중" } ?: "켜짐") else "꺼짐",
|
||||
running = powerOn,
|
||||
currentSettings = if (powerOn) settings else null,
|
||||
currentTemperature = current,
|
||||
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? =
|
||||
runCatching {
|
||||
main[capability]?.jsonObject?.get(attribute)?.jsonObject?.get("value") as? JsonPrimitive
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -18,10 +18,13 @@ import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.selection.selectable
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
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.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
@@ -58,10 +61,12 @@ import kr.tkrmagid.easyappliance.vm.macroFor
|
||||
fun AdminScreen(
|
||||
state: HomeUiState,
|
||||
onSelectDevice: (String) -> Unit,
|
||||
onRefreshDevices: () -> Unit,
|
||||
onSetAppName: (String) -> Unit,
|
||||
onToggleRemaining: (Boolean) -> Unit,
|
||||
onToggleReservation: (Boolean) -> Unit,
|
||||
onTogglePowerControls: (Boolean) -> Unit,
|
||||
onToggleConfirmAction: (Boolean) -> Unit,
|
||||
onSetLabel: (String, String) -> Unit,
|
||||
onSetImageRef: (String, 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) {
|
||||
Spacer(Modifier.height(28.dp))
|
||||
SectionTitle("기기 이름 바꾸기")
|
||||
@@ -149,6 +172,8 @@ fun AdminScreen(
|
||||
ToggleRow("예약 정보 표시", state.settings.showReservation, onToggleReservation)
|
||||
HorizontalDivider()
|
||||
ToggleRow("켜기 / 끄기 버튼 표시", state.settings.showPowerControls, onTogglePowerControls)
|
||||
HorizontalDivider()
|
||||
ToggleRow("시작 / 끄기 전 확인창", state.settings.confirmBeforeAction, onToggleConfirmAction)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
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.compose.foundation.layout.Box
|
||||
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.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.compose.LifecycleEventEffect
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import kr.tkrmagid.easyappliance.data.koreanLabel
|
||||
import kr.tkrmagid.easyappliance.vm.AppViewModel
|
||||
import kr.tkrmagid.easyappliance.vm.appDisplayName
|
||||
import kr.tkrmagid.easyappliance.vm.displayLabelFor
|
||||
import kotlinx.coroutines.delay
|
||||
|
||||
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_OFFLINE_MS = 5_000L
|
||||
|
||||
@@ -32,6 +44,16 @@ fun AppRoot() {
|
||||
val state by vm.state.collectAsStateWithLifecycle()
|
||||
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...
|
||||
LifecycleEventEffect(Lifecycle.Event.ON_RESUME) { vm.refresh() }
|
||||
// ...and poll while composed; retry faster while disconnected so the warning
|
||||
@@ -55,7 +77,7 @@ fun AppRoot() {
|
||||
state = state,
|
||||
onTogglePower = vm::togglePower,
|
||||
onRunMacro = vm::runMacro,
|
||||
onRefresh = vm::refresh,
|
||||
onClearNotice = vm::clearNotice,
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.adminUnlockGesture { screen = Screen.ADMIN },
|
||||
@@ -63,10 +85,12 @@ fun AppRoot() {
|
||||
Screen.ADMIN -> AdminScreen(
|
||||
state = state,
|
||||
onSelectDevice = vm::selectDevice,
|
||||
onRefreshDevices = vm::refreshDevices,
|
||||
onSetAppName = vm::setAppName,
|
||||
onToggleRemaining = vm::setShowRemainingTime,
|
||||
onToggleReservation = vm::setShowReservation,
|
||||
onTogglePowerControls = vm::setShowPowerControls,
|
||||
onToggleConfirmAction = vm::setConfirmBeforeAction,
|
||||
onSetLabel = vm::setDeviceLabel,
|
||||
onSetImageRef = vm::setDeviceImage,
|
||||
onSetToken = vm::setToken,
|
||||
@@ -85,6 +109,16 @@ fun AppRoot() {
|
||||
onDismiss = vm::dismissWarning,
|
||||
)
|
||||
}
|
||||
|
||||
state.update?.let { update ->
|
||||
UpdateDialog(
|
||||
update = update,
|
||||
downloading = state.updateDownloading,
|
||||
message = state.updateMessage,
|
||||
onUpdate = vm::startUpdate,
|
||||
onDismiss = vm::dismissUpdate,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,20 +16,21 @@ import kr.tkrmagid.easyappliance.data.DeviceImages
|
||||
fun DeviceImage(
|
||||
imageRef: String,
|
||||
modifier: Modifier = Modifier,
|
||||
contentDescription: String? = null,
|
||||
contentScale: ContentScale = ContentScale.Fit,
|
||||
) {
|
||||
val builtinRes = DeviceImages.resForRef(imageRef)
|
||||
if (builtinRes != null) {
|
||||
Image(
|
||||
painter = painterResource(builtinRes),
|
||||
contentDescription = null,
|
||||
contentDescription = contentDescription,
|
||||
modifier = modifier,
|
||||
contentScale = contentScale,
|
||||
)
|
||||
} else {
|
||||
AsyncImage(
|
||||
model = imageRef,
|
||||
contentDescription = null,
|
||||
contentDescription = contentDescription,
|
||||
modifier = modifier,
|
||||
contentScale = ContentScale.Crop,
|
||||
)
|
||||
|
||||
@@ -15,7 +15,7 @@ import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.PowerSettingsNew
|
||||
import androidx.compose.material.icons.filled.Refresh
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.Card
|
||||
@@ -23,9 +23,13 @@ import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
@@ -33,6 +37,9 @@ import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kr.tkrmagid.easyappliance.data.DeviceType
|
||||
import androidx.compose.material.icons.filled.PlayArrow
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
import kr.tkrmagid.easyappliance.vm.HomeUiState
|
||||
import kr.tkrmagid.easyappliance.vm.appDisplayName
|
||||
import kr.tkrmagid.easyappliance.vm.displayLabelFor
|
||||
@@ -46,7 +53,7 @@ fun HomeScreen(
|
||||
state: HomeUiState,
|
||||
onTogglePower: (Boolean) -> Unit,
|
||||
onRunMacro: () -> Unit,
|
||||
onRefresh: () -> Unit,
|
||||
onClearNotice: () -> Unit = {},
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
BoxWithConstraints(modifier = modifier.fillMaxSize()) {
|
||||
@@ -61,6 +68,12 @@ fun HomeScreen(
|
||||
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(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
@@ -72,6 +85,10 @@ fun HomeScreen(
|
||||
modifier = Modifier.widthIn(max = contentMaxWidth).fillMaxWidth(),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
state.notice?.let { msg ->
|
||||
NoticeCard(message = msg, onClose = onClearNotice)
|
||||
Spacer(Modifier.height(16.dp))
|
||||
}
|
||||
Text(
|
||||
text = state.appDisplayName(),
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
@@ -81,6 +98,7 @@ fun HomeScreen(
|
||||
Spacer(Modifier.height(16.dp))
|
||||
DeviceImage(
|
||||
imageRef = state.imageRefFor(device),
|
||||
contentDescription = state.displayLabelFor(device),
|
||||
modifier = Modifier.size(if (narrow) 96.dp else 128.dp),
|
||||
)
|
||||
Spacer(Modifier.height(12.dp))
|
||||
@@ -93,37 +111,102 @@ fun HomeScreen(
|
||||
|
||||
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) {
|
||||
Spacer(Modifier.height(28.dp))
|
||||
val macroOn = state.macroFor(device)?.enabled == true
|
||||
if (macroOn) {
|
||||
MacroControls(
|
||||
isOn = state.status?.powerOn == true,
|
||||
onStart = onRunMacro,
|
||||
onOff = { onTogglePower(false) },
|
||||
onStart = { request("정말 시작할까요?") { onRunMacro() } },
|
||||
onOff = { request("정말 끌까요?") { onTogglePower(false) } },
|
||||
)
|
||||
} else {
|
||||
PowerControls(
|
||||
isOn = state.status?.powerOn == true,
|
||||
stacked = narrow,
|
||||
onTogglePower = onTogglePower,
|
||||
onTogglePower = { on ->
|
||||
request(if (on) "정말 켤까요?" else "정말 끌까요?") { onTogglePower(on) }
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(20.dp))
|
||||
OutlinedButton(
|
||||
onClick = onRefresh,
|
||||
modifier = Modifier.fillMaxWidth().height(BigButtonHeight),
|
||||
) {
|
||||
Icon(Icons.Filled.Refresh, contentDescription = null, modifier = Modifier.size(30.dp))
|
||||
Text(" 새로고침", style = MaterialTheme.typography.labelLarge)
|
||||
state.lastUpdatedAt?.let { ts ->
|
||||
Spacer(Modifier.height(16.dp))
|
||||
Text(
|
||||
text = "마지막 확인 ${formatTime(ts)}" + if (!state.connected) " · 오프라인" else "",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pending?.let { p ->
|
||||
ConfirmDialog(
|
||||
message = p.message,
|
||||
onConfirm = { pending = null; p.onConfirm() },
|
||||
onCancel = { pending = null },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private data class PendingAction(val message: String, val onConfirm: () -> Unit)
|
||||
|
||||
private fun formatTime(epochMillis: Long): String =
|
||||
SimpleDateFormat("a h:mm", Locale.KOREAN).format(Date(epochMillis))
|
||||
|
||||
@Composable
|
||||
private fun NoticeCard(message: String, onClose: () -> Unit) {
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.errorContainer,
|
||||
contentColor = MaterialTheme.colorScheme.onErrorContainer,
|
||||
),
|
||||
) {
|
||||
Row(
|
||||
Modifier.fillMaxWidth().padding(16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(message, style = MaterialTheme.typography.bodyLarge, modifier = Modifier.weight(1f))
|
||||
TextButton(onClick = onClose) { Text("확인") }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ConfirmDialog(message: String, onConfirm: () -> Unit, onCancel: () -> Unit) {
|
||||
AlertDialog(
|
||||
onDismissRequest = onCancel,
|
||||
title = { Text(message, style = MaterialTheme.typography.headlineMedium) },
|
||||
confirmButton = {
|
||||
Button(onClick = onConfirm, modifier = Modifier.height(60.dp)) {
|
||||
Text("예", style = MaterialTheme.typography.labelLarge)
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onCancel, modifier = Modifier.height(60.dp)) {
|
||||
Text("아니오", style = MaterialTheme.typography.labelLarge)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun StatusCard(state: HomeUiState) {
|
||||
Card(
|
||||
@@ -150,6 +233,16 @@ private fun StatusCard(state: HomeUiState) {
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
|
||||
status?.jobPhase?.let {
|
||||
Spacer(Modifier.height(4.dp))
|
||||
Text(
|
||||
it,
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
}
|
||||
|
||||
if (state.settings.showRemainingTime && status?.remainingMinutes != null) {
|
||||
Spacer(Modifier.height(12.dp))
|
||||
Text(
|
||||
@@ -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
|
||||
private fun PowerControls(isOn: Boolean, stacked: Boolean, onTogglePower: (Boolean) -> Unit) {
|
||||
val onButton: @Composable (Modifier) -> Unit = { m ->
|
||||
|
||||
@@ -34,23 +34,67 @@ import kr.tkrmagid.easyappliance.data.DeviceMacro
|
||||
import kr.tkrmagid.easyappliance.data.DeviceType
|
||||
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_CMD = "setWasherWaterTemperature"
|
||||
private const val LEVEL_CAP = "custom.washerWaterLevel"
|
||||
private const val LEVEL_CMD = "setWasherWaterLevel"
|
||||
private const val SPIN_CAP = "custom.washerSpinLevel"
|
||||
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 val TEMP_PRESETS = listOf(
|
||||
Preset("cold", "냉수"),
|
||||
Preset("20", "20도"),
|
||||
Preset("30", "30도"),
|
||||
Preset("40", "40도"),
|
||||
Preset("60", "60도"),
|
||||
Preset("90", "90도"),
|
||||
)
|
||||
private val LEVEL_PRESETS = listOf(
|
||||
Preset("low", "낮음"),
|
||||
Preset("medium", "중간"),
|
||||
Preset("high", "높음"),
|
||||
private val SPIN_PRESETS = listOf(
|
||||
Preset("noSpin", "탈수없음"),
|
||||
Preset("low", "약"),
|
||||
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)
|
||||
@@ -80,45 +124,24 @@ fun MacroEditor(device: Device, macro: DeviceMacro, onChange: (DeviceMacro) -> U
|
||||
if (!macro.enabled) return@Column
|
||||
|
||||
if (device.type == DeviceType.WASHER) {
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text("물 온도", style = MaterialTheme.typography.bodyMedium)
|
||||
FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
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) },
|
||||
)
|
||||
}
|
||||
}
|
||||
PresetGroup("물 온도", TEMP_CAP, TEMP_CMD, "물 온도", TEMP_PRESETS, macro, onChange)
|
||||
PresetGroup("탈수 세기", SPIN_CAP, SPIN_CMD, "탈수", SPIN_PRESETS, macro, onChange)
|
||||
PresetGroup("헹굼 횟수", RINSE_CAP, RINSE_CMD, "헹굼", RINSE_PRESETS, macro, onChange)
|
||||
Spacer(Modifier.height(4.dp))
|
||||
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,
|
||||
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
|
||||
private fun AdvancedStepAdder(onAdd: (MacroStep) -> Unit) {
|
||||
var cap by remember { mutableStateOf("") }
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -6,12 +6,14 @@ import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.sp
|
||||
|
||||
// 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(
|
||||
displayLarge = TextStyle(fontWeight = FontWeight.Bold, fontSize = 48.sp, lineHeight = 56.sp),
|
||||
headlineLarge = TextStyle(fontWeight = FontWeight.Bold, fontSize = 40.sp, lineHeight = 48.sp),
|
||||
headlineMedium = TextStyle(fontWeight = FontWeight.Bold, fontSize = 32.sp, lineHeight = 40.sp),
|
||||
titleLarge = TextStyle(fontWeight = FontWeight.Bold, fontSize = 28.sp, lineHeight = 34.sp),
|
||||
bodyLarge = TextStyle(fontWeight = FontWeight.Normal, fontSize = 22.sp, lineHeight = 30.sp),
|
||||
bodyMedium = TextStyle(fontWeight = FontWeight.Normal, fontSize = 20.sp, lineHeight = 28.sp),
|
||||
titleMedium = TextStyle(fontWeight = FontWeight.Bold, fontSize = 24.sp, lineHeight = 30.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),
|
||||
)
|
||||
|
||||
@@ -11,8 +11,12 @@ import kr.tkrmagid.easyappliance.data.MockDeviceRepository
|
||||
import kr.tkrmagid.easyappliance.data.DeviceImages
|
||||
import kr.tkrmagid.easyappliance.data.DeviceMacro
|
||||
import kr.tkrmagid.easyappliance.data.SettingsRepository
|
||||
import kr.tkrmagid.easyappliance.BuildConfig
|
||||
import kr.tkrmagid.easyappliance.data.smartthings.SmartThingsClient
|
||||
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.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
@@ -25,11 +29,18 @@ data class HomeUiState(
|
||||
val selected: Device? = null,
|
||||
val status: DeviceStatus? = null,
|
||||
val settings: AppSettings = AppSettings(),
|
||||
val update: UpdateInfo? = null,
|
||||
val updateDownloading: Boolean = false,
|
||||
val updateMessage: String? = null,
|
||||
val usingRealApi: Boolean = false,
|
||||
/** False when the selected device / API cannot be reached. */
|
||||
val connected: Boolean = true,
|
||||
/** True after the user closes the warning with the small "x". */
|
||||
val warningDismissed: Boolean = false,
|
||||
/** Transient, large-text notice shown to the user (e.g. command failed). */
|
||||
val notice: String? = null,
|
||||
/** When the shown status was last refreshed successfully (epoch millis). */
|
||||
val lastUpdatedAt: Long? = null,
|
||||
val error: String? = null,
|
||||
) {
|
||||
val showConnectionWarning: Boolean get() = !connected && !warningDismissed
|
||||
@@ -38,6 +49,7 @@ data class HomeUiState(
|
||||
class AppViewModel(app: Application) : AndroidViewModel(app) {
|
||||
|
||||
private val settingsRepo = SettingsRepository(app)
|
||||
private val updateRepo = UpdateRepository()
|
||||
private var deviceRepo: DeviceRepository = MockDeviceRepository()
|
||||
private var activeToken: String? = null
|
||||
private var lastSelectedId: String? = null
|
||||
@@ -48,9 +60,44 @@ class AppViewModel(app: Application) : AndroidViewModel(app) {
|
||||
val state: StateFlow<HomeUiState> = _state.asStateFlow()
|
||||
|
||||
init {
|
||||
viewModelScope.launch { settingsRepo.migratePlaintextToken() }
|
||||
viewModelScope.launch {
|
||||
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) {
|
||||
@@ -77,11 +124,20 @@ class AppViewModel(app: Application) : AndroidViewModel(app) {
|
||||
.onSuccess { devices ->
|
||||
_state.update { it.copy(devices = devices, connected = true) }
|
||||
}
|
||||
.onFailure {
|
||||
_state.update { it.copy(loading = false, connected = false) }
|
||||
.onFailure { e ->
|
||||
_state.update {
|
||||
it.copy(
|
||||
loading = false,
|
||||
connected = false,
|
||||
notice = if (e.isAuthError()) TOKEN_INVALID_MSG else it.notice,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun Throwable.isAuthError(): Boolean =
|
||||
this is retrofit2.HttpException && (code() == 401 || code() == 403)
|
||||
|
||||
private suspend fun resolveSelectionAndStatus(settings: AppSettings) {
|
||||
val devices = _state.value.devices
|
||||
val selected = devices.firstOrNull { it.id == settings.selectedDeviceId }
|
||||
@@ -108,21 +164,38 @@ class AppViewModel(app: Application) : AndroidViewModel(app) {
|
||||
status = status,
|
||||
connected = !forcedWarning,
|
||||
warningDismissed = if (!forcedWarning) false else it.warningDismissed,
|
||||
lastUpdatedAt = System.currentTimeMillis(),
|
||||
error = null,
|
||||
)
|
||||
}
|
||||
}
|
||||
.onFailure {
|
||||
_state.update { it.copy(loading = false, connected = false) }
|
||||
.onFailure { e ->
|
||||
_state.update {
|
||||
it.copy(
|
||||
loading = false,
|
||||
connected = false,
|
||||
notice = if (e.isAuthError()) TOKEN_INVALID_MSG else it.notice,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun clearNotice() { _state.update { it.copy(notice = null) } }
|
||||
|
||||
/** Refresh selected device status; also used by the periodic poll. */
|
||||
fun refresh() {
|
||||
val id = _state.value.selected?.id ?: return
|
||||
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. */
|
||||
fun retryConnection() {
|
||||
forcedWarning = false
|
||||
@@ -150,7 +223,8 @@ class AppViewModel(app: Application) : AndroidViewModel(app) {
|
||||
val id = _state.value.selected?.id ?: return
|
||||
viewModelScope.launch {
|
||||
runCatching { deviceRepo.setPower(id, on) }
|
||||
.onFailure { _state.update { s -> s.copy(connected = false) } }
|
||||
.onSuccess { _state.update { s -> s.copy(notice = null) } }
|
||||
.onFailure { e -> _state.update { s -> s.copy(connected = false, notice = commandFailMsg(e)) } }
|
||||
loadStatus(id)
|
||||
}
|
||||
}
|
||||
@@ -161,11 +235,15 @@ class AppViewModel(app: Application) : AndroidViewModel(app) {
|
||||
val macro = _state.value.settings.deviceMacros[id] ?: return
|
||||
viewModelScope.launch {
|
||||
runCatching { deviceRepo.runMacro(id, macro.steps) }
|
||||
.onFailure { _state.update { s -> s.copy(connected = false) } }
|
||||
.onSuccess { _state.update { s -> s.copy(notice = null) } }
|
||||
.onFailure { e -> _state.update { s -> s.copy(connected = false, notice = commandFailMsg(e)) } }
|
||||
loadStatus(id)
|
||||
}
|
||||
}
|
||||
|
||||
private fun commandFailMsg(e: Throwable): String =
|
||||
if (e.isAuthError()) TOKEN_INVALID_MSG else "명령을 보내지 못했어요. 잠시 후 다시 시도해 주세요."
|
||||
|
||||
fun setDeviceMacro(deviceId: String, macro: DeviceMacro) {
|
||||
viewModelScope.launch { settingsRepo.setDeviceMacro(deviceId, macro) }
|
||||
}
|
||||
@@ -174,6 +252,7 @@ class AppViewModel(app: Application) : AndroidViewModel(app) {
|
||||
fun setShowRemainingTime(value: Boolean) { viewModelScope.launch { settingsRepo.setShowRemainingTime(value) } }
|
||||
fun setShowReservation(value: Boolean) { viewModelScope.launch { settingsRepo.setShowReservation(value) } }
|
||||
fun setShowPowerControls(value: Boolean) { viewModelScope.launch { settingsRepo.setShowPowerControls(value) } }
|
||||
fun setConfirmBeforeAction(value: Boolean) { viewModelScope.launch { settingsRepo.setConfirmBeforeAction(value) } }
|
||||
fun setToken(token: String) { viewModelScope.launch { settingsRepo.setToken(token) } }
|
||||
fun setDeviceLabel(deviceId: String, label: String) { viewModelScope.launch { settingsRepo.setDeviceLabel(deviceId, label) } }
|
||||
fun setDeviceImage(deviceId: String, imageRef: String) { viewModelScope.launch { settingsRepo.setDeviceImage(deviceId, imageRef) } }
|
||||
@@ -190,6 +269,9 @@ fun HomeUiState.imageRefFor(device: Device): String =
|
||||
/** The device's macro, or null if none configured. */
|
||||
fun HomeUiState.macroFor(device: Device): DeviceMacro? = settings.deviceMacros[device.id]
|
||||
|
||||
private const val TOKEN_INVALID_MSG =
|
||||
"SmartThings 토큰이 올바르지 않습니다. 관리자 화면에서 토큰을 다시 확인해 주세요."
|
||||
|
||||
const val DEFAULT_APP_NAME = "쉬운 가전 리모컨"
|
||||
|
||||
/** App-level display name shown as the home header. */
|
||||
|
||||
17
app/src/main/res/drawable/img_aircon_stand.xml
Normal file
17
app/src/main/res/drawable/img_aircon_stand.xml
Normal 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>
|
||||
5
app/src/main/res/xml/file_paths.xml
Normal file
5
app/src/main/res/xml/file_paths.xml
Normal 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>
|
||||
37
app/src/test/java/kr/tkrmagid/easyappliance/MacroTest.kt
Normal file
37
app/src/test/java/kr/tkrmagid/easyappliance/MacroTest.kt
Normal 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())
|
||||
}
|
||||
}
|
||||
@@ -26,9 +26,15 @@ class MockDeviceRepositoryTest {
|
||||
assertTrue(on.powerOn)
|
||||
assertEquals("운전 중", on.operatingState)
|
||||
assertEquals(32, on.remainingMinutes)
|
||||
assertTrue(on.running)
|
||||
assertEquals("물 온도 40도 · 탈수 강 · 헹굼 3회", on.currentSettings)
|
||||
assertEquals("헹굼 중", on.jobPhase)
|
||||
|
||||
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
|
||||
|
||||
@@ -19,8 +19,12 @@ class SmartThingsStatusMapperTest {
|
||||
"""
|
||||
{"components":{"main":{
|
||||
"switch":{"switch":{"value":"on"}},
|
||||
"washerOperatingState":{"machineState":{"value":"run"}},
|
||||
"samsungce.washerOperatingState":{"remainingTime":{"value":1980}}
|
||||
"washerOperatingState":{"machineState":{"value":"run"},"washerJobState":{"value":"rinse"}},
|
||||
"samsungce.washerOperatingState":{"remainingTime":{"value":1980}},
|
||||
"samsungce.washerDelayEnd":{"remainingTime":{"value":0}},
|
||||
"custom.washerWaterTemperature":{"washerWaterTemperature":{"value":"60"}},
|
||||
"custom.washerSpinLevel":{"washerSpinLevel":{"value":"extraHigh"}},
|
||||
"custom.washerRinseCycles":{"washerRinseCycles":{"value":"3"}}
|
||||
}}}
|
||||
""".trimIndent(),
|
||||
)
|
||||
@@ -28,6 +32,43 @@ class SmartThingsStatusMapperTest {
|
||||
assertTrue(result.powerOn)
|
||||
assertEquals("운전 중", result.operatingState)
|
||||
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
|
||||
@@ -36,6 +77,8 @@ class SmartThingsStatusMapperTest {
|
||||
"""
|
||||
{"components":{"main":{
|
||||
"switch":{"switch":{"value":"on"}},
|
||||
"airConditionerMode":{"airConditionerMode":{"value":"cool"}},
|
||||
"airConditionerFanMode":{"fanMode":{"value":"auto"}},
|
||||
"temperatureMeasurement":{"temperature":{"value":27}},
|
||||
"thermostatCoolingSetpoint":{"coolingSetpoint":{"value":24}}
|
||||
}}}
|
||||
@@ -46,6 +89,7 @@ class SmartThingsStatusMapperTest {
|
||||
assertEquals("냉방 중", result.operatingState)
|
||||
assertEquals(27, result.currentTemperature)
|
||||
assertEquals(24, result.targetTemperature)
|
||||
assertEquals("냉방 · 바람 자동", result.currentSettings)
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user