Add app settings with update track and auto-check options

- Add AppSettingsScreen with update track selection and auto-check toggle
- Remove checkUpdateAvailable() as all vendors now support update checking
- Add missing Chinese translations for update-related strings
This commit is contained in:
世界
2025-12-16 18:25:10 +08:00
parent be175ccd73
commit e2e2c2ca7b
21 changed files with 862 additions and 21 deletions

View File

@@ -0,0 +1,115 @@
package io.nekohasekai.sfa.vendor
import io.nekohasekai.libbox.Libbox
import io.nekohasekai.sfa.BuildConfig
import io.nekohasekai.sfa.ktx.unwrap
import io.nekohasekai.sfa.update.UpdateCheckException
import io.nekohasekai.sfa.update.UpdateInfo
import io.nekohasekai.sfa.update.UpdateTrack
import io.nekohasekai.sfa.utils.HTTPClient
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import java.io.Closeable
class GitHubUpdateChecker : Closeable {
companion object {
private const val RELEASES_URL = "https://api.github.com/repos/SagerNet/sing-box/releases"
private const val METADATA_FILENAME = "SFA-version-metadata.json"
}
private val client = Libbox.newHTTPClient().apply {
modernTLS()
keepAlive()
}
private val json = Json { ignoreUnknownKeys = true }
fun checkUpdate(track: UpdateTrack): UpdateInfo? {
val includePrerelease = track == UpdateTrack.BETA
val release = getLatestRelease(includePrerelease) ?: return null
if (!release.assets.any { it.name == METADATA_FILENAME }) {
throw UpdateCheckException.TrackNotSupported()
}
val metadata = downloadMetadata(release)!!
if (metadata.versionCode <= BuildConfig.VERSION_CODE) {
return null
}
val apkAsset = release.assets.find { asset ->
asset.name.endsWith(".apk") && !asset.name.contains("play")
}
return UpdateInfo(
versionCode = metadata.versionCode,
versionName = metadata.versionName,
downloadUrl = apkAsset?.browserDownloadUrl ?: release.htmlUrl,
releaseUrl = release.htmlUrl,
releaseNotes = release.body,
isPrerelease = release.prerelease,
)
}
private fun getLatestRelease(includePrerelease: Boolean): GitHubRelease? {
val request = client.newRequest()
request.setURL(RELEASES_URL)
request.setHeader("Accept", "application/vnd.github.v3+json")
request.setUserAgent(HTTPClient.userAgent)
val response = request.execute()
val content = response.content.unwrap
val releases = json.decodeFromString<List<GitHubRelease>>(content)
return if (includePrerelease) {
releases.firstOrNull()
} else {
releases.firstOrNull { !it.prerelease && !it.draft }
}
}
private fun downloadMetadata(release: GitHubRelease): VersionMetadata? {
val metadataAsset = release.assets.find { it.name == METADATA_FILENAME }
?: return null
val request = client.newRequest()
request.setURL(metadataAsset.browserDownloadUrl)
request.setUserAgent(HTTPClient.userAgent)
val response = request.execute()
val content = response.content.unwrap
return json.decodeFromString<VersionMetadata>(content)
}
override fun close() {
client.close()
}
@Serializable
data class GitHubRelease(
@SerialName("tag_name") val tagName: String = "",
val name: String = "",
val body: String? = null,
val draft: Boolean = false,
val prerelease: Boolean = false,
@SerialName("html_url") val htmlUrl: String = "",
val assets: List<GitHubAsset> = emptyList(),
)
@Serializable
data class GitHubAsset(
val name: String = "",
@SerialName("browser_download_url") val browserDownloadUrl: String = "",
val size: Long = 0,
)
@Serializable
data class VersionMetadata(
@SerialName("version_code") val versionCode: Int = 0,
@SerialName("version_name") val versionName: String = "",
)
}

View File

@@ -1,17 +1,89 @@
package io.nekohasekai.sfa.vendor
import android.app.Activity
import android.content.Intent
import android.net.Uri
import android.util.Log
import androidx.camera.core.ImageAnalysis
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import io.nekohasekai.sfa.R
import io.nekohasekai.sfa.database.Settings
import io.nekohasekai.sfa.update.UpdateCheckException
import io.nekohasekai.sfa.update.UpdateInfo
import io.nekohasekai.sfa.update.UpdateTrack
object Vendor : VendorInterface {
override fun checkUpdateAvailable(): Boolean {
return false
}
private const val TAG = "Vendor"
override fun checkUpdate(
activity: Activity,
byUser: Boolean,
) {
try {
val updateInfo = checkUpdateAsync()
if (updateInfo != null) {
activity.runOnUiThread {
showUpdateDialog(activity, updateInfo)
}
} else if (byUser) {
activity.runOnUiThread {
showNoUpdatesDialog(activity)
}
}
} catch (e: UpdateCheckException.TrackNotSupported) {
Log.d(TAG, "checkUpdate: track not supported")
if (byUser) {
activity.runOnUiThread {
showTrackNotSupportedDialog(activity)
}
}
} catch (e: Exception) {
Log.e(TAG, "checkUpdate: ", e)
if (byUser) {
activity.runOnUiThread {
showNoUpdatesDialog(activity)
}
}
}
}
private fun showUpdateDialog(activity: Activity, updateInfo: UpdateInfo) {
val message = buildString {
append(activity.getString(R.string.new_version_available, updateInfo.versionName))
if (!updateInfo.releaseNotes.isNullOrBlank()) {
append("\n\n")
append(updateInfo.releaseNotes.take(500))
if (updateInfo.releaseNotes.length > 500) {
append("...")
}
}
}
MaterialAlertDialogBuilder(activity)
.setTitle(R.string.check_update)
.setMessage(message)
.setPositiveButton(R.string.update) { _, _ ->
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(updateInfo.releaseUrl))
activity.startActivity(intent)
}
.setNegativeButton(R.string.cancel, null)
.show()
}
private fun showNoUpdatesDialog(activity: Activity) {
MaterialAlertDialogBuilder(activity)
.setTitle(R.string.check_update)
.setMessage(R.string.no_updates_available)
.setPositiveButton(R.string.ok, null)
.show()
}
private fun showTrackNotSupportedDialog(activity: Activity) {
MaterialAlertDialogBuilder(activity)
.setTitle(R.string.check_update)
.setMessage(R.string.update_track_not_supported)
.setPositiveButton(R.string.ok, null)
.show()
}
override fun createQRCodeAnalyzer(
@@ -22,7 +94,17 @@ object Vendor : VendorInterface {
}
override fun isPerAppProxyAvailable(): Boolean {
// Per-app Proxy is available for non-Play Store builds
return true
}
override fun supportsTrackSelection(): Boolean {
return true
}
override fun checkUpdateAsync(): UpdateInfo? {
val track = UpdateTrack.fromString(Settings.updateTrack)
return GitHubUpdateChecker().use { checker ->
checker.checkUpdate(track)
}
}
}