feat: version checking using github api
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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(),
|
||||
)
|
||||
)
|
||||
},
|
||||
|
||||
@@ -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) }
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user