diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 4f4d762..49311da 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -18,8 +18,8 @@ android { applicationId = "net.bluemangoo.indpjsk" minSdk = appMinSdk targetSdk = 37 - versionCode = 7 - versionName = "1.6" + versionCode = 8 + versionName = "1.7" } diff --git a/app/src/main/java/net/bluemangoo/indpjsk/data/UpdateChecker.kt b/app/src/main/java/net/bluemangoo/indpjsk/data/UpdateChecker.kt new file mode 100644 index 0000000..ce77a28 --- /dev/null +++ b/app/src/main/java/net/bluemangoo/indpjsk/data/UpdateChecker.kt @@ -0,0 +1,88 @@ +package net.bluemangoo.indpjsk.data + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import net.bluemangoo.indpjsk.BuildConfig +import org.json.JSONObject +import java.net.HttpURLConnection +import java.net.URL +import java.util.concurrent.atomic.AtomicBoolean + +data class UpdateInfo( + val latestVersion: String, + val downloadUrl: String, +) + +object UpdateChecker { + const val DOWNLOAD_URL = "https://indpjsk.bluemangoo.net/download?source=app_update" + private const val LATEST_URL = "https://indpjsk.bluemangoo.net/latest" + + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + private val started = AtomicBoolean(false) + + private val _update = MutableStateFlow(null) + + val update: StateFlow = _update.asStateFlow() + + fun ensureChecked() { + if (!started.compareAndSet(false, true)) return + scope.launch { + runCatching { fetchLatestVersion() } + .onSuccess { latest -> + if (latest != null && isNewer(latest, BuildConfig.VERSION_NAME)) { + _update.value = UpdateInfo(latest, DOWNLOAD_URL) + } + } + .onFailure { + started.set(false) + } + } + } + + private fun fetchLatestVersion(): String? { + val conn = URL(LATEST_URL).openConnection() as HttpURLConnection + try { + conn.instanceFollowRedirects = true + conn.connectTimeout = 10_000 + conn.readTimeout = 15_000 + if (conn.responseCode != 200) return null + val body = conn.inputStream.bufferedReader().use { it.readText() }.trim() + if (body.isEmpty()) return null + if (body.startsWith("{")) { + return JSONObject(body).optString("tag").trim().takeIf { it.isNotEmpty() } + } + return body + } finally { + conn.disconnect() + } + } + + internal fun isNewer(remote: String, current: String): Boolean { + fun parts(v: String): List = v.trim() + .removePrefix("v") + .removePrefix("V") + .split('.', '-', '+', '_') + .filter { it.isNotEmpty() } + + val r = parts(remote) + val c = parts(current) + for (i in 0 until maxOf(r.size, c.size)) { + val rs = r.getOrNull(i) ?: "" + val cs = c.getOrNull(i) ?: "" + val rn = rs.toIntOrNull() + val cn = cs.toIntOrNull() + if (rn != null && cn != null) { + if (rn != cn) return rn > cn + } else { + val cmp = rs.compareTo(cs, ignoreCase = true) + if (cmp != 0) return cmp > 0 + } + } + return false + } +} \ No newline at end of file diff --git a/app/src/main/java/net/bluemangoo/indpjsk/ui/AboutScreen.kt b/app/src/main/java/net/bluemangoo/indpjsk/ui/AboutScreen.kt index 2048595..2baecb6 100644 --- a/app/src/main/java/net/bluemangoo/indpjsk/ui/AboutScreen.kt +++ b/app/src/main/java/net/bluemangoo/indpjsk/ui/AboutScreen.kt @@ -1,26 +1,18 @@ package net.bluemangoo.indpjsk.ui -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.* import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.HorizontalDivider -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Scaffold -import androidx.compose.material3.Text -import androidx.compose.material3.TopAppBar -import androidx.compose.runtime.Composable +import androidx.compose.material3.* +import androidx.compose.runtime.* import androidx.compose.ui.Modifier import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.* import androidx.compose.ui.unit.dp import net.bluemangoo.indpjsk.BuildConfig import net.bluemangoo.indpjsk.R +import net.bluemangoo.indpjsk.data.UpdateChecker +import net.bluemangoo.indpjsk.ui.theme.indPjskCustomColor @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -28,6 +20,9 @@ fun AboutScreen( readme: String, onBack: () -> Unit, ) { + val update by UpdateChecker.update.collectAsState() + LaunchedEffect(Unit) { UpdateChecker.ensureChecked() } + Scaffold( topBar = { TopAppBar( @@ -54,6 +49,10 @@ fun AboutScreen( text = "Version ${BuildConfig.VERSION_NAME} (${BuildConfig.VERSION_CODE})", style = MaterialTheme.typography.titleMedium ) + update?.let { info -> + Spacer(Modifier.height(4.dp)) + UpdateLine(info.latestVersion) + } Spacer(Modifier.height(8.dp)) HorizontalDivider() Spacer(Modifier.height(8.dp)) @@ -65,3 +64,30 @@ fun AboutScreen( } } } + +@Composable +private fun UpdateLine(version: String) { + val warning = indPjskCustomColor().warning + val text = remember(version) { + buildAnnotatedString { + withStyle(SpanStyle(color = warning)) { + append("发现新的版本 $version,") + } + withLink( + LinkAnnotation.Url( + url = UpdateChecker.DOWNLOAD_URL, + ) + ) { + append("点击") + } + withStyle(SpanStyle(color = warning)) { + append("下载") + } + } + } + Text( + text = text, + style = MaterialTheme.typography.bodyMedium, + softWrap = true + ) +} diff --git a/app/src/main/java/net/bluemangoo/indpjsk/ui/theme/Color.kt b/app/src/main/java/net/bluemangoo/indpjsk/ui/theme/Color.kt index ee80048..352726f 100644 --- a/app/src/main/java/net/bluemangoo/indpjsk/ui/theme/Color.kt +++ b/app/src/main/java/net/bluemangoo/indpjsk/ui/theme/Color.kt @@ -8,4 +8,7 @@ val Pink80 = Color(0xFFEFB8C8) val Purple40 = Color(0xFF6650a4) val PurpleGrey40 = Color(0xFF625b71) -val Pink40 = Color(0xFF7D5260) \ No newline at end of file +val Pink40 = Color(0xFF7D5260) + +val Warning = Color(0xFFD97706) +val WarningDark = Color(0xFFFBBF24) diff --git a/app/src/main/java/net/bluemangoo/indpjsk/ui/theme/Theme.kt b/app/src/main/java/net/bluemangoo/indpjsk/ui/theme/Theme.kt index 2ab0564..08bf887 100644 --- a/app/src/main/java/net/bluemangoo/indpjsk/ui/theme/Theme.kt +++ b/app/src/main/java/net/bluemangoo/indpjsk/ui/theme/Theme.kt @@ -4,8 +4,28 @@ import android.os.Build import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.material3.* import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext +data class CustomColorScheme( + val warning: Color, +) + +private val customLightColorScheme = CustomColorScheme( + warning = Warning, +) + +private val customDarkColorScheme = CustomColorScheme( + warning = WarningDark, +) + +@Composable +fun indPjskCustomColor( + darkTheme: Boolean = isSystemInDarkTheme(), +): CustomColorScheme { + return if (darkTheme) customDarkColorScheme else customLightColorScheme +} + private val DarkColorScheme = darkColorScheme( primary = Purple80, secondary = PurpleGrey80,