Improve update system

This commit is contained in:
世界
2025-12-18 22:38:33 +08:00
parent 2da0674c33
commit 72c7794ba9
21 changed files with 1391 additions and 28 deletions

View File

@@ -0,0 +1,29 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
<uses-permission android:name="android.permission.UPDATE_PACKAGES_WITHOUT_USER_ACTION" />
<uses-permission android:name="moe.shizuku.manager.permission.API_V23" />
<uses-sdk tools:overrideLibrary="rikka.shizuku.provider,rikka.shizuku.api,rikka.shizuku.aidl,rikka.shizuku.shared" />
<application>
<provider
android:name="rikka.shizuku.ShizukuProvider"
android:authorities="${applicationId}.shizuku"
android:exported="true"
android:multiprocess="false"
android:permission="android.permission.INTERACT_ACROSS_USERS_FULL" />
<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,9 @@
package android.content;
import android.os.Bundle;
import android.os.IInterface;
public interface IIntentReceiver extends IInterface {
void performReceive(Intent intent, int resultCode, String data, Bundle extras,
boolean ordered, boolean sticky, int sendingUser);
}

View File

@@ -0,0 +1,23 @@
package android.content;
import android.os.Binder;
import android.os.Bundle;
import android.os.IBinder;
import android.os.IInterface;
public interface IIntentSender extends IInterface {
void send(int code, Intent intent, String resolvedType, IBinder whitelistToken,
IIntentReceiver finishedReceiver, String requiredPermission, Bundle options);
abstract class Stub extends Binder implements IIntentSender {
public static IIntentSender asInterface(IBinder binder) {
throw new UnsupportedOperationException();
}
@Override
public IBinder asBinder() {
return this;
}
}
}

View File

@@ -0,0 +1,21 @@
package android.content.pm;
import android.os.Binder;
import android.os.IBinder;
import android.os.IInterface;
import android.os.RemoteException;
public interface IPackageInstaller extends IInterface {
int createSession(PackageInstaller.SessionParams params, String installerPackageName, String installerAttributionTag, int userId) throws RemoteException;
IPackageInstallerSession openSession(int sessionId) throws RemoteException;
void abandonSession(int sessionId) throws RemoteException;
abstract class Stub extends Binder implements IPackageInstaller {
public static IPackageInstaller asInterface(IBinder binder) {
throw new UnsupportedOperationException();
}
}
}

View File

