Split support for Android API 21 and 23
This commit is contained in:
17
app/src/github/AndroidManifest.xml
Normal file
17
app/src/github/AndroidManifest.xml
Normal 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>
|
||||
43
app/src/github/java/io/nekohasekai/sfa/vendor/ApkDownloader.kt
vendored
Normal file
43
app/src/github/java/io/nekohasekai/sfa/vendor/ApkDownloader.kt
vendored
Normal 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()
|
||||
}
|
||||
}
|
||||
120
app/src/github/java/io/nekohasekai/sfa/vendor/GitHubUpdateChecker.kt
vendored
Normal file
120
app/src/github/java/io/nekohasekai/sfa/vendor/GitHubUpdateChecker.kt
vendored
Normal 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 = "",
|
||||
)
|
||||
}
|
||||
47
app/src/github/java/io/nekohasekai/sfa/vendor/InstallResultReceiver.kt
vendored
Normal file
47
app/src/github/java/io/nekohasekai/sfa/vendor/InstallResultReceiver.kt
vendored
Normal 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"))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
39
app/src/github/java/io/nekohasekai/sfa/vendor/RootInstaller.kt
vendored
Normal file
39
app/src/github/java/io/nekohasekai/sfa/vendor/RootInstaller.kt
vendored
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
52
app/src/github/java/io/nekohasekai/sfa/vendor/SystemPackageInstaller.kt
vendored
Normal file
52
app/src/github/java/io/nekohasekai/sfa/vendor/SystemPackageInstaller.kt
vendored
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
97
app/src/github/java/io/nekohasekai/sfa/vendor/UpdateWorker.kt
vendored
Normal file
97
app/src/github/java/io/nekohasekai/sfa/vendor/UpdateWorker.kt
vendored
Normal 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user