diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index dc15ba0..cff2f4f 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -24,8 +24,8 @@ android {
applicationId = "kr.tkrmagid.easyappliance"
minSdk = 24
targetSdk = 35
- versionCode = 5
- versionName = "0.3.2"
+ versionCode = (project.findProperty("verCode") as String?)?.toInt() ?: 6
+ versionName = (project.findProperty("verName") as String?) ?: "0.3.3"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
@@ -61,6 +61,7 @@ android {
}
buildFeatures {
compose = true
+ buildConfig = true
}
}
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 6a94bc4..a89a578 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -3,6 +3,7 @@
+
+
+
+
+
diff --git a/app/src/main/java/kr/tkrmagid/easyappliance/data/update/UpdateApi.kt b/app/src/main/java/kr/tkrmagid/easyappliance/data/update/UpdateApi.kt
new file mode 100644
index 0000000..a5124fb
--- /dev/null
+++ b/app/src/main/java/kr/tkrmagid/easyappliance/data/update/UpdateApi.kt
@@ -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 = 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
+}
+
+/** 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)
+ }
+}
diff --git a/app/src/main/java/kr/tkrmagid/easyappliance/data/update/UpdateRepository.kt b/app/src/main/java/kr/tkrmagid/easyappliance/data/update/UpdateRepository.kt
new file mode 100644
index 0000000..f9b59da
--- /dev/null
+++ b/app/src/main/java/kr/tkrmagid/easyappliance/data/update/UpdateRepository.kt
@@ -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 =
+ 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
+}
diff --git a/app/src/main/java/kr/tkrmagid/easyappliance/data/update/Updater.kt b/app/src/main/java/kr/tkrmagid/easyappliance/data/update/Updater.kt
new file mode 100644
index 0000000..84c8334
--- /dev/null
+++ b/app/src/main/java/kr/tkrmagid/easyappliance/data/update/Updater.kt
@@ -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)
+ }
+}
diff --git a/app/src/main/java/kr/tkrmagid/easyappliance/ui/AppRoot.kt b/app/src/main/java/kr/tkrmagid/easyappliance/ui/AppRoot.kt
index 5302ef7..0dfaba2 100644
--- a/app/src/main/java/kr/tkrmagid/easyappliance/ui/AppRoot.kt
+++ b/app/src/main/java/kr/tkrmagid/easyappliance/ui/AppRoot.kt
@@ -107,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,
+ )
+ }
}
}
}
diff --git a/app/src/main/java/kr/tkrmagid/easyappliance/ui/UpdateDialog.kt b/app/src/main/java/kr/tkrmagid/easyappliance/ui/UpdateDialog.kt
new file mode 100644
index 0000000..14d043e
--- /dev/null
+++ b/app/src/main/java/kr/tkrmagid/easyappliance/ui/UpdateDialog.kt
@@ -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)
+ }
+ }
+ },
+ )
+}
diff --git a/app/src/main/java/kr/tkrmagid/easyappliance/vm/AppViewModel.kt b/app/src/main/java/kr/tkrmagid/easyappliance/vm/AppViewModel.kt
index 629ee09..117691f 100644
--- a/app/src/main/java/kr/tkrmagid/easyappliance/vm/AppViewModel.kt
+++ b/app/src/main/java/kr/tkrmagid/easyappliance/vm/AppViewModel.kt
@@ -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()
+ 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) {
diff --git a/app/src/main/res/xml/file_paths.xml b/app/src/main/res/xml/file_paths.xml
new file mode 100644
index 0000000..109649f
--- /dev/null
+++ b/app/src/main/res/xml/file_paths.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/app/src/test/java/kr/tkrmagid/easyappliance/VersionCompareTest.kt b/app/src/test/java/kr/tkrmagid/easyappliance/VersionCompareTest.kt
new file mode 100644
index 0000000..effd894
--- /dev/null
+++ b/app/src/test/java/kr/tkrmagid/easyappliance/VersionCompareTest.kt
@@ -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)
+ }
+}