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 { buildFeatures {
compose = true compose = true
prefab = true prefab = true
buildConfig = true
} }
externalNativeBuild { externalNativeBuild {
@@ -108,4 +109,4 @@ dependencies {
androidTestImplementation(libs.androidx.compose.ui.test.junit4) androidTestImplementation(libs.androidx.compose.ui.test.junit4)
debugImplementation(libs.androidx.compose.ui.tooling) debugImplementation(libs.androidx.compose.ui.tooling)
debugImplementation(libs.androidx.compose.ui.test.manifest) 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.entryProvider
import androidx.navigation3.runtime.rememberDecoratedNavEntries import androidx.navigation3.runtime.rememberDecoratedNavEntries
import androidx.navigation3.ui.NavDisplay import androidx.navigation3.ui.NavDisplay
import io.github.miuzarte.scrcpyforandroid.BuildConfig
import io.github.miuzarte.scrcpyforandroid.NativeCoreFacade import io.github.miuzarte.scrcpyforandroid.NativeCoreFacade
import io.github.miuzarte.scrcpyforandroid.constants.ThemeModes import io.github.miuzarte.scrcpyforandroid.constants.ThemeModes
import io.github.miuzarte.scrcpyforandroid.constants.UiMotion import io.github.miuzarte.scrcpyforandroid.constants.UiMotion
import io.github.miuzarte.scrcpyforandroid.models.DeviceShortcuts import io.github.miuzarte.scrcpyforandroid.models.DeviceShortcuts
import io.github.miuzarte.scrcpyforandroid.nativecore.NativeAdbService import io.github.miuzarte.scrcpyforandroid.nativecore.NativeAdbService
import io.github.miuzarte.scrcpyforandroid.scrcpy.Scrcpy 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.services.SnackbarController
import io.github.miuzarte.scrcpyforandroid.storage.AppSettings import io.github.miuzarte.scrcpyforandroid.storage.AppSettings
import io.github.miuzarte.scrcpyforandroid.storage.Settings import io.github.miuzarte.scrcpyforandroid.storage.Settings
@@ -217,6 +219,15 @@ fun MainScreen() {
} }
val currentSession by scrcpy.currentSessionState.collectAsState() 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() { fun handleBackNavigation() {
if (rootBackStack.size > 1) { if (rootBackStack.size > 1) {
popRoot() popRoot()

View File

@@ -29,12 +29,14 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.core.net.toUri import androidx.core.net.toUri
import io.github.miuzarte.scrcpyforandroid.BuildConfig
import io.github.miuzarte.scrcpyforandroid.constants.ThemeModes import io.github.miuzarte.scrcpyforandroid.constants.ThemeModes
import io.github.miuzarte.scrcpyforandroid.constants.UiSpacing import io.github.miuzarte.scrcpyforandroid.constants.UiSpacing
import io.github.miuzarte.scrcpyforandroid.scaffolds.LazyColumn import io.github.miuzarte.scrcpyforandroid.scaffolds.LazyColumn
import io.github.miuzarte.scrcpyforandroid.scaffolds.SuperSlider import io.github.miuzarte.scrcpyforandroid.scaffolds.SuperSlider
import io.github.miuzarte.scrcpyforandroid.scaffolds.SuperTextField import io.github.miuzarte.scrcpyforandroid.scaffolds.SuperTextField
import io.github.miuzarte.scrcpyforandroid.scrcpy.Scrcpy 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.services.SnackbarController
import io.github.miuzarte.scrcpyforandroid.storage.AppSettings import io.github.miuzarte.scrcpyforandroid.storage.AppSettings
import io.github.miuzarte.scrcpyforandroid.storage.PreferenceMigration 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.SuperArrow
import top.yukonga.miuix.kmp.extra.SuperDropdown import top.yukonga.miuix.kmp.extra.SuperDropdown
import top.yukonga.miuix.kmp.extra.SuperSwitch import top.yukonga.miuix.kmp.extra.SuperSwitch
import top.yukonga.miuix.kmp.theme.ColorSchemeMode
import kotlin.math.roundToInt import kotlin.math.roundToInt
import kotlin.system.exitProcess import kotlin.system.exitProcess
@@ -100,6 +101,7 @@ fun SettingsPage(
val context = LocalContext.current val context = LocalContext.current
val appContext = context.applicationContext val appContext = context.applicationContext
var needMigration by remember { mutableStateOf(false) } var needMigration by remember { mutableStateOf(false) }
val updateState by AppUpdateChecker.state.collectAsState()
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
needMigration = PreferenceMigration(appContext).needsMigration() 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( LazyColumn(
contentPadding = contentPadding, contentPadding = contentPadding,
@@ -163,11 +196,19 @@ fun SettingsPage(
SuperDropdown( SuperDropdown(
title = "外观模式", title = "外观模式",
summary = ThemeModes.baseOptions summary = ThemeModes.baseOptions
.getOrNull(asBundle.themeBaseIndex.coerceIn(0, ThemeModes.baseOptions.lastIndex)) .getOrNull(
asBundle.themeBaseIndex.coerceIn(
0,
ThemeModes.baseOptions.lastIndex
)
)
?.label ?.label
?: "跟随系统", ?: "跟随系统",
items = themeItems, items = themeItems,
selectedIndex = asBundle.themeBaseIndex.coerceIn(0, ThemeModes.baseOptions.lastIndex), selectedIndex = asBundle.themeBaseIndex.coerceIn(
0,
ThemeModes.baseOptions.lastIndex
),
onSelectedIndexChange = { onSelectedIndexChange = {
asBundle = asBundle.copy(themeBaseIndex = it) asBundle = asBundle.copy(themeBaseIndex = it)
}, },
@@ -441,13 +482,13 @@ fun SettingsPage(
SectionSmallTitle("关于", showLeadingSpacer = false) SectionSmallTitle("关于", showLeadingSpacer = false)
Card { Card {
SuperArrow( SuperArrow(
title = "前往仓库", title = "仓库发布页",
summary = "github.com/Miuzarte/ScrcpyForAndroid", summary = "$updateSummary\n${AppUpdateChecker.REPO_URL.removePrefix("https://")}",
onClick = { onClick = {
context.startActivity( context.startActivity(
Intent( Intent(
Intent.ACTION_VIEW, 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.Preferences
import androidx.datastore.preferences.core.booleanPreferencesKey import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.intPreferencesKey import androidx.datastore.preferences.core.intPreferencesKey
import androidx.datastore.preferences.core.longPreferencesKey
import androidx.datastore.preferences.core.stringPreferencesKey import androidx.datastore.preferences.core.stringPreferencesKey
import io.github.miuzarte.scrcpyforandroid.scrcpy.Scrcpy import io.github.miuzarte.scrcpyforandroid.scrcpy.Scrcpy
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
@@ -68,6 +69,10 @@ class AppSettings(context: Context) : Settings(context, "AppSettings") {
booleanPreferencesKey("adb_mdns_lan_discovery"), booleanPreferencesKey("adb_mdns_lan_discovery"),
true true
) )
val LAST_UPDATE_CHECK_AT = Pair(
longPreferencesKey("last_update_check_at"),
0L
)
} }
// Theme Settings // 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 adbPairingAutoDiscoverOnDialogOpen by setting(ADB_PAIRING_AUTO_DISCOVER_ON_DIALOG_OPEN)
val adbAutoReconnectPairedDevice by setting(ADB_AUTO_RECONNECT_PAIRED_DEVICE) val adbAutoReconnectPairedDevice by setting(ADB_AUTO_RECONNECT_PAIRED_DEVICE)
val adbMdnsLanDiscovery by setting(ADB_MDNS_LAN_DISCOVERY) val adbMdnsLanDiscovery by setting(ADB_MDNS_LAN_DISCOVERY)
val lastUpdateCheckAt by setting(LAST_UPDATE_CHECK_AT)
@Parcelize @Parcelize
data class Bundle( data class Bundle(
@@ -108,6 +114,7 @@ class AppSettings(context: Context) : Settings(context, "AppSettings") {
val adbPairingAutoDiscoverOnDialogOpen: Boolean, val adbPairingAutoDiscoverOnDialogOpen: Boolean,
val adbAutoReconnectPairedDevice: Boolean, val adbAutoReconnectPairedDevice: Boolean,
val adbMdnsLanDiscovery: Boolean, val adbMdnsLanDiscovery: Boolean,
val lastUpdateCheckAt: Long,
) : Parcelable { ) : 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_PAIRING_AUTO_DISCOVER_ON_DIALOG_OPEN) { bundle: Bundle -> bundle.adbPairingAutoDiscoverOnDialogOpen },
bundleField(ADB_AUTO_RECONNECT_PAIRED_DEVICE) { bundle: Bundle -> bundle.adbAutoReconnectPairedDevice }, bundleField(ADB_AUTO_RECONNECT_PAIRED_DEVICE) { bundle: Bundle -> bundle.adbAutoReconnectPairedDevice },
bundleField(ADB_MDNS_LAN_DISCOVERY) { bundle: Bundle -> bundle.adbMdnsLanDiscovery }, bundleField(ADB_MDNS_LAN_DISCOVERY) { bundle: Bundle -> bundle.adbMdnsLanDiscovery },
bundleField(LAST_UPDATE_CHECK_AT) { bundle: Bundle -> bundle.lastUpdateCheckAt },
) )
val bundleState: StateFlow<Bundle> = createBundleState(::bundleFromPreferences) 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), adbAutoReconnectPairedDevice = preferences.read(ADB_AUTO_RECONNECT_PAIRED_DEVICE),
adbMdnsLanDiscovery = preferences.read(ADB_MDNS_LAN_DISCOVERY), adbMdnsLanDiscovery = preferences.read(ADB_MDNS_LAN_DISCOVERY),
lastUpdateCheckAt = preferences.read(LAST_UPDATE_CHECK_AT),
) )
suspend fun loadBundle() = loadBundle(::bundleFromPreferences) 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 import top.yukonga.miuix.kmp.basic.SmallTitle
@Composable @Composable
fun SectionSmallTitle(text: String, showLeadingSpacer: Boolean = true) { fun SectionSmallTitle(
text: String,
showLeadingSpacer: Boolean = true,
insideMargin: PaddingValues = PaddingValues(16.dp, 8.dp),
) {
if (showLeadingSpacer) { if (showLeadingSpacer) {
Spacer(Modifier.height(UiSpacing.SectionTitleLeadingGap)) Spacer(Modifier.height(UiSpacing.SectionTitleLeadingGap))
} }
SmallTitle( SmallTitle(
text = text, text = text,
insideMargin = PaddingValues(16.dp, 8.dp) insideMargin = insideMargin,
) )
} }