3 Commits

Author SHA1 Message Date
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
14 changed files with 406 additions and 17 deletions

1
.gitignore vendored
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -18,10 +18,13 @@ import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.selection.selectable import androidx.compose.foundation.selection.selectable
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.Card import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults import androidx.compose.material3.CardDefaults
import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextField
@@ -49,6 +52,7 @@ import kr.tkrmagid.easyappliance.data.DeviceImages
import kr.tkrmagid.easyappliance.data.DeviceMacro import kr.tkrmagid.easyappliance.data.DeviceMacro
import kr.tkrmagid.easyappliance.data.ImageStorage import kr.tkrmagid.easyappliance.data.ImageStorage
import kr.tkrmagid.easyappliance.vm.HomeUiState import kr.tkrmagid.easyappliance.vm.HomeUiState
import kr.tkrmagid.easyappliance.vm.appDisplayName
import kr.tkrmagid.easyappliance.vm.displayLabelFor import kr.tkrmagid.easyappliance.vm.displayLabelFor
import kr.tkrmagid.easyappliance.vm.imageRefFor import kr.tkrmagid.easyappliance.vm.imageRefFor
import kr.tkrmagid.easyappliance.vm.macroFor import kr.tkrmagid.easyappliance.vm.macroFor
@@ -57,6 +61,8 @@ import kr.tkrmagid.easyappliance.vm.macroFor
fun AdminScreen( fun AdminScreen(
state: HomeUiState, state: HomeUiState,
onSelectDevice: (String) -> Unit, onSelectDevice: (String) -> Unit,
onRefreshDevices: () -> Unit,
onSetAppName: (String) -> Unit,
onToggleRemaining: (Boolean) -> Unit, onToggleRemaining: (Boolean) -> Unit,
onToggleReservation: (Boolean) -> Unit, onToggleReservation: (Boolean) -> Unit,
onTogglePowerControls: (Boolean) -> Unit, onTogglePowerControls: (Boolean) -> Unit,
@@ -78,6 +84,13 @@ fun AdminScreen(
Text("관리자 설정", style = MaterialTheme.typography.headlineLarge) Text("관리자 설정", style = MaterialTheme.typography.headlineLarge)
Spacer(Modifier.height(24.dp)) Spacer(Modifier.height(24.dp))
SectionTitle("앱 이름 바꾸기")
NameEditor(
current = state.appDisplayName(),
onSave = onSetAppName,
)
Spacer(Modifier.height(28.dp))
SectionTitle("조작할 기기 선택") SectionTitle("조작할 기기 선택")
Card( Card(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
@@ -102,9 +115,18 @@ fun AdminScreen(
} }
} }
Spacer(Modifier.height(12.dp))
OutlinedButton(
onClick = onRefreshDevices,
modifier = Modifier.fillMaxWidth().height(60.dp),
) {
Icon(Icons.Filled.Refresh, contentDescription = null, modifier = Modifier.size(26.dp))
Text(" 목록 새로고침", style = MaterialTheme.typography.bodyLarge)
}
if (selected != null) { if (selected != null) {
Spacer(Modifier.height(28.dp)) Spacer(Modifier.height(28.dp))
SectionTitle("이름 바꾸기") SectionTitle("기기 이름 바꾸기")
NameEditor( NameEditor(
current = state.displayLabelFor(selected), current = state.displayLabelFor(selected),
onSave = { onSetLabel(selected.id, it) }, onSave = { onSetLabel(selected.id, it) },

View File

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

View File

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

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

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