Split support for Android API 21 and 23

This commit is contained in:
世界
2025-12-24 16:25:11 +08:00
parent cf771e1071
commit 456d35d969
22 changed files with 424 additions and 102 deletions

View File

@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
<uses-permission android:name="android.permission.UPDATE_PACKAGES_WITHOUT_USER_ACTION" />
<application>
<receiver
android:name=".vendor.InstallResultReceiver"
android:exported="false">
<intent-filter>
<action android:name="io.nekohasekai.sfa.INSTALL_COMPLETE" />
</intent-filter>
</receiver>
</application>
</manifest>

View File

@@ -0,0 +1,43 @@
package io.nekohasekai.sfa.vendor
import io.nekohasekai.libbox.Libbox
import io.nekohasekai.sfa.Application
import io.nekohasekai.sfa.update.UpdateState
import io.nekohasekai.sfa.utils.HTTPClient
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.io.Closeable
import java.io.File
class ApkDownloader : Closeable {
private val client = Libbox.newHTTPClient().apply {
modernTLS()
keepAlive()
}
suspend fun download(url: String): File = withContext(Dispatchers.IO) {
val cacheDir = File(Application.application.cacheDir, "updates")
cacheDir.mkdirs()
val apkFile = File(cacheDir, "update.apk")
if (apkFile.exists()) apkFile.delete()
val request = client.newRequest()
request.setUserAgent(HTTPClient.userAgent)
request.setURL(url)
val response = request.execute()
response.writeTo(apkFile.absolutePath)
if (!apkFile.exists() || apkFile.length() == 0L) {
throw Exception("Download failed: empty file")
}
UpdateState.saveApkPath(apkFile)
apkFile
}
override fun close() {
client.close()
}
}

View File

@@ -0,0 +1,120 @@
package io.nekohasekai.sfa.vendor
import android.os.Build
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 isLegacy = Build.VERSION.SDK_INT < Build.VERSION_CODES.M
val apkAsset = release.assets.find { asset ->
asset.name.endsWith(".apk") &&
!asset.name.contains("play") &&
asset.name.contains("legacy-android-5") == isLegacy
}
return UpdateInfo(
versionCode = metadata.versionCode,
versionName = metadata.versionName,
downloadUrl = apkAsset?.browserDownloadUrl ?: release.htmlUrl,
releaseUrl = release.htmlUrl,
releaseNotes = release.body,
isPrerelease = release.prerelease,
fileSize = apkAsset?.size ?: 0,
)
}
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

@@ -0,0 +1,47 @@
package io.nekohasekai.sfa.vendor
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.pm.PackageInstaller
import android.util.Log
import io.nekohasekai.sfa.update.UpdateState
class InstallResultReceiver : BroadcastReceiver() {
companion object {
const val ACTION_INSTALL_COMPLETE = "io.nekohasekai.sfa.INSTALL_COMPLETE"
private const val TAG = "InstallResultReceiver"
}
override fun onReceive(context: Context, intent: Intent) {
if (intent.action != ACTION_INSTALL_COMPLETE) return
val status = intent.getIntExtra(PackageInstaller.EXTRA_STATUS, PackageInstaller.STATUS_FAILURE)
val message = intent.getStringExtra(PackageInstaller.EXTRA_STATUS_MESSAGE)
Log.d(TAG, "Install result: status=$status, message=$message")
when (status) {
PackageInstaller.STATUS_PENDING_USER_ACTION -> {
val confirmIntent = if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.TIRAMISU) {
intent.getParcelableExtra(Intent.EXTRA_INTENT, Intent::class.java)
} else {
@Suppress("DEPRECATION")
intent.getParcelableExtra(Intent.EXTRA_INTENT)
}
confirmIntent?.let {
it.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
context.startActivity(it)
}
}
PackageInstaller.STATUS_SUCCESS -> {
Log.d(TAG, "Installation successful")
UpdateState.setInstallStatus(UpdateState.InstallStatus.Success)
}
else -> {
Log.e(TAG, "Installation failed: $status - $message")
UpdateState.setInstallStatus(UpdateState.InstallStatus.Failed(message ?: "Unknown error"))
}
}
}
}

View File

@@ -0,0 +1,39 @@
package io.nekohasekai.sfa.vendor
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.io.BufferedReader
import java.io.BufferedWriter
import java.io.File
import java.io.InputStreamReader
import java.io.OutputStreamWriter
object RootInstaller {
suspend fun checkAccess(): Boolean = withContext(Dispatchers.IO) {
try {
val process = Runtime.getRuntime().exec("su -c echo test")
val exitCode = process.waitFor()
exitCode == 0
} catch (e: Exception) {
false
}
}
suspend fun install(apkFile: File): Result<Unit> = withContext(Dispatchers.IO) {
try {
val process = Runtime.getRuntime().exec(arrayOf("su", "-c", "pm install -r \"${apkFile.absolutePath}\""))
val reader = BufferedReader(InputStreamReader(process.inputStream))
val output = reader.readText()
val exitCode = process.waitFor()
if (exitCode == 0 && output.contains("Success")) {
Result.success(Unit)
} else {
Result.failure(Exception("Installation failed: $output"))
}
} catch (e: Exception) {
Result.failure(e)
}
}
}

