feat: version checking using github api

This commit is contained in:
Miuzarte
2026-04-10 11:55:03 +08:00
parent b45175dd65
commit 3f94d91564
7 changed files with 186 additions and 9 deletions

View File

@@ -67,6 +67,7 @@ android {
buildFeatures {
compose = true
prefab = true
buildConfig = true
}
externalNativeBuild {
@@ -108,4 +109,4 @@ dependencies {
androidTestImplementation(libs.androidx.compose.ui.test.junit4)
debugImplementation(libs.androidx.compose.ui.tooling)
debugImplementation(libs.androidx.compose.ui.test.manifest)
}
}

View File

@@ -42,12 +42,14 @@ import androidx.navigation3.runtime.NavKey
import androidx.navigation3.runtime.entryProvider
import androidx.navigation3.runtime.rememberDecoratedNavEntries
import androidx.navigation3.ui.NavDisplay
import io.github.miuzarte.scrcpyforandroid.BuildConfig
import io.github.miuzarte.scrcpyforandroid.NativeCoreFacade
import io.github.miuzarte.scrcpyforandroid.constants.ThemeModes
import io.github.miuzarte.scrcpyforandroid.constants.UiMotion
import io.github.miuzarte.scrcpyforandroid.models.DeviceShortcuts
import io.github.miuzarte.scrcpyforandroid.nativecore.NativeAdbService
import io.github.miuzarte.scrcpyforandroid.scrcpy.Scrcpy
import io.github.miuzarte.scrcpyforandroid.services.AppUpdateChecker
import io.github.miuzarte.scrcpyforandroid.services.SnackbarController
import io.github.miuzarte.scrcpyforandroid.storage.AppSettings
import io.github.miuzarte.scrcpyforandroid.storage.Settings
@@ -217,6 +219,15 @@ fun MainScreen() {
}
val currentSession by scrcpy.currentSessionState.collectAsState()
LaunchedEffect(asBundle.lastUpdateCheckAt) {
val now = System.currentTimeMillis()
if (now - asBundle.lastUpdateCheckAt < AppUpdateChecker.CHECK_INTERVAL_MS) return@LaunchedEffect
taskScope.launch {
appSettings.updateBundle { it.copy(lastUpdateCheckAt = now) }
AppUpdateChecker.ensureChecked(BuildConfig.VERSION_NAME)
}
}
fun handleBackNavigation() {
if (rootBackStack.size > 1) {
popRoot()

View File

@@ -29,12 +29,14 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.core.net.toUri
import io.github.miuzarte.scrcpyforandroid.BuildConfig
import io.github.miuzarte.scrcpyforandroid.constants.ThemeModes
import io.github.miuzarte.scrcpyforandroid.constants.UiSpacing
import io.github.miuzarte.scrcpyforandroid.scaffolds.LazyColumn
import io.github.miuzarte.scrcpyforandroid.scaffolds.SuperSlider
import io.github.miuzarte.scrcpyforandroid.scaffolds.SuperTextField
import io.github.miuzarte.scrcpyforandroid.scrcpy.Scrcpy
import io.github.miuzarte.scrcpyforandroid.services.AppUpdateChecker
import io.github.miuzarte.scrcpyforandroid.services.SnackbarController
import io.github.miuzarte.scrcpyforandroid.storage.AppSettings
import io.github.miuzarte.scrcpyforandroid.storage.PreferenceMigration
@@ -57,7 +59,6 @@ import top.yukonga.miuix.kmp.basic.TopAppBar
import top.yukonga.miuix.kmp.extra.SuperArrow
import top.yukonga.miuix.kmp.extra.SuperDropdown
import top.yukonga.miuix.kmp.extra.SuperSwitch
import top.yukonga.miuix.kmp.theme.ColorSchemeMode
import kotlin.math.roundToInt
import kotlin.system.exitProcess
@@ -100,6 +101,7 @@ fun SettingsPage(
val context = LocalContext.current
val appContext = context.applicationContext
var needMigration by remember { mutableStateOf(false) }
val updateState by AppUpdateChecker.state.collectAsState()
LaunchedEffect(Unit) {
needMigration = PreferenceMigration(appContext).needsMigration()
}
@@ -152,6 +154,37 @@ fun SettingsPage(
)
}
val updateSummary = remember(updateState) {
when (val state = updateState) {
AppUpdateChecker.State.Idle ->
"当前版本 ${BuildConfig.VERSION_NAME}"
AppUpdateChecker.State.Checking ->
"当前版本 ${BuildConfig.VERSION_NAME},正在检查更新"
is AppUpdateChecker.State.Ready -> {
when {
state.release.hasUpdate ->
"当前版本 ${state.release.currentVersion},发现新版本 ${state.release.latestVersion}"
state.release.currentVersion == state.release.latestVersion.removePrefix("v")
|| state.release.currentVersion == state.release.latestVersion ->
"当前版本 ${state.release.currentVersion},已是最新版本"
else ->
"当前版本 ${state.release.currentVersion},高于最新发布版本 ${state.release.latestVersion}"
}
}
}
}
val updateUrl = remember(updateState) {
when (val state = updateState) {
is AppUpdateChecker.State.Ready -> state.release.htmlUrl
else -> "https://github.com/Miuzarte/ScrcpyForAndroid/releases"
}
}
// 设置
LazyColumn(
contentPadding = contentPadding,
@@ -163,11 +196,19 @@ fun SettingsPage(
SuperDropdown(
title = "外观模式",
summary = ThemeModes.baseOptions
.getOrNull(asBundle.themeBaseIndex.coerceIn(0, ThemeModes.baseOptions.lastIndex))
.getOrNull(
asBundle.themeBaseIndex.coerceIn(
0,
ThemeModes.baseOptions.lastIndex
)
)
?.label
?: "跟随系统",
items = themeItems,
selectedIndex = asBundle.themeBaseIndex.coerceIn(0, ThemeModes.baseOptions.lastIndex),
selectedIndex = asBundle.themeBaseIndex.coerceIn(
0,
ThemeModes.baseOptions.lastIndex
),
onSelectedIndexChange = {
asBundle = asBundle.copy(themeBaseIndex = it)
},
@@ -441,13 +482,13 @@ fun SettingsPage(
SectionSmallTitle("关于", showLeadingSpacer = false)
Card {
SuperArrow(
title = "前往仓库",
summary = "github.com/Miuzarte/ScrcpyForAndroid",
title = "仓库发布页",
summary = "$updateSummary\n${AppUpdateChecker.REPO_URL.removePrefix("https://")}",
onClick = {
context.startActivity(
Intent(
Intent.ACTION_VIEW,
"https://github.com/Miuzarte/ScrcpyForAndroid".toUri(),
updateUrl.toUri(),
)
)
},

View File

@@ -0,0 +1,111 @@
package io.github.miuzarte.scrcpyforandroid.services
import android.util.Log
import io.github.miuzarte.scrcpyforandroid.BuildConfig
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import org.json.JSONArray
import java.net.HttpURLConnection
import java.net.URL
object AppUpdateChecker {
private const val TAG = "AppUpdateChecker"
const val RELEASES_API_URL =
"https://api.github.com/repos/Miuzarte/ScrcpyForAndroid/releases?per_page=1"
const val RELEASES_URL =
"https://github.com/Miuzarte/ScrcpyForAndroid/releases"
const val REPO_URL =
"https://github.com/Miuzarte/ScrcpyForAndroid"
const val CHECK_INTERVAL_MS = 12 * 60 * 60 * 1000L
data class ReleaseInfo(
val currentVersion: String,
val latestVersion: String,
val hasUpdate: Boolean,
val htmlUrl: String,
)
sealed interface State {
data object Idle : State
data object Checking : State
data class Ready(val release: ReleaseInfo) : State
}
private val checkMutex = Mutex()
private val _state = MutableStateFlow<State>(State.Idle)
val state: StateFlow<State> = _state.asStateFlow()
suspend fun ensureChecked(currentVersion: String = BuildConfig.VERSION_NAME) {
checkMutex.withLock {
if (_state.value is State.Ready || _state.value is State.Checking) return
_state.value = State.Checking
runCatching { fetchLatestRelease(currentVersion) }
.onSuccess { _state.value = State.Ready(it) }
.onFailure { error ->
EventLogger.logEvent(
"检查更新失败: ${error.message ?: error.javaClass.simpleName}",
Log.WARN,
error
)
_state.value = State.Idle
}
}
}
private suspend fun fetchLatestRelease(currentVersion: String): ReleaseInfo =
withContext(Dispatchers.IO) {
val connection = (URL(RELEASES_API_URL).openConnection() as HttpURLConnection).apply {
requestMethod = "GET"
connectTimeout = 10_000
readTimeout = 10_000
setRequestProperty("Accept", "application/vnd.github+json")
setRequestProperty("X-GitHub-Api-Version", "2022-11-28")
setRequestProperty("User-Agent", "ScrcpyForAndroid/$currentVersion")
}
try {
val body = connection.inputStream.bufferedReader().use { it.readText() }
val release = JSONArray(body).optJSONObject(0)
?: error("GitHub releases response is empty")
val latestVersion = release.optString("tag_name")
.ifBlank { release.optString("name") }
.ifBlank { error("GitHub release has no tag name") }
ReleaseInfo(
currentVersion = currentVersion,
latestVersion = latestVersion.removePrefix("v").removePrefix("V"),
hasUpdate = compareVersions(currentVersion, latestVersion) < 0,
htmlUrl = release.optString("html_url").ifBlank { RELEASES_URL },
)
} finally {
connection.disconnect()
}
}
private fun compareVersions(current: String, latest: String): Int {
val currentParts = normalizeVersion(current)
val latestParts = normalizeVersion(latest)
val maxSize = maxOf(currentParts.size, latestParts.size)
for (i in 0 until maxSize) {
val currentPart = currentParts.getOrElse(i) { 0 }
val latestPart = latestParts.getOrElse(i) { 0 }
if (currentPart != latestPart) {
return currentPart.compareTo(latestPart)
}
}
return 0
}
private fun normalizeVersion(value: String) =
value
.trim()
.removePrefix("v")
.removePrefix("V")
.split(Regex("[^0-9]+"))
.filter { it.isNotBlank() }
.mapNotNull { it.toIntOrNull() }
.ifEmpty { listOf(0) }
}

View File

@@ -5,6 +5,7 @@ import android.os.Parcelable
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.intPreferencesKey
import androidx.datastore.preferences.core.longPreferencesKey
import androidx.datastore.preferences.core.stringPreferencesKey
import io.github.miuzarte.scrcpyforandroid.scrcpy.Scrcpy
import kotlinx.coroutines.flow.StateFlow
@@ -68,6 +69,10 @@ class AppSettings(context: Context) : Settings(context, "AppSettings") {
booleanPreferencesKey("adb_mdns_lan_discovery"),
true
)
val LAST_UPDATE_CHECK_AT = Pair(
longPreferencesKey("last_update_check_at"),
0L
)
}
// Theme Settings
@@ -91,6 +96,7 @@ class AppSettings(context: Context) : Settings(context, "AppSettings") {
val adbPairingAutoDiscoverOnDialogOpen by setting(ADB_PAIRING_AUTO_DISCOVER_ON_DIALOG_OPEN)
val adbAutoReconnectPairedDevice by setting(ADB_AUTO_RECONNECT_PAIRED_DEVICE)
val adbMdnsLanDiscovery by setting(ADB_MDNS_LAN_DISCOVERY)
val lastUpdateCheckAt by setting(LAST_UPDATE_CHECK_AT)
@Parcelize
data class Bundle(
@@ -108,6 +114,7 @@ class AppSettings(context: Context) : Settings(context, "AppSettings") {
val adbPairingAutoDiscoverOnDialogOpen: Boolean,
val adbAutoReconnectPairedDevice: Boolean,
val adbMdnsLanDiscovery: Boolean,
val lastUpdateCheckAt: Long,
) : Parcelable {
}
@@ -126,6 +133,7 @@ class AppSettings(context: Context) : Settings(context, "AppSettings") {
bundleField(ADB_PAIRING_AUTO_DISCOVER_ON_DIALOG_OPEN) { bundle: Bundle -> bundle.adbPairingAutoDiscoverOnDialogOpen },
bundleField(ADB_AUTO_RECONNECT_PAIRED_DEVICE) { bundle: Bundle -> bundle.adbAutoReconnectPairedDevice },
bundleField(ADB_MDNS_LAN_DISCOVERY) { bundle: Bundle -> bundle.adbMdnsLanDiscovery },
bundleField(LAST_UPDATE_CHECK_AT) { bundle: Bundle -> bundle.lastUpdateCheckAt },
)
val bundleState: StateFlow<Bundle> = createBundleState(::bundleFromPreferences)
@@ -147,6 +155,7 @@ class AppSettings(context: Context) : Settings(context, "AppSettings") {
),
adbAutoReconnectPairedDevice = preferences.read(ADB_AUTO_RECONNECT_PAIRED_DEVICE),
adbMdnsLanDiscovery = preferences.read(ADB_MDNS_LAN_DISCOVERY),
lastUpdateCheckAt = preferences.read(LAST_UPDATE_CHECK_AT),
)
suspend fun loadBundle() = loadBundle(::bundleFromPreferences)

View File

@@ -10,12 +10,16 @@ import io.github.miuzarte.scrcpyforandroid.constants.UiSpacing
import top.yukonga.miuix.kmp.basic.SmallTitle
@Composable
fun SectionSmallTitle(text: String, showLeadingSpacer: Boolean = true) {
fun SectionSmallTitle(
text: String,
showLeadingSpacer: Boolean = true,
insideMargin: PaddingValues = PaddingValues(16.dp, 8.dp),
) {
if (showLeadingSpacer) {
Spacer(Modifier.height(UiSpacing.SectionTitleLeadingGap))
}
SmallTitle(
text = text,
insideMargin = PaddingValues(16.dp, 8.dp)
insideMargin = insideMargin,
)
}