diff --git a/MAINPAGE_REFACTORING_ANALYSIS.md b/MAINPAGE_REFACTORING_ANALYSIS.md deleted file mode 100644 index e69de29..0000000 diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 76676bc..29e8b38 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -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) -} \ No newline at end of file +} diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/MainScreen.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/MainScreen.kt index a8bc02a..0cc58fa 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/MainScreen.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/MainScreen.kt @@ -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() diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/SettingsScreen.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/SettingsScreen.kt index 9e9b533..9c09c9a 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/SettingsScreen.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/SettingsScreen.kt @@ -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(), ) ) }, diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/services/AppUpdateChecker.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/services/AppUpdateChecker.kt new file mode 100644 index 0000000..05f9565 --- /dev/null +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/services/AppUpdateChecker.kt @@ -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.Idle) + val state: StateFlow = _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) } +} diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/storage/AppSettings.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/storage/AppSettings.kt index fff143f..92887cb 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/storage/AppSettings.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/storage/AppSettings.kt @@ -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 = 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) diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/widgets/SectionSmallTitle.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/widgets/SectionSmallTitle.kt index 3522532..e1df591 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/widgets/SectionSmallTitle.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/widgets/SectionSmallTitle.kt @@ -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, ) }