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