@@ -0,0 +1,14 @@
package android.content.pm;
import android.os.Binder;
import android.os.IBinder;
import android.os.IInterface;
public interface IPackageInstallerSession extends IInterface {
abstract class Stub extends Binder implements IPackageInstallerSession {
public static IPackageInstallerSession asInterface(IBinder binder) {
throw new UnsupportedOperationException();
}
}
}

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,84 @@
package io.nekohasekai.sfa.vendor
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.content.pm.PackageInstaller
import android.os.Build
import io.nekohasekai.sfa.database.Settings
import java.io.File
import java.io.FileInputStream
enum class InstallMethod {
PACKAGE_INSTALLER,
SHIZUKU,
ROOT,
}
object ApkInstaller {
fun getConfiguredMethod(): InstallMethod {
return if (Settings.silentInstallEnabled) {
InstallMethod.valueOf(Settings.silentInstallMethod)
} else {
InstallMethod.PACKAGE_INSTALLER
}
}
suspend fun install(context: Context, apkFile: File, method: InstallMethod = getConfiguredMethod()): Result<Unit> {
return when (method) {
InstallMethod.SHIZUKU -> ShizukuInstaller.install(apkFile)
InstallMethod.ROOT -> RootInstaller.install(apkFile)
InstallMethod.PACKAGE_INSTALLER -> installWithPackageInstaller(context, apkFile)
}
}
fun canSystemSilentInstall(): Boolean {
return Build.VERSION.SDK_INT >= Build.VERSION_CODES.S
}
suspend fun canSilentInstall(): Boolean {
val method = getConfiguredMethod()
return when (method) {
InstallMethod.PACKAGE_INSTALLER -> canSystemSilentInstall()
InstallMethod.SHIZUKU -> ShizukuInstaller.isAvailable() && ShizukuInstaller.checkPermission()
InstallMethod.ROOT -> RootInstaller.checkAccess()
}
}
private fun installWithPackageInstaller(context: Context, apkFile: File): Result<Unit> {
return try {
val packageInstaller = context.packageManager.packageInstaller
val params = PackageInstaller.SessionParams(PackageInstaller.SessionParams.MODE_FULL_INSTALL)
params.setAppPackageName(context.packageName)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
params.setRequireUserAction(PackageInstaller.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

@@ -50,6 +50,7 @@ class GitHubUpdateChecker : Closeable {
releaseUrl = release.htmlUrl,
releaseNotes = release.body,
isPrerelease = release.prerelease,
fileSize = apkAsset?.size ?: 0,
)
}

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,185 @@
package io.nekohasekai.sfa.vendor
import android.content.Intent
import android.content.IntentSender
import android.content.pm.IPackageInstaller
import android.content.pm.IPackageInstallerSession
import android.content.pm.PackageInstaller
import android.content.pm.PackageManager
import android.os.Build
import android.os.Process
import io.nekohasekai.sfa.vendor.hidden.IPackageManager
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.lsposed.hiddenapibypass.HiddenApiBypass
import rikka.shizuku.Shizuku
import rikka.shizuku.ShizukuBinderWrapper
import rikka.shizuku.SystemServiceHelper
import java.io.File
import java.io.FileInputStream
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
import android.content.IIntentSender
object ShizukuInstaller {
private const val SHIZUKU_PERMISSION_REQUEST_CODE = 1001
fun isAvailable(): Boolean {
return try {
Shizuku.pingBinder()
} catch (e: Exception) {
false
}
}
fun checkPermission(): Boolean {
return try {
if (Shizuku.isPreV11()) {
false
} else {
Shizuku.checkSelfPermission() == PackageManager.PERMISSION_GRANTED
}
} catch (e: Exception) {
false
}
}
fun requestPermission() {
if (!Shizuku.isPreV11()) {
Shizuku.requestPermission(SHIZUKU_PERMISSION_REQUEST_CODE)
}
}
private fun isRunningAsRoot(): Boolean {
return try {
Shizuku.getUid() == 0
} catch (e: Exception) {
false
}
}
private fun getPackageInstaller(): IPackageInstaller {
val packageManagerBinder = SystemServiceHelper.getSystemService("package")
val packageManager = IPackageManager.Stub.asInterface(ShizukuBinderWrapper(packageManagerBinder))
val installerBinder = packageManager.packageInstaller.asBinder()
return IPackageInstaller.Stub.asInterface(ShizukuBinderWrapper(installerBinder))
}
private fun createPackageInstaller(
installer: IPackageInstaller,
installerPackageName: String,
installerAttributionTag: String?,
userId: Int
): PackageInstaller {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
return PackageInstaller::class.java
.getConstructor(
IPackageInstaller::class.java,
String::class.java,
String::class.java,
Int::class.javaPrimitiveType
)
.newInstance(installer, installerPackageName, installerAttributionTag, userId)
} else {
return PackageInstaller::class.java
.getConstructor(
IPackageInstaller::class.java,
String::class.java,
Int::class.javaPrimitiveType
)
.newInstance(installer, installerPackageName, userId)
}
}
private fun createSession(session: IPackageInstallerSession): PackageInstaller.Session {
return PackageInstaller.Session::class.java
.getConstructor(IPackageInstallerSession::class.java)
.newInstance(session)
}
private fun createIntentSender(onResult: (Intent) -> Unit): IntentSender {
val sender = object : IIntentSender.Stub() {
override fun send(
code: Int,
intent: Intent,
resolvedType: String?,
whitelistToken: android.os.IBinder?,
finishedReceiver: android.content.IIntentReceiver?,
requiredPermission: String?,
options: android.os.Bundle?
) {
onResult(intent)
}
}
return IntentSender::class.java
.getConstructor(IIntentSender::class.java)
.newInstance(sender)
}
suspend fun install(apkFile: File): Result<Unit> = withContext(Dispatchers.IO) {
try {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
HiddenApiBypass.addHiddenApiExemptions("")
}
val iPackageInstaller = getPackageInstaller()
val isRoot = isRunningAsRoot()
val installerPackageName = if (isRoot) "io.nekohasekai.sfa" else "com.android.shell"
val installerAttributionTag: String? = null
val userId = if (isRoot) Process.myUserHandle().hashCode() else 0
val packageInstaller = createPackageInstaller(
iPackageInstaller,
installerPackageName,
installerAttributionTag,
userId
)
val params = PackageInstaller.SessionParams(PackageInstaller.SessionParams.MODE_FULL_INSTALL)
val sessionId = packageInstaller.createSession(params)
val iSession = IPackageInstallerSession.Stub.asInterface(
ShizukuBinderWrapper(iPackageInstaller.openSession(sessionId).asBinder())
)
val session = createSession(iSession)
try {
FileInputStream(apkFile).use { inputStream ->
session.openWrite("base.apk", 0, apkFile.length()).use { outputStream ->
inputStream.copyTo(outputStream)
session.fsync(outputStream)
}
}
val resultIntent = arrayOfNulls<Intent>(1)
val latch = CountDownLatch(1)
val intentSender = createIntentSender { intent ->
resultIntent[0] = intent
latch.countDown()
}
session.commit(intentSender)
latch.await(60, TimeUnit.SECONDS)
val intent = resultIntent[0]
?: return@withContext Result.failure(Exception("Installation timed out"))
val status = intent.getIntExtra(PackageInstaller.EXTRA_STATUS, PackageInstaller.STATUS_FAILURE)
val message = intent.getStringExtra(PackageInstaller.EXTRA_STATUS_MESSAGE)
if (status == PackageInstaller.STATUS_SUCCESS) {
Result.success(Unit)
} else {
Result.failure(Exception("Installation failed: $status - $message"))
}
} finally {
session.close()
}
} 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()
}
}
}

