7 Commits

Author SHA1 Message Date
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
tkrmagid
2287e1cf83 feat: app-level display name (v0.3.1) 2026-08-05 14:45:57 +09:00
26 changed files with 740 additions and 72 deletions

1
.gitignore vendored
View File

@@ -12,4 +12,3 @@
.cxx
app/build/
build/
.kotlin/

View File

@@ -24,8 +24,8 @@ android {
applicationId = "kr.tkrmagid.easyappliance"
minSdk = 24
targetSdk = 35
versionCode = 3
versionName = "0.3.0"
versionCode = (project.findProperty("verCode") as String?)?.toInt() ?: 11
versionName = (project.findProperty("verName") as String?) ?: "0.3.8"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
@@ -61,6 +61,7 @@ android {
}
buildFeatures {
compose = true
buildConfig = true
}
}

View File

@@ -3,6 +3,7 @@
<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"
@@ -23,6 +24,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>

View File

@@ -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),

View File

@@ -36,12 +36,16 @@ class MockDeviceRepository : DeviceRepository {
"washer-1" -> DeviceStatus(
powerOn = on,
operatingState = if (on) "운전 중" else "꺼짐",
running = on,
currentSettings = if (on) "물 온도 40도 · 탈수 강 · 헹굼 3회" 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,
)

View File

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

View File

@@ -28,6 +28,13 @@ 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,
/** Remaining minutes for a running cycle, if reported. */
val remainingMinutes: Int? = null,
/** Reservation / scheduled-start description, if set. */

View File

@@ -17,6 +17,8 @@ private val Context.dataStore: DataStore<Preferences> by preferencesDataStore(na
/** User/admin choices that must survive app restarts. */
data class AppSettings(
/** App-level display name shown as the home header; null = default. */
val appName: String? = null,
val selectedDeviceId: String? = null,
val showRemainingTime: Boolean = true,
val showReservation: Boolean = true,
@@ -36,6 +38,7 @@ class SettingsRepository(private val context: Context) {
val settings: Flow<AppSettings> = context.dataStore.data.map { p ->
AppSettings(
appName = p[KEY_APP_NAME]?.takeIf { it.isNotBlank() },
selectedDeviceId = p[KEY_SELECTED_DEVICE],
showRemainingTime = p[KEY_SHOW_REMAINING] ?: true,
showReservation = p[KEY_SHOW_RESERVATION] ?: true,
@@ -47,6 +50,9 @@ class SettingsRepository(private val context: Context) {
)
}
suspend fun setAppName(name: String) =
edit { it[KEY_APP_NAME] = name.trim() }
suspend fun setSelectedDevice(deviceId: String) =
edit { it[KEY_SELECTED_DEVICE] = deviceId }
@@ -93,6 +99,7 @@ class SettingsRepository(private val context: Context) {
else runCatching { Json.decodeFromString<Map<String, DeviceMacro>>(raw) }.getOrDefault(emptyMap())
private companion object {
val KEY_APP_NAME = stringPreferencesKey("app_name")
val KEY_SELECTED_DEVICE = stringPreferencesKey("selected_device_id")
val KEY_SHOW_REMAINING = booleanPreferencesKey("show_remaining_time")
val KEY_SHOW_RESERVATION = booleanPreferencesKey("show_reservation")

View File

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

View File

@@ -37,11 +37,25 @@ 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 }
return DeviceStatus(
powerOn = powerOn,
operatingState = state,
running = running,
currentSettings = if (running) settings else null,
remainingMinutes = remaining,
)
}
@@ -50,14 +64,58 @@ 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 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

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.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
@@ -49,6 +52,7 @@ import kr.tkrmagid.easyappliance.data.DeviceImages
import kr.tkrmagid.easyappliance.data.DeviceMacro
import kr.tkrmagid.easyappliance.data.ImageStorage
import kr.tkrmagid.easyappliance.vm.HomeUiState
import kr.tkrmagid.easyappliance.vm.appDisplayName
import kr.tkrmagid.easyappliance.vm.displayLabelFor
import kr.tkrmagid.easyappliance.vm.imageRefFor
import kr.tkrmagid.easyappliance.vm.macroFor
@@ -57,6 +61,8 @@ 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,
@@ -78,6 +84,13 @@ fun AdminScreen(
Text("관리자 설정", style = MaterialTheme.typography.headlineLarge)
Spacer(Modifier.height(24.dp))
SectionTitle("앱 이름 바꾸기")
NameEditor(
current = state.appDisplayName(),
onSave = onSetAppName,
)
Spacer(Modifier.height(28.dp))
SectionTitle("조작할 기기 선택")
Card(
modifier = Modifier.fillMaxWidth(),
@@ -102,9 +115,27 @@ 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("이름 바꾸기")
SectionTitle("기기 이름 바꾸기")
NameEditor(
current = state.displayLabelFor(selected),
onSave = { onSetLabel(selected.id, it) },

View File

@@ -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,6 @@ fun AppRoot() {
state = state,
onTogglePower = vm::togglePower,
onRunMacro = vm::runMacro,
onRefresh = vm::refresh,
modifier = Modifier
.fillMaxSize()
.adminUnlockGesture { screen = Screen.ADMIN },
@@ -63,6 +84,8 @@ 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,
@@ -84,6 +107,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,
)
}
}
}
}

View File

@@ -15,7 +15,6 @@ 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.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Card
@@ -23,7 +22,6 @@ 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.runtime.Composable
import androidx.compose.ui.Alignment
@@ -34,6 +32,7 @@ import androidx.compose.ui.unit.dp
import kr.tkrmagid.easyappliance.data.DeviceType
import androidx.compose.material.icons.filled.PlayArrow
import kr.tkrmagid.easyappliance.vm.HomeUiState
import kr.tkrmagid.easyappliance.vm.appDisplayName
import kr.tkrmagid.easyappliance.vm.displayLabelFor
import kr.tkrmagid.easyappliance.vm.imageRefFor
import kr.tkrmagid.easyappliance.vm.macroFor
@@ -45,7 +44,6 @@ fun HomeScreen(
state: HomeUiState,
onTogglePower: (Boolean) -> Unit,
onRunMacro: () -> Unit,
onRefresh: () -> Unit,
modifier: Modifier = Modifier,
) {
BoxWithConstraints(modifier = modifier.fillMaxSize()) {
@@ -71,6 +69,13 @@ fun HomeScreen(
modifier = Modifier.widthIn(max = contentMaxWidth).fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(
text = state.appDisplayName(),
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.primary,
textAlign = TextAlign.Center,
)
Spacer(Modifier.height(16.dp))
DeviceImage(
imageRef = state.imageRefFor(device),
modifier = Modifier.size(if (narrow) 96.dp else 128.dp),
@@ -85,6 +90,18 @@ 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
@@ -102,15 +119,6 @@ fun HomeScreen(
)
}
}
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)
}
}
}
}
@@ -173,6 +181,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 ->

View File

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

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
// 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),
)

