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

@@ -1,29 +0,0 @@
<?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

@@ -1,9 +0,0 @@
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

@@ -1,23 +0,0 @@
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

@@ -1,21 +0,0 @@
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

@@ -1,14 +0,0 @@
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

@@ -1,43 +0,0 @@
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

@@ -1,13 +1,8 @@
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,
@@ -29,12 +24,12 @@ object ApkInstaller {
return when (method) {
InstallMethod.SHIZUKU -> ShizukuInstaller.install(apkFile)
InstallMethod.ROOT -> RootInstaller.install(apkFile)
InstallMethod.PACKAGE_INSTALLER -> installWithPackageInstaller(context, apkFile)
InstallMethod.PACKAGE_INSTALLER -> SystemPackageInstaller.install(context, apkFile)
}
}
fun canSystemSilentInstall(): Boolean {
return Build.VERSION.SDK_INT >= Build.VERSION_CODES.S
return SystemPackageInstaller.canSystemSilentInstall()
}
suspend fun canSilentInstall(): Boolean {
@@ -45,40 +40,4 @@ object ApkInstaller {
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

@@ -1,116 +0,0 @@
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,
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

@@ -1,47 +0,0 @@
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

@@ -1,39 +0,0 @@
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

@@ -1,185 +0,0 @@
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

@@ -1,97 +0,0 @@
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

@@ -1,19 +0,0 @@
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();
}
}
}