1 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
10 changed files with 330 additions and 2 deletions

View File

@@ -24,8 +24,8 @@ android {
applicationId = "kr.tkrmagid.easyappliance" applicationId = "kr.tkrmagid.easyappliance"
minSdk = 24 minSdk = 24
targetSdk = 35 targetSdk = 35
versionCode = 5 versionCode = (project.findProperty("verCode") as String?)?.toInt() ?: 6
versionName = "0.3.2" 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

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

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

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

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)
}
}