View File

@@ -0,0 +1,52 @@
package io.nekohasekai.sfa.vendor
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.os.Build
import java.io.File
import java.io.FileInputStream
import android.content.pm.PackageInstaller as AndroidPackageInstaller
object SystemPackageInstaller {
fun canSystemSilentInstall(): Boolean {
return Build.VERSION.SDK_INT >= Build.VERSION_CODES.S
}
fun install(context: Context, apkFile: File): Result<Unit> {
return try {
val packageInstaller = context.packageManager.packageInstaller
val params = AndroidPackageInstaller.SessionParams(AndroidPackageInstaller.SessionParams.MODE_FULL_INSTALL)
params.setAppPackageName(context.packageName)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
params.setRequireUserAction(AndroidPackageInstaller.SessionParams.USER_ACTION_NOT_REQUIRED)
}
val sessionId = packageInstaller.createSession(params)
packageInstaller.openSession(sessionId).use { session ->
session.openWrite("update.apk", 0, apkFile.length()).use { outputStream ->
FileInputStream(apkFile).use { inputStream ->
inputStream.copyTo(outputStream)
}
session.fsync(outputStream)
}
val intent = Intent(context, InstallResultReceiver::class.java).apply {
action = InstallResultReceiver.ACTION_INSTALL_COMPLETE
}
val pendingIntent = PendingIntent.getBroadcast(
context,
sessionId,
intent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_MUTABLE
)
session.commit(pendingIntent.intentSender)
}
Result.success(Unit)
} catch (e: Exception) {
Result.failure(e)
}
}
}

View File

@@ -0,0 +1,97 @@
package io.nekohasekai.sfa.vendor
import android.content.Context
import android.util.Log
import androidx.work.BackoffPolicy
import androidx.work.Constraints
import androidx.work.CoroutineWorker
import androidx.work.ExistingPeriodicWorkPolicy
import androidx.work.NetworkType
import androidx.work.PeriodicWorkRequestBuilder
import androidx.work.WorkManager
import androidx.work.WorkerParameters
import io.nekohasekai.sfa.database.Settings
import io.nekohasekai.sfa.update.UpdateState
import io.nekohasekai.sfa.update.UpdateTrack
import java.util.concurrent.TimeUnit
class UpdateWorker(
private val appContext: Context,
params: WorkerParameters
) : CoroutineWorker(appContext, params) {
companion object {
private const val WORK_NAME = "AutoUpdate"
private const val TAG = "UpdateWorker"
fun schedule(context: Context) {
if (!Settings.autoUpdateEnabled) {
WorkManager.getInstance(context).cancelUniqueWork(WORK_NAME)
Log.d(TAG, "Auto update disabled, cancelled scheduled work")
return
}
val constraints = Constraints.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED)
.setRequiresBatteryNotLow(true)
.build()
val workRequest = PeriodicWorkRequestBuilder<UpdateWorker>(
24, TimeUnit.HOURS
)
.setConstraints(constraints)
.setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 1, TimeUnit.HOURS)
.build()
WorkManager.getInstance(context).enqueueUniquePeriodicWork(
WORK_NAME,
ExistingPeriodicWorkPolicy.KEEP,
workRequest
)
Log.d(TAG, "Auto update scheduled")
}
}
override suspend fun doWork(): Result {
if (!Settings.autoUpdateEnabled) {
Log.d(TAG, "Auto update disabled, skipping")
return Result.success()
}
Log.d(TAG, "Checking for updates...")
return try {
val track = UpdateTrack.fromString(Settings.updateTrack)
val updateInfo = GitHubUpdateChecker().use { it.checkUpdate(track) }
if (updateInfo == null) {
Log.d(TAG, "No update available")
return Result.success()
}
Log.d(TAG, "Update available: ${updateInfo.versionName}")
UpdateState.setUpdate(updateInfo)
if (Settings.silentInstallEnabled && ApkInstaller.canSilentInstall()) {
Log.d(TAG, "Downloading update...")
val apkFile = ApkDownloader().use { it.download(updateInfo.downloadUrl) }
Log.d(TAG, "Installing update...")
val result = ApkInstaller.install(appContext, apkFile)
if (result.isSuccess) {
Log.d(TAG, "Update installed successfully")
} else {
Log.e(TAG, "Update installation failed", result.exceptionOrNull())
}
} else {
Log.d(TAG, "Silent install not available, update will be shown on next app launch")
}
Result.success()
} catch (e: Exception) {
Log.e(TAG, "Auto update failed", e)
Result.retry()
}
}
}