Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b5f135f475 | ||
|
|
067170802a |
@@ -24,8 +24,8 @@ android {
|
||||
applicationId = "kr.tkrmagid.easyappliance"
|
||||
minSdk = 24
|
||||
targetSdk = 35
|
||||
versionCode = 4
|
||||
versionName = "0.3.1"
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -58,6 +61,7 @@ import kr.tkrmagid.easyappliance.vm.macroFor
|
||||
fun AdminScreen(
|
||||
state: HomeUiState,
|
||||
onSelectDevice: (String) -> Unit,
|
||||
onRefreshDevices: () -> Unit,
|
||||
onSetAppName: (String) -> Unit,
|
||||
onToggleRemaining: (Boolean) -> Unit,
|
||||
onToggleReservation: (Boolean) -> Unit,
|
||||
@@ -111,6 +115,15 @@ 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) {
|
||||
Spacer(Modifier.height(28.dp))
|
||||
SectionTitle("기기 이름 바꾸기")
|
||||
|
||||
@@ -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,7 @@ fun AppRoot() {
|
||||
Screen.ADMIN -> AdminScreen(
|
||||
state = state,
|
||||
onSelectDevice = vm::selectDevice,
|
||||
onRefreshDevices = vm::refreshDevices,
|
||||
onSetAppName = vm::setAppName,
|
||||
onToggleRemaining = vm::setShowRemainingTime,
|
||||
onToggleReservation = vm::setShowReservation,
|
||||
@@ -85,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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -46,7 +44,6 @@ fun HomeScreen(
|
||||
state: HomeUiState,
|
||||
onTogglePower: (Boolean) -> Unit,
|
||||
onRunMacro: () -> Unit,
|
||||
onRefresh: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
BoxWithConstraints(modifier = modifier.fillMaxSize()) {
|
||||
@@ -110,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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
5
app/src/main/res/xml/file_paths.xml
Normal file
5
app/src/main/res/xml/file_paths.xml
Normal 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>
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user