View File

@@ -6,10 +6,12 @@ import android.net.Uri
import android.util.Log
import androidx.camera.core.ImageAnalysis
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import io.nekohasekai.sfa.Application
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.UpdateState
import io.nekohasekai.sfa.update.UpdateTrack
object Vendor : VendorInterface {
@@ -107,4 +109,51 @@ object Vendor : VendorInterface {
checker.checkUpdate(track)
}
}
override fun supportsSilentInstall(): Boolean {
return true
}
override fun supportsAutoUpdate(): Boolean {
return true
}
override fun scheduleAutoUpdate() {
UpdateWorker.schedule(io.nekohasekai.sfa.Application.application)
}
override suspend fun verifySilentInstallMethod(method: String): Boolean {
return when (method) {
"PACKAGE_INSTALLER" -> {
ApkInstaller.canSystemSilentInstall() &&
Application.application.packageManager.canRequestPackageInstalls()
}
"SHIZUKU" -> {
if (!ShizukuInstaller.isAvailable()) {
return false
}
if (!ShizukuInstaller.checkPermission()) {
ShizukuInstaller.requestPermission()
return false
}
true
}
"ROOT" -> RootInstaller.checkAccess()
else -> false
}
}
override suspend fun downloadAndInstall(context: android.content.Context, downloadUrl: String): Result<Unit> {
return try {
val cachedApk = UpdateState.cachedApkFile.value
val apkFile = if (cachedApk != null && cachedApk.exists() && cachedApk.length() > 0) {
cachedApk
} else {
ApkDownloader().use { it.download(downloadUrl) }
}
ApkInstaller.install(context, apkFile)
} catch (e: Exception) {
Result.failure(e)
}
}
}

View File

@@ -0,0 +1,19 @@
package io.nekohasekai.sfa.vendor.hidden;
import android.os.Binder;
import android.os.IBinder;
import android.os.IInterface;
import android.os.RemoteException;
import android.content.pm.IPackageInstaller;
public interface IPackageManager extends IInterface {
IPackageInstaller getPackageInstaller() throws RemoteException;
abstract class Stub extends Binder implements IPackageManager {
public static IPackageManager asInterface(IBinder binder) {
throw new UnsupportedOperationException();
}
}
}