View File

@@ -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,6 +29,9 @@ 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,
@@ -38,6 +45,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
@@ -51,6 +59,40 @@ class AppViewModel(app: Application) : AndroidViewModel(app) {
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) {
@@ -123,6 +165,14 @@ class AppViewModel(app: Application) : AndroidViewModel(app) {
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
@@ -170,6 +220,7 @@ class AppViewModel(app: Application) : AndroidViewModel(app) {
viewModelScope.launch { settingsRepo.setDeviceMacro(deviceId, macro) }
}
fun setAppName(name: String) { viewModelScope.launch { settingsRepo.setAppName(name) } }
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) } }
@@ -188,3 +239,8 @@ 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]
const val DEFAULT_APP_NAME = "쉬운 가전 리모컨"
/** App-level display name shown as the home header. */
fun HomeUiState.appDisplayName(): String = settings.appName ?: DEFAULT_APP_NAME

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,14 @@ class MockDeviceRepositoryTest {
assertTrue(on.powerOn)
assertEquals("운전 중", on.operatingState)
assertEquals(32, on.remainingMinutes)
assertTrue(on.running)
assertEquals("물 온도 40도 · 탈수 강 · 헹굼 3회", on.currentSettings)
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

View File

@@ -20,7 +20,10 @@ class SmartThingsStatusMapperTest {
{"components":{"main":{
"switch":{"switch":{"value":"on"}},
"washerOperatingState":{"machineState":{"value":"run"}},
"samsungce.washerOperatingState":{"remainingTime":{"value":1980}}
"samsungce.washerOperatingState":{"remainingTime":{"value":1980}},
"custom.washerWaterTemperature":{"washerWaterTemperature":{"value":"60"}},
"custom.washerSpinLevel":{"washerSpinLevel":{"value":"extraHigh"}},
"custom.washerRinseCycles":{"washerRinseCycles":{"value":"3"}}
}}}
""".trimIndent(),
)
@@ -28,6 +31,26 @@ class SmartThingsStatusMapperTest {
assertTrue(result.powerOn)
assertEquals("운전 중", result.operatingState)
assertEquals(33, result.remainingMinutes)
assertTrue(result.running)
assertEquals("물 온도 60도 · 탈수 최강 · 헹굼 3회", result.currentSettings)
}
@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 +59,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 +71,7 @@ class SmartThingsStatusMapperTest {
assertEquals("냉방 중", result.operatingState)
assertEquals(27, result.currentTemperature)
assertEquals(24, result.targetTemperature)
assertEquals("냉방 · 바람 자동", result.currentSettings)
}
@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)
}
}