Compare commits

..
2 Commits
Author SHA1 Message Date
n3t1zen 4bfd43af1a use my keystore 2026-02-27 16:03:33 +08:00
世界 3228793f4a Bump version 2026-02-27 15:09:16 +08:00
65 changed files with 384 additions and 1689 deletions
Binary file not shown.
@@ -1,8 +1,6 @@
package io.nekohasekai.sfa.vendor package io.nekohasekai.sfa.vendor
import io.nekohasekai.libbox.HTTPResponseWriteToProgressHandler
import io.nekohasekai.libbox.Libbox import io.nekohasekai.libbox.Libbox
import io.nekohasekai.libbox.writeToWithProgress
import io.nekohasekai.sfa.Application import io.nekohasekai.sfa.Application
import io.nekohasekai.sfa.update.UpdateState import io.nekohasekai.sfa.update.UpdateState
import io.nekohasekai.sfa.utils.HTTPClient import io.nekohasekai.sfa.utils.HTTPClient
@@ -29,15 +27,7 @@ class ApkDownloader : Closeable {
request.setURL(url) request.setURL(url)
val response = request.execute() val response = request.execute()
response.writeToWithProgress( response.writeTo(apkFile.absolutePath)
apkFile.absolutePath,
object : HTTPResponseWriteToProgressHandler {
override fun update(progress: Long, total: Long) {
UpdateState.downloadProgress.value =
if (total > 0) progress.toFloat() / total.toFloat() else null
}
},
)
if (!apkFile.exists() || apkFile.length() == 0L) { if (!apkFile.exists() || apkFile.length() == 0L) {
throw Exception("Download failed: empty file") throw Exception("Download failed: empty file")
@@ -86,7 +86,9 @@ class GitHubUpdateChecker : Closeable {
} }
} }
private fun isNewerThanCurrent(versionName: String): Boolean = Libbox.compareSemver(versionName, BuildConfig.VERSION_NAME) private fun isNewerThanCurrent(versionName: String): Boolean {
return Libbox.compareSemver(versionName, BuildConfig.VERSION_NAME)
}
private fun isBetterVersion(version: VersionMetadata, other: VersionMetadata): Boolean { private fun isBetterVersion(version: VersionMetadata, other: VersionMetadata): Boolean {
if (Libbox.compareSemver(version.versionName, other.versionName)) { if (Libbox.compareSemver(version.versionName, other.versionName)) {
@@ -11,10 +11,8 @@ import androidx.work.PeriodicWorkRequestBuilder
import androidx.work.WorkManager import androidx.work.WorkManager
import androidx.work.WorkerParameters import androidx.work.WorkerParameters
import io.nekohasekai.sfa.database.Settings import io.nekohasekai.sfa.database.Settings
import io.nekohasekai.sfa.update.UpdateSource
import io.nekohasekai.sfa.update.UpdateState import io.nekohasekai.sfa.update.UpdateState
import io.nekohasekai.sfa.update.UpdateTrack import io.nekohasekai.sfa.update.UpdateTrack
import io.nekohasekai.sfa.update.checkFDroidUpdate
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
class UpdateWorker(private val appContext: Context, params: WorkerParameters) : CoroutineWorker(appContext, params) { class UpdateWorker(private val appContext: Context, params: WorkerParameters) : CoroutineWorker(appContext, params) {
@@ -61,13 +59,8 @@ class UpdateWorker(private val appContext: Context, params: WorkerParameters) :
Log.d(TAG, "Checking for updates...") Log.d(TAG, "Checking for updates...")
return try { return try {
val updateInfo = when (UpdateSource.fromString(Settings.updateSource)) {
UpdateSource.FDROID -> checkFDroidUpdate(appContext)
UpdateSource.GITHUB -> {
val track = UpdateTrack.fromString(Settings.updateTrack) val track = UpdateTrack.fromString(Settings.updateTrack)
GitHubUpdateChecker().use { it.checkUpdate(track) } val updateInfo = GitHubUpdateChecker().use { it.checkUpdate(track) }
}
}
if (updateInfo == null) { if (updateInfo == null) {
Log.d(TAG, "No update available") Log.d(TAG, "No update available")
@@ -1,27 +0,0 @@
package io.nekohasekai.libbox
class FDroidMirror(
val url: String,
val name: String,
val country: String,
)
class FDroidMirrorIterator(
private val items: List<FDroidMirror>,
) {
private var index = 0
fun hasNext(): Boolean = index < items.size
fun next(): FDroidMirror = items[index++]
}
class FDroidMirrorPingResult(
val url: String,
val latencyMs: Int,
)
class FDroidUpdateResult(
val versionCode: Int,
val versionName: String,
val downloadURL: String,
val fileSize: Long,
)
@@ -1,5 +0,0 @@
package io.nekohasekai.libbox
interface HTTPResponseWriteToProgressHandler {
fun update(progress: Long, total: Long)
}
@@ -1,34 +0,0 @@
package io.nekohasekai.libbox
fun HTTPResponse.writeToWithProgress(path: String, handler: HTTPResponseWriteToProgressHandler) {
writeTo(path)
}
fun ConnectionOwner.setAndroidPackageNames(names: StringIterator) {
if (names.hasNext()) {
androidPackageName = names.next()
}
}
fun ProcessInfo.packageNames(): StringIterator? {
val name = packageName ?: return null
if (name.isEmpty()) return null
return object : StringIterator {
private var consumed = false
override fun len(): Int = 1
override fun hasNext(): Boolean = !consumed
override fun next(): String { consumed = true; return name }
}
}
fun getFDroidMirrors(): FDroidMirrorIterator {
return FDroidMirrorIterator(emptyList())
}
fun pingFDroidMirror(url: String): FDroidMirrorPingResult {
return FDroidMirrorPingResult(url, -1)
}
fun checkFDroidUpdate(mirrorUrl: String, packageName: String, versionCode: Int, cachePath: String): FDroidUpdateResult? {
return null
}
@@ -1,3 +0,0 @@
package io.nekohasekai.libbox
interface NeighborUpdateListener
@@ -162,6 +162,7 @@ class BoxService(private val service: Service, private val platformInterface: Pl
android.Manifest.permission.ACCESS_BACKGROUND_LOCATION android.Manifest.permission.ACCESS_BACKGROUND_LOCATION
} }
if (!service.hasPermission(wifiPermission)) { if (!service.hasPermission(wifiPermission)) {
closeService()
stopAndAlert(Alert.RequestLocationPermission) stopAndAlert(Alert.RequestLocationPermission)
return return
} }
@@ -242,6 +243,7 @@ class BoxService(private val service: Service, private val platformInterface: Pl
android.Manifest.permission.ACCESS_BACKGROUND_LOCATION android.Manifest.permission.ACCESS_BACKGROUND_LOCATION
} }
if (!service.hasPermission(wifiPermission)) { if (!service.hasPermission(wifiPermission)) {
closeService()
stopAndAlert(Alert.RequestLocationPermission) stopAndAlert(Alert.RequestLocationPermission)
return return
} }
@@ -309,16 +311,6 @@ class BoxService(private val service: Service, private val platformInterface: Pl
private suspend fun stopAndAlert(type: Alert, message: String? = null) { private suspend fun stopAndAlert(type: Alert, message: String? = null) {
Settings.startedByUser = false Settings.startedByUser = false
val pfd = fileDescriptor
if (pfd != null) {
pfd.close()
fileDescriptor = null
}
DefaultNetworkMonitor.stop()
if (::commandServer.isInitialized) {
closeService()
commandServer.close()
}
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
if (receiverRegistered) { if (receiverRegistered) {
service.unregisterReceiver(receiver) service.unregisterReceiver(receiver)
@@ -329,7 +321,6 @@ class BoxService(private val service: Service, private val platformInterface: Pl
callback.onServiceAlert(type.ordinal, message) callback.onServiceAlert(type.ordinal, message)
} }
status.value = Status.Stopped status.value = Status.Stopped
service.stopSelf()
} }
} }
@@ -13,13 +13,11 @@ import java.io.StringWriter
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
import java.util.Date import java.util.Date
import java.util.Locale import java.util.Locale
import java.util.zip.Deflater
import java.util.zip.ZipEntry import java.util.zip.ZipEntry
import java.util.zip.ZipOutputStream import java.util.zip.ZipOutputStream
object DebugInfoExporter { object DebugInfoExporter {
private const val TAG = "DebugInfoExporter" private const val TAG = "DebugInfoExporter"
private const val BUFFER_SIZE = 128 * 1024
fun export(context: Context, outputPath: String, packageName: String): String { fun export(context: Context, outputPath: String, packageName: String): String {
Log.i(TAG, "export start: output=$outputPath, package=$packageName") Log.i(TAG, "export start: output=$outputPath, package=$packageName")
@@ -96,27 +94,43 @@ object DebugInfoExporter {
private fun addFrameworkEntries(zip: ZipOutputStream, warnings: MutableList<String>): Int { private fun addFrameworkEntries(zip: ZipOutputStream, warnings: MutableList<String>): Int {
var count = 0 var count = 0
val root = File("/system/framework") val roots =
if (!root.isDirectory) return 0 listOf(
File("/system/framework"),
File("/system_ext/framework"),
File("/product/framework"),
File("/vendor/framework"),
)
val targetFiles = setOf("framework.jar", "services.jar") val targetFiles = setOf("framework.jar", "services.jar")
for (root in roots) {
if (!root.isDirectory) continue
val destPrefix = "framework/${root.name}"
val files = root.listFiles() ?: emptyArray() val files = root.listFiles() ?: emptyArray()
for (file in files) { for (file in files) {
if (!file.isFile) continue if (!file.isFile) continue
if (file.name !in targetFiles) continue if (file.name !in targetFiles) continue
if (addFileEntry(zip, file, "framework/${file.name}", warnings, noCompression = true)) { if (addFileEntry(zip, file, "$destPrefix/${file.name}", warnings)) {
count++ count++
} }
} }
}
return count return count
} }
private fun addApexEntries(zip: ZipOutputStream, warnings: MutableList<String>): Int { private fun addApexEntries(zip: ZipOutputStream, warnings: MutableList<String>): Int {
val file = File("/apex/com.android.tethering/javalib/service-connectivity.jar") var count = 0
if (!file.isFile) { val tetheringApex = File("/apex/com.android.tethering/javalib")
warnings.add("missing file: ${file.path}") if (!tetheringApex.isDirectory) return 0
return 0 val destPrefix = "framework/apex_com.android.tethering"
val files = tetheringApex.listFiles() ?: emptyArray()
for (file in files) {
if (!file.isFile) continue
if (!file.name.lowercase(Locale.US).endsWith(".jar")) continue
if (addFileEntry(zip, file, "$destPrefix/${file.name}", warnings)) {
count++
} }
return if (addFileEntry(zip, file, "framework/apex_com.android.tethering/service-connectivity.jar", warnings, noCompression = true)) 1 else 0 }
return count
} }
private fun addLogEntries(zip: ZipOutputStream, warnings: MutableList<String>, context: Context): Int { private fun addLogEntries(zip: ZipOutputStream, warnings: MutableList<String>, context: Context): Int {
@@ -208,22 +222,16 @@ object DebugInfoExporter {
return count return count
} }
private fun addFileEntry( private fun addFileEntry(zip: ZipOutputStream, file: File, entryName: String, warnings: MutableList<String>): Boolean {
zip: ZipOutputStream,
file: File,
entryName: String,
warnings: MutableList<String>,
noCompression: Boolean = false,
): Boolean {
if (!file.isFile) { if (!file.isFile) {
warnings.add("missing file: ${file.path}") warnings.add("missing file: ${file.path}")
return false return false
} }
try { try {
if (noCompression) zip.setLevel(Deflater.NO_COMPRESSION) val entry = ZipEntry(entryName)
zip.putNextEntry(ZipEntry(entryName)) zip.putNextEntry(entry)
BufferedInputStream(FileInputStream(file)).use { input -> BufferedInputStream(FileInputStream(file)).use { input ->
val buffer = ByteArray(BUFFER_SIZE) val buffer = ByteArray(16 * 1024)
while (true) { while (true) {
val read = input.read(buffer) val read = input.read(buffer)
if (read <= 0) break if (read <= 0) break
@@ -231,11 +239,9 @@ object DebugInfoExporter {
} }
} }
zip.closeEntry() zip.closeEntry()
if (noCompression) zip.setLevel(Deflater.DEFAULT_COMPRESSION)
return true return true
} catch (e: Throwable) { } catch (e: Throwable) {
warnings.add("zip failed ${file.path}: ${e.message}") warnings.add("zip failed ${file.path}: ${e.message}")
if (noCompression) zip.setLevel(Deflater.DEFAULT_COMPRESSION)
return false return false
} }
} }
@@ -257,10 +263,11 @@ object DebugInfoExporter {
command: List<String>, command: List<String>,
): CommandResult? = try { ): CommandResult? = try {
val process = ProcessBuilder(command).redirectErrorStream(true).start() val process = ProcessBuilder(command).redirectErrorStream(true).start()
zip.putNextEntry(ZipEntry(entryName)) val entry = ZipEntry(entryName)
zip.putNextEntry(entry)
var bytes = 0L var bytes = 0L
process.inputStream.use { input -> process.inputStream.use { input ->
val buffer = ByteArray(BUFFER_SIZE) val buffer = ByteArray(16 * 1024)
while (true) { while (true) {
val read = input.read(buffer) val read = input.read(buffer)
if (read <= 0) break if (read <= 0) break
@@ -43,20 +43,17 @@ object DefaultNetworkMonitor {
private fun checkDefaultInterfaceUpdate(newNetwork: Network?) { private fun checkDefaultInterfaceUpdate(newNetwork: Network?) {
val listener = listener ?: return val listener = listener ?: return
if (newNetwork != null) { if (newNetwork != null) {
val interfaceName =
(Application.connectivity.getLinkProperties(newNetwork) ?: return).interfaceName
for (times in 0 until 10) { for (times in 0 until 10) {
val linkProperties = Application.connectivity.getLinkProperties(newNetwork)
if (linkProperties == null) {
Thread.sleep(100)
continue
}
var interfaceIndex: Int var interfaceIndex: Int
try { try {
interfaceIndex = NetworkInterface.getByName(linkProperties.interfaceName).index interfaceIndex = NetworkInterface.getByName(interfaceName).index
} catch (e: Exception) { } catch (e: Exception) {
Thread.sleep(100) Thread.sleep(100)
continue continue
} }
listener.updateDefaultInterface(linkProperties.interfaceName, interfaceIndex, false, false) listener.updateDefaultInterface(interfaceName, interfaceIndex, false, false)
} }
} else { } else {
listener.updateDefaultInterface("", -1, false, false) listener.updateDefaultInterface("", -1, false, false)
@@ -23,8 +23,8 @@ object LocalResolver : LocalDNSTransport {
@RequiresApi(Build.VERSION_CODES.Q) @RequiresApi(Build.VERSION_CODES.Q)
override fun exchange(ctx: ExchangeContext, message: ByteArray) { override fun exchange(ctx: ExchangeContext, message: ByteArray) {
val defaultNetwork = DefaultNetworkMonitor.defaultNetwork ?: error("missing default interface")
return runBlocking { return runBlocking {
val defaultNetwork = DefaultNetworkMonitor.require()
suspendCoroutine { continuation -> suspendCoroutine { continuation ->
val signal = CancellationSignal() val signal = CancellationSignal()
ctx.onCancel(signal::cancel) ctx.onCancel(signal::cancel)
@@ -63,8 +63,8 @@ object LocalResolver : LocalDNSTransport {
} }
override fun lookup(ctx: ExchangeContext, network: String, domain: String) { override fun lookup(ctx: ExchangeContext, network: String, domain: String) {
val defaultNetwork = DefaultNetworkMonitor.defaultNetwork ?: error("missing default interface")
return runBlocking { return runBlocking {
val defaultNetwork = DefaultNetworkMonitor.require()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
suspendCoroutine { continuation -> suspendCoroutine { continuation ->
val signal = CancellationSignal() val signal = CancellationSignal()
@@ -136,7 +136,7 @@ public class ParceledListSlice<T extends Parcelable> implements Parcelable {
new Parcelable.ClassLoaderCreator<ParceledListSlice>() { new Parcelable.ClassLoaderCreator<ParceledListSlice>() {
@Override @Override
public ParceledListSlice createFromParcel(Parcel in) { public ParceledListSlice createFromParcel(Parcel in) {
return new ParceledListSlice(in, ParceledListSlice.class.getClassLoader()); return new ParceledListSlice(in, null);
} }
@Override @Override
@@ -16,7 +16,6 @@ import io.nekohasekai.libbox.PlatformInterface
import io.nekohasekai.libbox.StringIterator import io.nekohasekai.libbox.StringIterator
import io.nekohasekai.libbox.TunOptions import io.nekohasekai.libbox.TunOptions
import io.nekohasekai.libbox.WIFIState import io.nekohasekai.libbox.WIFIState
import io.nekohasekai.libbox.setAndroidPackageNames
import io.nekohasekai.sfa.Application import io.nekohasekai.sfa.Application
import java.net.Inet6Address import java.net.Inet6Address
import java.net.InetSocketAddress import java.net.InetSocketAddress
@@ -59,7 +58,7 @@ interface PlatformInterfaceWrapper : PlatformInterface {
val owner = ConnectionOwner() val owner = ConnectionOwner()
owner.userId = uid owner.userId = uid
owner.userName = packages?.firstOrNull() ?: "" owner.userName = packages?.firstOrNull() ?: ""
owner.setAndroidPackageNames(StringArray(packages?.toList()?.iterator() ?: emptyList<String>().iterator())) owner.androidPackageName = packages?.firstOrNull() ?: ""
return owner return owner
} catch (e: Exception) { } catch (e: Exception) {
Log.e("PlatformInterface", "getConnectionOwnerUid", e) Log.e("PlatformInterface", "getConnectionOwnerUid", e)
@@ -2,7 +2,6 @@ package io.nekohasekai.sfa.bg
import android.app.Service import android.app.Service
import android.content.Intent import android.content.Intent
import io.nekohasekai.libbox.NeighborUpdateListener
import io.nekohasekai.libbox.Notification import io.nekohasekai.libbox.Notification
class ProxyService : class ProxyService :
@@ -15,10 +14,6 @@ class ProxyService :
override fun onBind(intent: Intent) = service.onBind() override fun onBind(intent: Intent) = service.onBind()
override fun onDestroy() = service.onDestroy() override fun onDestroy() = service.onDestroy()
fun closeNeighborMonitor(listener: NeighborUpdateListener?) {
}
override fun sendNotification(notification: Notification) = service.sendNotification(notification) override fun sendNotification(notification: Notification) = service.sendNotification(notification)
fun startNeighborMonitor(listener: NeighborUpdateListener?) {
}
} }
@@ -6,7 +6,6 @@ import android.content.ServiceConnection
import android.content.pm.PackageInfo import android.content.pm.PackageInfo
import android.os.IBinder import android.os.IBinder
import android.os.RemoteException import android.os.RemoteException
import androidx.core.content.ContextCompat
import com.topjohnwu.superuser.Shell import com.topjohnwu.superuser.Shell
import com.topjohnwu.superuser.ipc.RootService import com.topjohnwu.superuser.ipc.RootService
import io.nekohasekai.sfa.Application import io.nekohasekai.sfa.Application
@@ -18,9 +17,7 @@ import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import java.io.IOException
import kotlin.coroutines.resume import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
object RootClient { object RootClient {
init { init {
@@ -56,10 +53,6 @@ object RootClient {
suspend fun bindService(): IRootService = connectionMutex.withLock { suspend fun bindService(): IRootService = connectionMutex.withLock {
service?.let { return it } service?.let { return it }
if (Shell.isAppGrantedRoot() == false) {
throw IOException("permission denied")
}
return withContext(Dispatchers.Main) { return withContext(Dispatchers.Main) {
suspendCancellableCoroutine { continuation -> suspendCancellableCoroutine { continuation ->
val conn = object : ServiceConnection { val conn = object : ServiceConnection {
@@ -79,30 +72,7 @@ object RootClient {
} }
val intent = Intent(Application.application, RootServer::class.java) val intent = Intent(Application.application, RootServer::class.java)
val task = RootService.bindOrTask( RootService.bind(intent, conn)
intent,
ContextCompat.getMainExecutor(Application.application),
conn,
)
if (task == null) {
// Already connected, onServiceConnected will fire
} else {
Shell.EXECUTOR.execute {
try {
val shell = Shell.getShell()
if (shell.isRoot) {
shell.execTask(task)
} else {
continuation.resumeWithException(
IOException("permission denied"),
)
}
} catch (e: Exception) {
continuation.resumeWithException(e)
}
}
}
continuation.invokeOnCancellation { continuation.invokeOnCancellation {
RootService.unbind(conn) RootService.unbind(conn)
@@ -7,7 +7,6 @@ import android.net.VpnService
import android.os.Build import android.os.Build
import android.os.IBinder import android.os.IBinder
import android.util.Log import android.util.Log
import io.nekohasekai.libbox.NeighborUpdateListener
import io.nekohasekai.libbox.Notification import io.nekohasekai.libbox.Notification
import io.nekohasekai.libbox.TunOptions import io.nekohasekai.libbox.TunOptions
import io.nekohasekai.sfa.database.Settings import io.nekohasekai.sfa.database.Settings
@@ -52,9 +51,6 @@ class VPNService :
protect(fd) protect(fd)
} }
fun closeNeighborMonitor(listener: NeighborUpdateListener?) {
}
var systemProxyAvailable = false var systemProxyAvailable = false
var systemProxyEnabled = false var systemProxyEnabled = false
@@ -70,10 +66,6 @@ class VPNService :
builder.setMetered(false) builder.setMetered(false)
} }
if (Settings.allowBypass) {
builder.allowBypass()
}
val inet4Address = options.inet4Address val inet4Address = options.inet4Address
while (inet4Address.hasNext()) { while (inet4Address.hasNext()) {
val address = inet4Address.next() val address = inet4Address.next()
@@ -186,6 +178,4 @@ class VPNService :
} }
override fun sendNotification(notification: Notification) = service.sendNotification(notification) override fun sendNotification(notification: Notification) = service.sendNotification(notification)
fun startNeighborMonitor(listener: NeighborUpdateListener?) {
}
} }
@@ -8,7 +8,6 @@ import android.net.Uri
import android.net.VpnService import android.net.VpnService
import android.os.Build import android.os.Build
import android.os.Bundle import android.os.Bundle
import androidx.activity.compose.BackHandler
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge import androidx.activity.enableEdgeToEdge
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
@@ -43,7 +42,6 @@ import androidx.compose.material3.ExtendedFloatingActionButton
import androidx.compose.material3.FloatingActionButton import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.NavigationBar import androidx.compose.material3.NavigationBar
@@ -115,7 +113,6 @@ import io.nekohasekai.sfa.compose.theme.SFATheme
import io.nekohasekai.sfa.compose.topbar.LocalTopBarController import io.nekohasekai.sfa.compose.topbar.LocalTopBarController
import io.nekohasekai.sfa.compose.topbar.TopBarController import io.nekohasekai.sfa.compose.topbar.TopBarController
import io.nekohasekai.sfa.compose.topbar.TopBarEntry import io.nekohasekai.sfa.compose.topbar.TopBarEntry
import io.nekohasekai.sfa.constant.Action
import io.nekohasekai.sfa.constant.Alert import io.nekohasekai.sfa.constant.Alert
import io.nekohasekai.sfa.constant.ServiceMode import io.nekohasekai.sfa.constant.ServiceMode
import io.nekohasekai.sfa.constant.Status import io.nekohasekai.sfa.constant.Status
@@ -228,10 +225,6 @@ class MainActivity :
pendingNavigationRoute.value = "settings/privilege" pendingNavigationRoute.value = "settings/privilege"
} }
val uri = intent.data ?: return val uri = intent.data ?: return
if (intent.action == Action.OPEN_URL) {
launchCustomTab(uri.toString())
return
}
if (uri.scheme == "sing-box" && uri.host == "import-remote-profile") { if (uri.scheme == "sing-box" && uri.host == "import-remote-profile") {
try { try {
val profile = Libbox.parseRemoteProfileImportLink(uri.toString()) val profile = Libbox.parseRemoteProfileImportLink(uri.toString())
@@ -572,23 +565,11 @@ class MainActivity :
color = MaterialTheme.colorScheme.error, color = MaterialTheme.colorScheme.error,
) )
} else { } else {
val progress by UpdateState.downloadProgress Row(verticalAlignment = Alignment.CenterVertically) {
Column { CircularProgressIndicator(modifier = Modifier.size(24.dp))
if (progress != null) { Spacer(modifier = Modifier.width(12.dp))
Text("${stringResource(R.string.downloading)} ${(progress!! * 100).toInt()}%")
} else {
Text(stringResource(R.string.downloading)) Text(stringResource(R.string.downloading))
} }
Spacer(modifier = Modifier.height(8.dp))
if (progress != null) {
LinearProgressIndicator(
progress = { progress!! },
modifier = Modifier.fillMaxWidth(),
)
} else {
LinearProgressIndicator(modifier = Modifier.fillMaxWidth())
}
}
} }
} }
}, },
@@ -599,7 +580,6 @@ class MainActivity :
downloadJob = null downloadJob = null
showDownloadDialog = false showDownloadDialog = false
downloadError = null downloadError = null
UpdateState.downloadProgress.value = null
}, },
) { ) {
Text(stringResource(if (downloadError != null) R.string.ok else android.R.string.cancel)) Text(stringResource(if (downloadError != null) R.string.ok else android.R.string.cancel))
@@ -1108,10 +1088,6 @@ class MainActivity :
} }
} }
BackHandler(enabled = selectedConnectionId != null) {
selectedConnectionId = null
}
ModalBottomSheet( ModalBottomSheet(
onDismissRequest = { onDismissRequest = {
showConnectionsSheet = false showConnectionsSheet = false
@@ -1,13 +1,12 @@
package io.nekohasekai.sfa.compose.model package io.nekohasekai.sfa.compose.model
import androidx.compose.runtime.Immutable import androidx.compose.runtime.Immutable
import io.nekohasekai.libbox.packageNames
import io.nekohasekai.sfa.ktx.toList import io.nekohasekai.sfa.ktx.toList
import io.nekohasekai.libbox.Connection as LibboxConnection import io.nekohasekai.libbox.Connection as LibboxConnection
import io.nekohasekai.libbox.ProcessInfo as LibboxProcessInfo import io.nekohasekai.libbox.ProcessInfo as LibboxProcessInfo
@Immutable @Immutable
data class ProcessInfo(val processId: Long, val userId: Int, val userName: String, val processPath: String, val packageNames: List<String>) { data class ProcessInfo(val processId: Long, val userId: Int, val userName: String, val processPath: String, val packageName: String) {
companion object { companion object {
fun from(processInfo: LibboxProcessInfo?): ProcessInfo? { fun from(processInfo: LibboxProcessInfo?): ProcessInfo? {
if (processInfo == null) return null if (processInfo == null) return null
@@ -16,7 +15,7 @@ data class ProcessInfo(val processId: Long, val userId: Int, val userName: Strin
userId = processInfo.userID, userId = processInfo.userID,
userName = processInfo.userName ?: "", userName = processInfo.userName ?: "",
processPath = processInfo.processPath ?: "", processPath = processInfo.processPath ?: "",
packageNames = processInfo.packageNames()?.toList() ?: emptyList(), packageName = processInfo.packageName ?: "",
) )
} }
} }
@@ -67,7 +66,7 @@ data class Connection(
domain.contains(content, ignoreCase = true) || domain.contains(content, ignoreCase = true) ||
outbound.contains(content, ignoreCase = true) || outbound.contains(content, ignoreCase = true) ||
rule.contains(content, ignoreCase = true) || rule.contains(content, ignoreCase = true) ||
processInfo?.packageNames?.any { it.contains(content, ignoreCase = true) } == true processInfo?.packageName?.contains(content, ignoreCase = true) == true
private fun performSearchType(type: String, value: String): Boolean = when (type) { private fun performSearchType(type: String, value: String): Boolean = when (type) {
"network" -> network.equals(value, ignoreCase = true) "network" -> network.equals(value, ignoreCase = true)
@@ -80,7 +79,7 @@ data class Connection(
"rule" -> rule.contains(value, ignoreCase = true) "rule" -> rule.contains(value, ignoreCase = true)
"protocol" -> protocolName.equals(value, ignoreCase = true) "protocol" -> protocolName.equals(value, ignoreCase = true)
"user" -> user.contains(value, ignoreCase = true) "user" -> user.contains(value, ignoreCase = true)
"package" -> processInfo?.packageNames?.any { it.contains(value, ignoreCase = true) } == true "package" -> processInfo?.packageName?.contains(value, ignoreCase = true) == true
"chain" -> chain.any { it.contains(value, ignoreCase = true) } "chain" -> chain.any { it.contains(value, ignoreCase = true) }
else -> false else -> false
} }
@@ -28,7 +28,6 @@ import io.nekohasekai.sfa.compose.screen.profile.EditProfileRoute
import io.nekohasekai.sfa.compose.screen.profileoverride.PerAppProxyScreen import io.nekohasekai.sfa.compose.screen.profileoverride.PerAppProxyScreen
import io.nekohasekai.sfa.compose.screen.settings.AppSettingsScreen import io.nekohasekai.sfa.compose.screen.settings.AppSettingsScreen
import io.nekohasekai.sfa.compose.screen.settings.CoreSettingsScreen import io.nekohasekai.sfa.compose.screen.settings.CoreSettingsScreen
import io.nekohasekai.sfa.compose.screen.settings.FDroidMirrorScreen
import io.nekohasekai.sfa.compose.screen.settings.PrivilegeSettingsScreen import io.nekohasekai.sfa.compose.screen.settings.PrivilegeSettingsScreen
import io.nekohasekai.sfa.compose.screen.settings.ProfileOverrideScreen import io.nekohasekai.sfa.compose.screen.settings.ProfileOverrideScreen
import io.nekohasekai.sfa.compose.screen.settings.ServiceSettingsScreen import io.nekohasekai.sfa.compose.screen.settings.ServiceSettingsScreen
@@ -225,16 +224,6 @@ fun SFANavHost(
AppSettingsScreen(navController = navController) AppSettingsScreen(navController = navController)
} }
composable(
route = "settings/fdroid_mirror",
enterTransition = slideInFromRight,
exitTransition = slideOutToLeft,
popEnterTransition = slideInFromLeft,
popExitTransition = slideOutToRight,
) {
FDroidMirrorScreen(navController = navController)
}
composable( composable(
route = "settings/core", route = "settings/core",
enterTransition = slideInFromRight, enterTransition = slideInFromRight,
@@ -241,9 +241,8 @@ class ProfileImportHandler(private val context: Context) {
} }
// Save config file // Save config file
val fileID = ProfileManager.nextFileID()
val configDirectory = File(context.filesDir, "configs").also { it.mkdirs() } val configDirectory = File(context.filesDir, "configs").also { it.mkdirs() }
val configFile = File(configDirectory, "$fileID.json") val configFile = File(configDirectory, "${profile.userOrder}.json")
configFile.writeText(content.config) configFile.writeText(content.config)
typedProfile.path = configFile.path typedProfile.path = configFile.path
@@ -269,9 +268,8 @@ class ProfileImportHandler(private val context: Context) {
} }
// Create empty config file for remote profile // Create empty config file for remote profile
val fileID = ProfileManager.nextFileID()
val configDirectory = File(context.filesDir, "configs").also { it.mkdirs() } val configDirectory = File(context.filesDir, "configs").also { it.mkdirs() }
val configFile = File(configDirectory, "$fileID.json") val configFile = File(configDirectory, "${profile.userOrder}.json")
configFile.writeText("{}") configFile.writeText("{}")
typedProfile.path = configFile.path typedProfile.path = configFile.path
@@ -372,9 +370,8 @@ class ProfileImportHandler(private val context: Context) {
} }
// Save the configuration file // Save the configuration file
val fileID = ProfileManager.nextFileID()
val configDirectory = File(context.filesDir, "configs").also { it.mkdirs() } val configDirectory = File(context.filesDir, "configs").also { it.mkdirs() }
val configFile = File(configDirectory, "$fileID.json") val configFile = File(configDirectory, "${profile.userOrder}.json")
configFile.writeText(jsonContent) configFile.writeText(jsonContent)
typedProfile.path = configFile.path typedProfile.path = configFile.path
@@ -247,7 +247,7 @@ fun ConnectionDetailsScreen(
} }
connection.processInfo?.let { processInfo -> connection.processInfo?.let { processInfo ->
if (processInfo.packageNames.isNotEmpty() || if (processInfo.packageName.isNotEmpty() ||
processInfo.processPath.isNotEmpty() || processInfo.processPath.isNotEmpty() ||
processInfo.processId > 0 processInfo.processId > 0
) { ) {
@@ -282,10 +282,10 @@ fun ConnectionDetailsScreen(
monospace = true, monospace = true,
) )
} }
if (processInfo.packageNames.isNotEmpty()) { if (processInfo.packageName.isNotEmpty()) {
DetailRow( DetailRow(
label = stringResource(R.string.connection_package_name), label = stringResource(R.string.connection_package_name),
value = processInfo.packageNames.joinToString(", "), value = processInfo.packageName,
monospace = true, monospace = true,
) )
} }
@@ -82,7 +82,7 @@ private fun rememberAppInfo(packageName: String): AppInfo? {
@Composable @Composable
fun ConnectionItem(connection: Connection, onClick: () -> Unit, onClose: () -> Unit, modifier: Modifier = Modifier) { fun ConnectionItem(connection: Connection, onClick: () -> Unit, onClose: () -> Unit, modifier: Modifier = Modifier) {
var showContextMenu by remember { mutableStateOf(false) } var showContextMenu by remember { mutableStateOf(false) }
val packageName = connection.processInfo?.packageNames?.firstOrNull() val packageName = connection.processInfo?.packageName?.takeIf { it.isNotEmpty() }
val appInfo = packageName?.let { rememberAppInfo(it) } val appInfo = packageName?.let { rememberAppInfo(it) }
Box(modifier = modifier) { Box(modifier = modifier) {
@@ -200,7 +200,7 @@ class DashboardViewModel :
private fun checkDeprecatedNotes() { private fun checkDeprecatedNotes() {
viewModelScope.launch(Dispatchers.IO) { viewModelScope.launch(Dispatchers.IO) {
runCatching { try {
// Check if deprecated warnings are disabled // Check if deprecated warnings are disabled
if (Settings.disableDeprecatedWarnings) { if (Settings.disableDeprecatedWarnings) {
return@launch return@launch
@@ -227,6 +227,8 @@ class DashboardViewModel :
} }
} }
} }
} catch (e: Exception) {
sendError(e)
} }
} }
} }
@@ -42,6 +42,7 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.ui.graphics.lerp
import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
@@ -51,7 +52,6 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.lerp
import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
@@ -19,8 +19,8 @@ import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.lerp import androidx.compose.ui.graphics.lerp
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
@@ -81,20 +81,16 @@ class LogViewModel :
override fun setDefaultLogLevel(level: Int) { override fun setDefaultLogLevel(level: Int) {
val logLevel = LogLevel.entries.find { it.priority == level } ?: error("Unknown log level: $level") val logLevel = LogLevel.entries.find { it.priority == level } ?: error("Unknown log level: $level")
viewModelScope.launch(Dispatchers.Main) {
_uiState.update { it.copy(defaultLogLevel = logLevel) } _uiState.update { it.copy(defaultLogLevel = logLevel) }
updateDisplayedLogs() updateDisplayedLogs()
} }
}
override fun clearLogs() { override fun clearLogs() {
viewModelScope.launch(Dispatchers.Main) {
allLogs.clear() allLogs.clear()
bufferedLogs.clear() bufferedLogs.clear()
_uiState.update { it.copy(isPaused = false) } _uiState.update { it.copy(isPaused = false) }
updateDisplayedLogs() updateDisplayedLogs()
} }
}
override fun requestClearLogs() { override fun requestClearLogs() {
viewModelScope.launch { viewModelScope.launch {
@@ -108,7 +104,6 @@ class LogViewModel :
override fun appendLogs(message: List<LogEntry>) { override fun appendLogs(message: List<LogEntry>) {
val processedLogs = message.map { processLogEntry(it) } val processedLogs = message.map { processLogEntry(it) }
viewModelScope.launch(Dispatchers.Main) {
if (_uiState.value.isPaused) { if (_uiState.value.isPaused) {
bufferedLogs.addAll(processedLogs) bufferedLogs.addAll(processedLogs)
} else { } else {
@@ -129,7 +124,6 @@ class LogViewModel :
} }
} }
} }
}
override fun togglePause() { override fun togglePause() {
val currentState = _uiState.value val currentState = _uiState.value
@@ -92,11 +92,12 @@ fun EditProfileContentScreen(
profileId: Long, profileId: Long,
onNavigateBack: () -> Unit, onNavigateBack: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
profileName: String = "",
isReadOnly: Boolean = false, isReadOnly: Boolean = false,
) { ) {
val viewModel: EditProfileContentViewModel = val viewModel: EditProfileContentViewModel =
viewModel( viewModel(
factory = EditProfileContentViewModel.Factory(profileId, isReadOnly), factory = EditProfileContentViewModel.Factory(profileId, profileName, isReadOnly),
) )
val uiState by viewModel.uiState.collectAsState() val uiState by viewModel.uiState.collectAsState()
val context = LocalContext.current val context = LocalContext.current
@@ -38,10 +38,11 @@ data class EditProfileContentUiState(
val profileName: String = "", // Add profile name val profileName: String = "", // Add profile name
) )
class EditProfileContentViewModel(private val profileId: Long, initialIsReadOnly: Boolean = false) : ViewModel() { class EditProfileContentViewModel(private val profileId: Long, initialProfileName: String = "", initialIsReadOnly: Boolean = false) : ViewModel() {
private val _uiState = private val _uiState =
MutableStateFlow( MutableStateFlow(
EditProfileContentUiState( EditProfileContentUiState(
profileName = initialProfileName,
isReadOnly = initialIsReadOnly, isReadOnly = initialIsReadOnly,
), ),
) )
@@ -210,7 +211,7 @@ class EditProfileContentViewModel(private val profileId: Long, initialIsReadOnly
originalContent = content, originalContent = content,
hasUnsavedChanges = false, hasUnsavedChanges = false,
isLoading = false, isLoading = false,
profileName = loadedProfile.name, // Keep profileName and isReadOnly from initial state - no need to update
) )
} }
} }
@@ -583,12 +584,13 @@ class EditProfileContentViewModel(private val profileId: Long, initialIsReadOnly
class Factory( class Factory(
private val profileId: Long, private val profileId: Long,
private val initialProfileName: String = "",
private val initialIsReadOnly: Boolean = false, private val initialIsReadOnly: Boolean = false,
) : ViewModelProvider.Factory { ) : ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST") @Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(modelClass: Class<T>): T { override fun <T : ViewModel> create(modelClass: Class<T>): T {
if (modelClass.isAssignableFrom(EditProfileContentViewModel::class.java)) { if (modelClass.isAssignableFrom(EditProfileContentViewModel::class.java)) {
return EditProfileContentViewModel(profileId, initialIsReadOnly) as T return EditProfileContentViewModel(profileId, initialProfileName, initialIsReadOnly) as T
} }
throw IllegalArgumentException("Unknown ViewModel class") throw IllegalArgumentException("Unknown ViewModel class")
} }
@@ -1,6 +1,5 @@
package io.nekohasekai.sfa.compose.screen.profile package io.nekohasekai.sfa.compose.screen.profile
import android.net.Uri
import androidx.compose.animation.AnimatedContentTransitionScope import androidx.compose.animation.AnimatedContentTransitionScope
import androidx.compose.animation.core.tween import androidx.compose.animation.core.tween
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@@ -65,12 +64,12 @@ fun EditProfileRoute(profileId: Long, onNavigateBack: () -> Unit, modifier: Modi
profileId = profileId, profileId = profileId,
onNavigateBack = onNavigateBack, onNavigateBack = onNavigateBack,
onNavigateToIconSelection = { currentIconId -> onNavigateToIconSelection = { currentIconId ->
navController.navigate("icon_selection/${Uri.encode(currentIconId ?: "null")}") { navController.navigate("icon_selection/${currentIconId ?: "null"}") {
launchSingleTop = true launchSingleTop = true
} }
}, },
onNavigateToEditContent = { isReadOnly -> onNavigateToEditContent = { profileName, isReadOnly ->
navController.navigate("edit_content/$isReadOnly") { navController.navigate("edit_content/$profileName/$isReadOnly") {
launchSingleTop = true launchSingleTop = true
} }
}, },
@@ -129,9 +128,13 @@ fun EditProfileRoute(profileId: Long, onNavigateBack: () -> Unit, modifier: Modi
} }
composable( composable(
route = "edit_content/{isReadOnly}", route = "edit_content/{profileName}/{isReadOnly}",
arguments = arguments =
listOf( listOf(
navArgument("profileName") {
type = NavType.StringType
defaultValue = ""
},
navArgument("isReadOnly") { navArgument("isReadOnly") {
type = NavType.BoolType type = NavType.BoolType
defaultValue = false defaultValue = false
@@ -162,6 +165,7 @@ fun EditProfileRoute(profileId: Long, onNavigateBack: () -> Unit, modifier: Modi
) )
}, },
) { backStackEntry -> ) { backStackEntry ->
val profileName = backStackEntry.arguments?.getString("profileName") ?: ""
val isReadOnly = backStackEntry.arguments?.getBoolean("isReadOnly") ?: false val isReadOnly = backStackEntry.arguments?.getBoolean("isReadOnly") ?: false
EditProfileContentScreen( EditProfileContentScreen(
@@ -169,6 +173,7 @@ fun EditProfileRoute(profileId: Long, onNavigateBack: () -> Unit, modifier: Modi
onNavigateBack = { onNavigateBack = {
navController.popBackStack("edit_profile", inclusive = false) navController.popBackStack("edit_profile", inclusive = false)
}, },
profileName = profileName,
isReadOnly = isReadOnly, isReadOnly = isReadOnly,
) )
} }
@@ -79,7 +79,7 @@ fun EditProfileScreen(
profileId: Long, profileId: Long,
onNavigateBack: () -> Unit, onNavigateBack: () -> Unit,
onNavigateToIconSelection: (currentIconId: String?) -> Unit = {}, onNavigateToIconSelection: (currentIconId: String?) -> Unit = {},
onNavigateToEditContent: (isReadOnly: Boolean) -> Unit = {}, onNavigateToEditContent: (profileName: String, isReadOnly: Boolean) -> Unit = { _, _ -> },
viewModel: EditProfileViewModel = viewModel(), viewModel: EditProfileViewModel = viewModel(),
) { ) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle() val uiState by viewModel.uiState.collectAsStateWithLifecycle()
@@ -473,6 +473,7 @@ fun EditProfileScreen(
.clip(RoundedCornerShape(12.dp)) .clip(RoundedCornerShape(12.dp))
.clickable { .clickable {
onNavigateToEditContent( onNavigateToEditContent(
uiState.name,
uiState.profileType == TypedProfile.Type.Remote, uiState.profileType == TypedProfile.Type.Remote,
) )
}, },
@@ -7,15 +7,10 @@ import android.content.Context
import android.content.Intent import android.content.Intent
import android.net.Uri import android.net.Uri
import android.os.Build import android.os.Build
import android.text.format.Formatter
import android.util.Log import android.util.Log
import android.widget.Toast
import androidx.appcompat.app.AppCompatDelegate import androidx.appcompat.app.AppCompatDelegate
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
@@ -30,11 +25,8 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.ContentCopy
import androidx.compose.material.icons.outlined.AdminPanelSettings import androidx.compose.material.icons.outlined.AdminPanelSettings
import androidx.compose.material.icons.outlined.Autorenew import androidx.compose.material.icons.outlined.Autorenew
import androidx.compose.material.icons.outlined.DeleteForever
import androidx.compose.material.icons.outlined.DeleteSweep
import androidx.compose.material.icons.outlined.Download import androidx.compose.material.icons.outlined.Download
import androidx.compose.material.icons.outlined.Info import androidx.compose.material.icons.outlined.Info
import androidx.compose.material.icons.outlined.Language import androidx.compose.material.icons.outlined.Language
@@ -49,12 +41,9 @@ import androidx.compose.material3.Badge
import androidx.compose.material3.Card import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.ListItem import androidx.compose.material3.ListItem
import androidx.compose.material3.ListItemDefaults import androidx.compose.material3.ListItemDefaults
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
@@ -73,7 +62,6 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
@@ -83,17 +71,13 @@ import androidx.core.os.LocaleListCompat
import androidx.lifecycle.Lifecycle import androidx.lifecycle.Lifecycle
import androidx.lifecycle.compose.LifecycleEventEffect import androidx.lifecycle.compose.LifecycleEventEffect
import androidx.navigation.NavController import androidx.navigation.NavController
import io.nekohasekai.libbox.Libbox
import io.nekohasekai.libbox.getFDroidMirrors
import io.nekohasekai.sfa.Application import io.nekohasekai.sfa.Application
import io.nekohasekai.sfa.BuildConfig import io.nekohasekai.sfa.BuildConfig
import io.nekohasekai.sfa.R import io.nekohasekai.sfa.R
import io.nekohasekai.sfa.compose.component.UpdateAvailableDialog import io.nekohasekai.sfa.compose.component.UpdateAvailableDialog
import io.nekohasekai.sfa.compose.topbar.OverrideTopBar import io.nekohasekai.sfa.compose.topbar.OverrideTopBar
import io.nekohasekai.sfa.database.Settings import io.nekohasekai.sfa.database.Settings
import io.nekohasekai.sfa.ktx.clipboardText
import io.nekohasekai.sfa.update.UpdateCheckException import io.nekohasekai.sfa.update.UpdateCheckException
import io.nekohasekai.sfa.update.UpdateSource
import io.nekohasekai.sfa.update.UpdateState import io.nekohasekai.sfa.update.UpdateState
import io.nekohasekai.sfa.update.UpdateTrack import io.nekohasekai.sfa.update.UpdateTrack
import io.nekohasekai.sfa.utils.HookStatusClient import io.nekohasekai.sfa.utils.HookStatusClient
@@ -104,11 +88,10 @@ import kotlinx.coroutines.Job
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.xmlpull.v1.XmlPullParser import org.xmlpull.v1.XmlPullParser
import java.io.File
import java.util.Locale import java.util.Locale
import android.provider.Settings as AndroidSettings import android.provider.Settings as AndroidSettings
@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun AppSettingsScreen(navController: NavController) { fun AppSettingsScreen(navController: NavController) {
OverrideTopBar { OverrideTopBar {
@@ -130,12 +113,10 @@ fun AppSettingsScreen(navController: NavController) {
val hasUpdate by UpdateState.hasUpdate val hasUpdate by UpdateState.hasUpdate
val updateInfo by UpdateState.updateInfo val updateInfo by UpdateState.updateInfo
val isChecking by UpdateState.isChecking val isChecking by UpdateState.isChecking
var showSourceDialog by remember { mutableStateOf(false) }
var currentSource by remember { mutableStateOf(Settings.updateSource) }
var showTrackDialog by remember { mutableStateOf(false) } var showTrackDialog by remember { mutableStateOf(false) }
var currentTrack by remember { mutableStateOf(Settings.updateTrack) } var currentTrack by remember { mutableStateOf(Settings.updateTrack) }
var checkUpdateEnabled by remember { mutableStateOf(Settings.checkUpdateEnabled) } var checkUpdateEnabled by remember { mutableStateOf(Settings.checkUpdateEnabled) }
var showErrorDialog by remember { mutableStateOf<String?>(null) } var showErrorDialog by remember { mutableStateOf<Int?>(null) }
var silentInstallEnabled by remember { mutableStateOf(Settings.silentInstallEnabled) } var silentInstallEnabled by remember { mutableStateOf(Settings.silentInstallEnabled) }
var silentInstallMethod by remember { mutableStateOf(Settings.silentInstallMethod) } var silentInstallMethod by remember { mutableStateOf(Settings.silentInstallMethod) }
@@ -151,7 +132,6 @@ fun AppSettingsScreen(navController: NavController) {
var downloadJob by remember { mutableStateOf<Job?>(null) } var downloadJob by remember { mutableStateOf<Job?>(null) }
var downloadError by remember { mutableStateOf<String?>(null) } var downloadError by remember { mutableStateOf<String?>(null) }
var showUpdateAvailableDialog by remember { mutableStateOf(false) } var showUpdateAvailableDialog by remember { mutableStateOf(false) }
var showVersionMenu by remember { mutableStateOf(false) }
var notificationEnabled by remember { mutableStateOf(true) } var notificationEnabled by remember { mutableStateOf(true) }
var dynamicNotification by remember { mutableStateOf(Settings.dynamicNotification) } var dynamicNotification by remember { mutableStateOf(Settings.dynamicNotification) }
@@ -164,22 +144,8 @@ fun AppSettingsScreen(navController: NavController) {
mutableStateOf(if (appLocales.isEmpty) "" else appLocales.toLanguageTags()) mutableStateOf(if (appLocales.isEmpty) "" else appLocales.toLanguageTags())
} }
var cacheSize by remember { mutableStateOf(0L) }
var cacheSizeText by remember { mutableStateOf("") }
fun refreshCacheSize() {
scope.launch(Dispatchers.IO) {
val size = calculateDirSize(context.cacheDir)
withContext(Dispatchers.Main) {
cacheSize = size
cacheSizeText = Formatter.formatFileSize(context, size)
}
}
}
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
HookStatusClient.refresh() HookStatusClient.refresh()
refreshCacheSize()
} }
// Re-check states when returning from background (e.g., after granting permission) // Re-check states when returning from background (e.g., after granting permission)
@@ -217,21 +183,6 @@ fun AppSettingsScreen(navController: NavController) {
} }
} }
if (showSourceDialog) {
UpdateSourceDialog(
currentSource = currentSource,
onSourceSelected = { source ->
currentSource = source
UpdateState.clear()
scope.launch(Dispatchers.IO) {
Settings.updateSource = source
}
showSourceDialog = false
},
onDismiss = { showSourceDialog = false },
)
}
if (showTrackDialog) { if (showTrackDialog) {
UpdateTrackDialog( UpdateTrackDialog(
currentTrack = currentTrack, currentTrack = currentTrack,
@@ -247,11 +198,11 @@ fun AppSettingsScreen(navController: NavController) {
) )
} }
showErrorDialog?.let { message -> showErrorDialog?.let { messageRes ->
AlertDialog( AlertDialog(
onDismissRequest = { showErrorDialog = null }, onDismissRequest = { showErrorDialog = null },
title = { Text(stringResource(R.string.check_update)) }, title = { Text(stringResource(R.string.check_update)) },
text = { Text(message) }, text = { Text(stringResource(messageRes)) },
confirmButton = { confirmButton = {
TextButton(onClick = { showErrorDialog = null }) { TextButton(onClick = { showErrorDialog = null }) {
Text(stringResource(R.string.ok)) Text(stringResource(R.string.ok))
@@ -272,23 +223,11 @@ fun AppSettingsScreen(navController: NavController) {
color = MaterialTheme.colorScheme.error, color = MaterialTheme.colorScheme.error,
) )
} else { } else {
val progress by UpdateState.downloadProgress Row(verticalAlignment = Alignment.CenterVertically) {
Column { CircularProgressIndicator(modifier = Modifier.size(24.dp))
if (progress != null) { Spacer(modifier = Modifier.width(12.dp))
Text("${stringResource(R.string.downloading)} ${(progress!! * 100).toInt()}%")
} else {
Text(stringResource(R.string.downloading)) Text(stringResource(R.string.downloading))
} }
Spacer(modifier = Modifier.height(8.dp))
if (progress != null) {
LinearProgressIndicator(
progress = { progress!! },
modifier = Modifier.fillMaxWidth(),
)
} else {
LinearProgressIndicator(modifier = Modifier.fillMaxWidth())
}
}
} }
} }
}, },
@@ -299,7 +238,6 @@ fun AppSettingsScreen(navController: NavController) {
downloadJob = null downloadJob = null
showDownloadDialog = false showDownloadDialog = false
downloadError = null downloadError = null
UpdateState.downloadProgress.value = null
}, },
) { ) {
Text(stringResource(if (downloadError != null) R.string.ok else android.R.string.cancel)) Text(stringResource(if (downloadError != null) R.string.ok else android.R.string.cancel))
@@ -443,7 +381,6 @@ fun AppSettingsScreen(navController: NavController) {
), ),
) { ) {
Column { Column {
Box {
ListItem( ListItem(
headlineContent = { headlineContent = {
Text( Text(
@@ -471,42 +408,12 @@ fun AppSettingsScreen(navController: NavController) {
}, },
modifier = modifier =
Modifier Modifier
.clip(RoundedCornerShape(topStart = 12.dp, topEnd = 12.dp)) .clip(RoundedCornerShape(topStart = 12.dp, topEnd = 12.dp)),
.combinedClickable(
onClick = {},
onLongClick = { showVersionMenu = true },
),
colors = colors =
ListItemDefaults.colors( ListItemDefaults.colors(
containerColor = Color.Transparent, containerColor = Color.Transparent,
), ),
) )
Box(modifier = Modifier.align(Alignment.BottomEnd)) {
DropdownMenu(
expanded = showVersionMenu,
onDismissRequest = { showVersionMenu = false },
) {
DropdownMenuItem(
text = { Text(stringResource(R.string.per_app_proxy_action_copy)) },
leadingIcon = {
Icon(
imageVector = Icons.Filled.ContentCopy,
contentDescription = null,
)
},
onClick = {
clipboardText = BuildConfig.VERSION_NAME
Toast.makeText(
context,
R.string.copied_to_clipboard,
Toast.LENGTH_SHORT,
).show()
showVersionMenu = false
},
)
}
}
}
ListItem( ListItem(
headlineContent = { headlineContent = {
@@ -533,80 +440,13 @@ fun AppSettingsScreen(navController: NavController) {
}, },
modifier = modifier =
Modifier Modifier
.clip(RoundedCornerShape(bottomStart = 12.dp, bottomEnd = 12.dp))
.clickable { showLanguageDialog = true }, .clickable { showLanguageDialog = true },
colors = colors =
ListItemDefaults.colors( ListItemDefaults.colors(
containerColor = Color.Transparent, containerColor = Color.Transparent,
), ),
) )
ListItem(
headlineContent = {
Text(
stringResource(R.string.cache_size),
style = MaterialTheme.typography.bodyLarge,
)
},
supportingContent = {
if (cacheSizeText.isNotEmpty()) {
Text(cacheSizeText, style = MaterialTheme.typography.bodyMedium)
}
},
leadingContent = {
Icon(
imageVector = Icons.Outlined.DeleteSweep,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
)
},
modifier =
Modifier
.clip(
if (cacheSize > 0L) {
RoundedCornerShape(0.dp)
} else {
RoundedCornerShape(bottomStart = 12.dp, bottomEnd = 12.dp)
},
),
colors =
ListItemDefaults.colors(
containerColor = Color.Transparent,
),
)
if (cacheSize > 0L) {
ListItem(
headlineContent = {
Text(
stringResource(R.string.clear_cache),
style = MaterialTheme.typography.bodyLarge,
)
},
leadingContent = {
Icon(
imageVector = Icons.Outlined.DeleteForever,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
)
},
modifier =
Modifier
.clip(RoundedCornerShape(bottomStart = 12.dp, bottomEnd = 12.dp))
.clickable {
scope.launch(Dispatchers.IO) {
context.cacheDir?.listFiles()?.forEach { it.deleteRecursively() }
withContext(Dispatchers.Main) {
cacheSize = 0L
cacheSizeText = Formatter.formatFileSize(context, 0L)
}
}
},
colors =
ListItemDefaults.colors(
containerColor = Color.Transparent,
),
)
}
} }
} }
@@ -715,21 +555,14 @@ fun AppSettingsScreen(navController: NavController) {
), ),
) { ) {
Column { Column {
val isFDroid = UpdateSource.fromString(currentSource) == UpdateSource.FDROID
val updateItemCount = val updateItemCount =
run { run {
var count = 0 var count = 0
if (Vendor.updateSources.size > 1) { if (Vendor.supportsTrackSelection()) {
count += 1
}
if (Vendor.hasCustomUpdate) {
count += 1
}
if (isFDroid) {
count += 1 count += 1
} }
count += 1 count += 1
if (Vendor.hasCustomUpdate) { if (Vendor.supportsSilentInstall()) {
count += 1 count += 1
if (silentInstallEnabled) { if (silentInstallEnabled) {
count += 1 count += 1
@@ -741,7 +574,7 @@ fun AppSettingsScreen(navController: NavController) {
} }
} }
} }
if (Vendor.hasCustomUpdate) { if (Vendor.supportsAutoUpdate()) {
count += 1 count += 1
} }
count count
@@ -759,39 +592,7 @@ fun AppSettingsScreen(navController: NavController) {
} }
} }
if (Vendor.updateSources.size > 1) { if (Vendor.supportsTrackSelection()) {
ListItem(
headlineContent = {
Text(
stringResource(R.string.update_source),
style = MaterialTheme.typography.bodyLarge,
)
},
supportingContent = {
val sourceName = when (UpdateSource.fromString(currentSource)) {
UpdateSource.GITHUB -> stringResource(R.string.update_source_github)
UpdateSource.FDROID -> stringResource(R.string.update_source_fdroid)
}
Text(sourceName, style = MaterialTheme.typography.bodyMedium)
},
leadingContent = {
Icon(
imageVector = Icons.Outlined.NewReleases,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
)
},
modifier =
updateItemModifier()
.clickable { showSourceDialog = true },
colors =
ListItemDefaults.colors(
containerColor = Color.Transparent,
),
)
}
if (Vendor.hasCustomUpdate) {
ListItem( ListItem(
headlineContent = { headlineContent = {
Text( Text(
@@ -800,14 +601,10 @@ fun AppSettingsScreen(navController: NavController) {
) )
}, },
supportingContent = { supportingContent = {
val trackName = if (isFDroid) { val trackName = when (UpdateTrack.fromString(currentTrack)) {
stringResource(R.string.update_track_stable)
} else {
when (UpdateTrack.fromString(currentTrack)) {
UpdateTrack.STABLE -> stringResource(R.string.update_track_stable) UpdateTrack.STABLE -> stringResource(R.string.update_track_stable)
UpdateTrack.BETA -> stringResource(R.string.update_track_beta) UpdateTrack.BETA -> stringResource(R.string.update_track_beta)
} }
}
Text(trackName, style = MaterialTheme.typography.bodyMedium) Text(trackName, style = MaterialTheme.typography.bodyMedium)
}, },
leadingContent = { leadingContent = {
@@ -818,63 +615,8 @@ fun AppSettingsScreen(navController: NavController) {
) )
}, },
modifier = modifier =
updateItemModifier().let {
if (isFDroid) it.alpha(0.38f) else it.clickable { showTrackDialog = true }
},
colors =
ListItemDefaults.colors(
containerColor = Color.Transparent,
),
)
}
if (isFDroid) {
ListItem(
headlineContent = {
Text(
stringResource(R.string.fdroid_mirror),
style = MaterialTheme.typography.bodyLarge,
)
},
supportingContent = {
val mirrorUrl = Settings.fdroidMirrorUrl
val mirrorName = remember(mirrorUrl) {
val iter = getFDroidMirrors()
var name: String? = null
while (iter.hasNext()) {
val m = iter.next()
if (m.url == mirrorUrl) {
name = m.name
break
}
}
if (name == null) {
val customMirrors = Settings.fdroidCustomMirrors
for (entry in customMirrors) {
val parts = entry.split("|", limit = 2)
if (parts.size == 2 && parts[1] == mirrorUrl) {
name = parts[0]
break
}
}
}
name ?: mirrorUrl
}
Text(
mirrorName,
style = MaterialTheme.typography.bodyMedium,
)
},
leadingContent = {
Icon(
imageVector = Icons.Outlined.Speed,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
)
},
modifier =
updateItemModifier() updateItemModifier()
.clickable { navController.navigate("settings/fdroid_mirror") }, .clickable { showTrackDialog = true },
colors = colors =
ListItemDefaults.colors( ListItemDefaults.colors(
containerColor = Color.Transparent, containerColor = Color.Transparent,
@@ -914,7 +656,7 @@ fun AppSettingsScreen(navController: NavController) {
), ),
) )
if (Vendor.hasCustomUpdate) { if (Vendor.supportsSilentInstall()) {
ListItem( ListItem(
headlineContent = { headlineContent = {
Text( Text(
@@ -1094,7 +836,7 @@ fun AppSettingsScreen(navController: NavController) {
} }
} }
if (Vendor.hasCustomUpdate) { if (Vendor.supportsAutoUpdate()) {
ListItem( ListItem(
headlineContent = { headlineContent = {
Text( Text(
@@ -1198,17 +940,15 @@ fun AppSettingsScreen(navController: NavController) {
val result = Vendor.checkUpdateAsync() val result = Vendor.checkUpdateAsync()
UpdateState.setUpdate(result) UpdateState.setUpdate(result)
if (result == null) { if (result == null) {
showErrorDialog = context.getString(R.string.no_updates_available) showErrorDialog = R.string.no_updates_available
} else { } else {
showUpdateAvailableDialog = true showUpdateAvailableDialog = true
} }
} catch (_: UpdateCheckException.TrackNotSupported) { } catch (_: UpdateCheckException.TrackNotSupported) {
UpdateState.setUpdate(null) UpdateState.setUpdate(null)
showErrorDialog = context.getString(R.string.update_track_not_supported) showErrorDialog = R.string.update_track_not_supported
} catch (e: Exception) { } catch (_: Exception) {
Log.e("AppSettingsScreen", "checkUpdateAsync failed", e)
UpdateState.setUpdate(null) UpdateState.setUpdate(null)
showErrorDialog = e.message
} }
} }
UpdateState.isChecking.value = false UpdateState.isChecking.value = false
@@ -1258,53 +998,6 @@ fun AppSettingsScreen(navController: NavController) {
} }
} }
@Composable
private fun UpdateSourceDialog(
currentSource: String,
onSourceSelected: (String) -> Unit,
onDismiss: () -> Unit,
) {
val sources = listOf(
"github" to stringResource(R.string.update_source_github),
"fdroid" to stringResource(R.string.update_source_fdroid),
)
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(stringResource(R.string.update_source)) },
text = {
Column {
sources.forEach { (value, label) ->
Row(
modifier =
Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(8.dp))
.clickable { onSourceSelected(value) }
.padding(vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
RadioButton(
selected = currentSource == value,
onClick = { onSourceSelected(value) },
)
Text(
text = label,
style = MaterialTheme.typography.bodyLarge,
modifier = Modifier.padding(start = 8.dp),
)
}
}
}
},
confirmButton = {
TextButton(onClick = onDismiss) {
Text(stringResource(android.R.string.cancel))
}
},
)
}
@Composable @Composable
private fun UpdateTrackDialog( private fun UpdateTrackDialog(
currentTrack: String, currentTrack: String,
@@ -1415,15 +1108,6 @@ private fun LanguageDialog(
) )
} }
private fun calculateDirSize(dir: File?): Long {
if (dir == null || !dir.exists()) return 0
var size = 0L
dir.listFiles()?.forEach { file ->
size += if (file.isDirectory) calculateDirSize(file) else file.length()
}
return size
}
private fun getSupportedLocales(context: Context): List<Locale> { private fun getSupportedLocales(context: Context): List<Locale> {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
val localeConfig = LocaleConfig(context) val localeConfig = LocaleConfig(context)
@@ -5,11 +5,8 @@ import android.content.Context
import android.content.Intent import android.content.Intent
import android.provider.DocumentsContract import android.provider.DocumentsContract
import android.widget.Toast import android.widget.Toast
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
@@ -21,7 +18,6 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.ContentCopy
import androidx.compose.material.icons.outlined.DeleteForever import androidx.compose.material.icons.outlined.DeleteForever
import androidx.compose.material.icons.outlined.FolderOpen import androidx.compose.material.icons.outlined.FolderOpen
import androidx.compose.material.icons.outlined.Info import androidx.compose.material.icons.outlined.Info
@@ -29,8 +25,6 @@ import androidx.compose.material.icons.outlined.Storage
import androidx.compose.material.icons.outlined.WarningAmber import androidx.compose.material.icons.outlined.WarningAmber
import androidx.compose.material3.Card import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults import androidx.compose.material3.CardDefaults
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
@@ -47,7 +41,6 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
@@ -59,12 +52,11 @@ import io.nekohasekai.libbox.Libbox
import io.nekohasekai.sfa.R import io.nekohasekai.sfa.R
import io.nekohasekai.sfa.compose.topbar.OverrideTopBar import io.nekohasekai.sfa.compose.topbar.OverrideTopBar
import io.nekohasekai.sfa.database.Settings import io.nekohasekai.sfa.database.Settings
import io.nekohasekai.sfa.ktx.clipboardText
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun CoreSettingsScreen(navController: NavController) { fun CoreSettingsScreen(navController: NavController) {
OverrideTopBar { OverrideTopBar {
@@ -85,7 +77,6 @@ fun CoreSettingsScreen(navController: NavController) {
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
var dataSize by remember { mutableStateOf("") } var dataSize by remember { mutableStateOf("") }
val version = remember { Libbox.version() } val version = remember { Libbox.version() }
var showVersionMenu by remember { mutableStateOf(false) }
var disableDeprecatedWarnings by remember { mutableStateOf(Settings.disableDeprecatedWarnings) } var disableDeprecatedWarnings by remember { mutableStateOf(Settings.disableDeprecatedWarnings) }
// Calculate data size on launch // Calculate data size on launch
@@ -123,7 +114,6 @@ fun CoreSettingsScreen(navController: NavController) {
) { ) {
Column { Column {
// Version Info // Version Info
Box {
ListItem( ListItem(
headlineContent = { headlineContent = {
Text( Text(
@@ -146,43 +136,12 @@ fun CoreSettingsScreen(navController: NavController) {
tint = MaterialTheme.colorScheme.primary, tint = MaterialTheme.colorScheme.primary,
) )
}, },
modifier = Modifier modifier = Modifier.clip(RoundedCornerShape(topStart = 12.dp, topEnd = 12.dp)),
.clip(RoundedCornerShape(topStart = 12.dp, topEnd = 12.dp))
.combinedClickable(
onClick = {},
onLongClick = { showVersionMenu = true },
),
colors = colors =
ListItemDefaults.colors( ListItemDefaults.colors(
containerColor = Color.Transparent, containerColor = Color.Transparent,
), ),
) )
Box(modifier = Modifier.align(Alignment.BottomEnd)) {
DropdownMenu(
expanded = showVersionMenu,
onDismissRequest = { showVersionMenu = false },
) {
DropdownMenuItem(
text = { Text(stringResource(R.string.per_app_proxy_action_copy)) },
leadingIcon = {
Icon(
imageVector = Icons.Filled.ContentCopy,
contentDescription = null,
)
},
onClick = {
clipboardText = version
Toast.makeText(
context,
R.string.copied_to_clipboard,
Toast.LENGTH_SHORT,
).show()
showVersionMenu = false
},
)
}
}
}
// Data Size // Data Size
ListItem( ListItem(
@@ -222,11 +181,11 @@ fun CoreSettingsScreen(navController: NavController) {
} }
} }
if (version.contains("-")) { // Options Section
Spacer(modifier = Modifier.height(16.dp)) Spacer(modifier = Modifier.height(16.dp))
Text( Text(
text = stringResource(R.string.beta_settings), text = stringResource(R.string.options),
style = MaterialTheme.typography.labelLarge, style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.primary, color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(horizontal = 32.dp, vertical = 8.dp), modifier = Modifier.padding(horizontal = 32.dp, vertical = 8.dp),
@@ -274,7 +233,6 @@ fun CoreSettingsScreen(navController: NavController) {
), ),
) )
} }
}
// Working Directory Section // Working Directory Section
Spacer(modifier = Modifier.height(16.dp)) Spacer(modifier = Modifier.height(16.dp))
@@ -1,458 +0,0 @@
package io.nekohasekai.sfa.compose.screen.settings
import android.webkit.URLUtil
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.outlined.Add
import androidx.compose.material.icons.outlined.Delete
import androidx.compose.material.icons.outlined.Speed
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.ListItem
import androidx.compose.material3.ListItemDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.RadioButton
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateMapOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.navigation.NavController
import io.nekohasekai.libbox.Libbox
import io.nekohasekai.libbox.getFDroidMirrors
import io.nekohasekai.libbox.pingFDroidMirror
import io.nekohasekai.sfa.R
import io.nekohasekai.sfa.compose.topbar.OverrideTopBar
import io.nekohasekai.sfa.database.Settings
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
private data class MirrorEntry(
val url: String,
val name: String,
val country: String,
val isCustom: Boolean = false,
)
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun FDroidMirrorScreen(navController: NavController) {
OverrideTopBar {
TopAppBar(
title = { Text(stringResource(R.string.fdroid_mirror)) },
navigationIcon = {
IconButton(onClick = { navController.navigateUp() }) {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = stringResource(R.string.content_description_back),
)
}
},
)
}
val scope = rememberCoroutineScope()
var selectedMirrorUrl by remember { mutableStateOf(Settings.fdroidMirrorUrl) }
var isTesting by remember { mutableStateOf(false) }
val latencyResults = remember { mutableStateMapOf<String, Int>() }
val latencyErrors = remember { mutableStateMapOf<String, Boolean>() }
var showAddForm by remember { mutableStateOf(false) }
var newMirrorName by remember { mutableStateOf("") }
var newMirrorUrl by remember { mutableStateOf("") }
var urlError by remember { mutableStateOf<String?>(null) }
val invalidUrlMessage = stringResource(R.string.fdroid_mirror_invalid_url)
var customMirrors by remember { mutableStateOf(Settings.fdroidCustomMirrors) }
val builtinMirrors = remember {
val mirrors = mutableListOf<MirrorEntry>()
val iter = getFDroidMirrors()
while (iter.hasNext()) {
val m = iter.next()
mirrors.add(MirrorEntry(url = m.url, name = m.name, country = m.country))
}
mirrors
}
val parsedCustomMirrors = remember(customMirrors) {
customMirrors.map { entry ->
val parts = entry.split("|", limit = 2)
if (parts.size == 2) {
MirrorEntry(url = parts[1], name = parts[0], country = "", isCustom = true)
} else {
MirrorEntry(url = entry, name = entry, country = "", isCustom = true)
}
}
}
val allMirrors = builtinMirrors + parsedCustomMirrors
fun selectMirror(url: String) {
selectedMirrorUrl = url
Settings.fdroidMirrorUrl = url
}
fun testAllMirrors() {
isTesting = true
latencyResults.clear()
latencyErrors.clear()
scope.launch {
allMirrors.map { mirror ->
async(Dispatchers.IO) {
val r = pingFDroidMirror(mirror.url)
withContext(Dispatchers.Main) {
if (r.latencyMs < 0) {
latencyErrors[r.url] = true
} else {
latencyResults[r.url] = r.latencyMs
}
}
}
}.awaitAll()
val fastest = latencyResults.minByOrNull { it.value }
if (fastest != null) {
selectMirror(fastest.key)
}
isTesting = false
}
}
val grouped = remember(builtinMirrors) {
builtinMirrors.groupBy { it.country }
}
val countryOrder = remember(grouped) { grouped.keys.toList() }
Column(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.surface)
.verticalScroll(rememberScrollState())
.padding(vertical = 8.dp),
) {
FilledTonalButton(
onClick = { testAllMirrors() },
enabled = !isTesting,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
) {
if (isTesting) {
CircularProgressIndicator(
modifier = Modifier.size(18.dp),
strokeWidth = 2.dp,
)
Spacer(modifier = Modifier.width(8.dp))
Text(stringResource(R.string.fdroid_mirror_testing))
} else {
Icon(
imageVector = Icons.Outlined.Speed,
contentDescription = null,
modifier = Modifier.size(18.dp),
)
Spacer(modifier = Modifier.width(8.dp))
Text(stringResource(R.string.fdroid_mirror_test_all))
}
}
Spacer(modifier = Modifier.height(8.dp))
countryOrder.forEach { country ->
val mirrors = grouped[country] ?: return@forEach
Text(
text = country,
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(horizontal = 32.dp, vertical = 8.dp),
)
Card(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceContainer,
),
) {
Column {
mirrors.forEachIndexed { index, mirror ->
val shape = when {
mirrors.size == 1 -> RoundedCornerShape(12.dp)
index == 0 -> RoundedCornerShape(topStart = 12.dp, topEnd = 12.dp)
index == mirrors.lastIndex -> RoundedCornerShape(
bottomStart = 12.dp,
bottomEnd = 12.dp,
)
else -> RoundedCornerShape(0.dp)
}
ListItem(
headlineContent = {
Text(
mirror.name,
style = MaterialTheme.typography.bodyLarge,
)
},
leadingContent = {
RadioButton(
selected = selectedMirrorUrl == mirror.url,
onClick = { selectMirror(mirror.url) },
)
},
trailingContent = {
LatencyBadge(
url = mirror.url,
latencyResults = latencyResults,
latencyErrors = latencyErrors,
)
},
modifier = Modifier
.clip(shape)
.clickable { selectMirror(mirror.url) },
colors = ListItemDefaults.colors(
containerColor = Color.Transparent,
),
)
}
}
}
Spacer(modifier = Modifier.height(8.dp))
}
Text(
text = stringResource(R.string.fdroid_mirror_custom),
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(horizontal = 32.dp, vertical = 8.dp),
)
Card(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceContainer,
),
) {
Column {
parsedCustomMirrors.forEachIndexed { index, mirror ->
val isLast = index == parsedCustomMirrors.lastIndex && !showAddForm
val shape = when {
index == 0 && isLast -> RoundedCornerShape(12.dp)
index == 0 -> RoundedCornerShape(topStart = 12.dp, topEnd = 12.dp)
isLast -> RoundedCornerShape(
bottomStart = 12.dp,
bottomEnd = 12.dp,
)
else -> RoundedCornerShape(0.dp)
}
ListItem(
headlineContent = {
Text(
mirror.name,
style = MaterialTheme.typography.bodyLarge,
)
},
supportingContent = {
Text(
mirror.url,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
},
leadingContent = {
RadioButton(
selected = selectedMirrorUrl == mirror.url,
onClick = { selectMirror(mirror.url) },
)
},
trailingContent = {
Row(verticalAlignment = Alignment.CenterVertically) {
LatencyBadge(
url = mirror.url,
latencyResults = latencyResults,
latencyErrors = latencyErrors,
)
IconButton(onClick = {
val encoded = "${mirror.name}|${mirror.url}"
val newSet = customMirrors.toMutableSet()
newSet.remove(encoded)
customMirrors = newSet
Settings.fdroidCustomMirrors = newSet
if (selectedMirrorUrl == mirror.url) {
selectMirror("https://f-droid.org/repo")
}
}) {
Icon(
imageVector = Icons.Outlined.Delete,
contentDescription = stringResource(R.string.fdroid_mirror_delete),
tint = MaterialTheme.colorScheme.error,
)
}
}
},
modifier = Modifier
.clip(shape)
.clickable { selectMirror(mirror.url) },
colors = ListItemDefaults.colors(
containerColor = Color.Transparent,
),
)
}
if (showAddForm) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
OutlinedTextField(
value = newMirrorName,
onValueChange = { newMirrorName = it },
label = { Text(stringResource(R.string.fdroid_mirror_name_hint)) },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
OutlinedTextField(
value = newMirrorUrl,
onValueChange = {
newMirrorUrl = it
urlError = null
},
label = { Text(stringResource(R.string.fdroid_mirror_url_hint)) },
singleLine = true,
isError = urlError != null,
supportingText = urlError?.let { { Text(it) } },
modifier = Modifier.fillMaxWidth(),
)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.End,
verticalAlignment = Alignment.CenterVertically,
) {
Button(onClick = {
val url = newMirrorUrl.trim().trimEnd('/')
if (!URLUtil.isHttpsUrl(url)) {
urlError = invalidUrlMessage
return@Button
}
val name = newMirrorName.trim().ifEmpty { url }
val encoded = "$name|$url"
val newSet = customMirrors.toMutableSet()
newSet.add(encoded)
customMirrors = newSet
Settings.fdroidCustomMirrors = newSet
newMirrorName = ""
newMirrorUrl = ""
urlError = null
showAddForm = false
}) {
Text(stringResource(R.string.fdroid_mirror_add_action))
}
}
}
} else {
ListItem(
headlineContent = {
Text(
stringResource(R.string.fdroid_mirror_add),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.primary,
)
},
leadingContent = {
Icon(
imageVector = Icons.Outlined.Add,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
)
},
modifier = Modifier
.clip(
if (parsedCustomMirrors.isEmpty()) {
RoundedCornerShape(12.dp)
} else {
RoundedCornerShape(bottomStart = 12.dp, bottomEnd = 12.dp)
},
)
.clickable { showAddForm = true },
colors = ListItemDefaults.colors(
containerColor = Color.Transparent,
),
)
}
}
}
Spacer(modifier = Modifier.height(16.dp))
}
}
@Composable
private fun LatencyBadge(
url: String,
latencyResults: Map<String, Int>,
latencyErrors: Map<String, Boolean>,
) {
val latency = latencyResults[url]
val failed = latencyErrors[url] == true
when {
latency != null -> {
Text(
text = stringResource(R.string.fdroid_mirror_latency, latency),
style = MaterialTheme.typography.labelMedium,
color = when {
latency < 100 -> MaterialTheme.colorScheme.primary
latency < 500 -> MaterialTheme.colorScheme.onSurfaceVariant
else -> MaterialTheme.colorScheme.error
},
)
}
failed -> {
Text(
text = stringResource(R.string.fdroid_mirror_failed),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.error,
)
}
else -> {}
}
}
@@ -16,8 +16,6 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.ClickableText
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.filled.ArrowBack
@@ -28,11 +26,8 @@ import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.ListItem
import androidx.compose.material3.ListItemDefaults
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Switch
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@@ -40,28 +35,18 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.navigation.NavController import androidx.navigation.NavController
import io.nekohasekai.sfa.R import io.nekohasekai.sfa.R
import io.nekohasekai.sfa.bg.ServiceConnection import io.nekohasekai.sfa.bg.ServiceConnection
import io.nekohasekai.sfa.compose.topbar.OverrideTopBar import io.nekohasekai.sfa.compose.topbar.OverrideTopBar
import io.nekohasekai.sfa.database.Settings
import io.nekohasekai.sfa.ktx.launchCustomTab import io.nekohasekai.sfa.ktx.launchCustomTab
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
@@ -81,13 +66,14 @@ fun ServiceSettingsScreen(navController: NavController, serviceConnection: Servi
} }
val context = LocalContext.current val context = LocalContext.current
val scope = rememberCoroutineScope() // Check battery optimization status
var isBatteryOptimizationIgnored by remember { mutableStateOf(false) } var isBatteryOptimizationIgnored by remember { mutableStateOf(false) }
var allowBypass by remember { mutableStateOf(Settings.allowBypass) } // Activity result launcher for battery optimization permission
val requestBatteryOptimizationLauncher = val requestBatteryOptimizationLauncher =
rememberLauncherForActivityResult( rememberLauncherForActivityResult(
ActivityResultContracts.StartActivityForResult(), ActivityResultContracts.StartActivityForResult(),
) { _ -> ) { _ ->
// Recheck the status after returning from settings
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
val pm = context.getSystemService(PowerManager::class.java) val pm = context.getSystemService(PowerManager::class.java)
isBatteryOptimizationIgnored = isBatteryOptimizationIgnored =
@@ -95,6 +81,7 @@ fun ServiceSettingsScreen(navController: NavController, serviceConnection: Servi
} }
} }
// Check battery optimization status on launch
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
val pm = context.getSystemService(PowerManager::class.java) val pm = context.getSystemService(PowerManager::class.java)
@@ -113,6 +100,7 @@ fun ServiceSettingsScreen(navController: NavController, serviceConnection: Servi
.verticalScroll(rememberScrollState()) .verticalScroll(rememberScrollState())
.padding(vertical = 8.dp), .padding(vertical = 8.dp),
) { ) {
// Background Permission Card (only show if battery optimization is not ignored)
if (!isBatteryOptimizationIgnored && Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { if (!isBatteryOptimizationIgnored && Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
Card( Card(
modifier = modifier =
@@ -183,93 +171,6 @@ fun ServiceSettingsScreen(navController: NavController, serviceConnection: Servi
} }
} }
// VPN Section
Spacer(modifier = Modifier.height(16.dp))
Text(
text = "VPN",
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(horizontal = 32.dp, vertical = 8.dp),
)
Card(
modifier =
Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
colors =
CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceContainer,
),
) {
val descriptionText = stringResource(R.string.allow_bypass_description)
val linkText = stringResource(R.string.android_documentation)
val linkColor = MaterialTheme.colorScheme.primary
val textColor = MaterialTheme.colorScheme.onSurfaceVariant
val textStyle = MaterialTheme.typography.bodyMedium
ListItem(
headlineContent = {
Text(
stringResource(R.string.allow_bypass),
style = MaterialTheme.typography.bodyLarge,
)
},
supportingContent = {
val annotatedString = buildAnnotatedString {
withStyle(SpanStyle(color = textColor)) {
append(descriptionText)
}
append("\n\n")
pushStringAnnotation(tag = "URL", annotation = ALLOW_BYPASS_DOC_URL)
withStyle(
SpanStyle(
color = linkColor,
textDecoration = TextDecoration.Underline,
),
) {
append(linkText)
}
pop()
}
ClickableText(
text = annotatedString,
style = textStyle,
modifier = Modifier.padding(top = 4.dp),
onClick = { offset ->
annotatedString.getStringAnnotations(
tag = "URL",
start = offset,
end = offset,
).firstOrNull()?.let {
context.launchCustomTab(it.item)
}
},
)
},
trailingContent = {
Switch(
checked = allowBypass,
onCheckedChange = { checked ->
allowBypass = checked
scope.launch(Dispatchers.IO) {
Settings.allowBypass = checked
}
},
)
},
modifier = Modifier.clip(RoundedCornerShape(12.dp)),
colors =
ListItemDefaults.colors(
containerColor = Color.Transparent,
),
)
}
Spacer(modifier = Modifier.height(16.dp)) Spacer(modifier = Modifier.height(16.dp))
} }
} }
private const val ALLOW_BYPASS_DOC_URL =
"https://developer.android.com/reference/android/net/VpnService.Builder#allowBypass()"
@@ -1,5 +1,7 @@
package io.nekohasekai.sfa.compose.screen.settings package io.nekohasekai.sfa.compose.screen.settings
import android.os.Build
import android.os.PowerManager
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
@@ -35,7 +37,10 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
@@ -65,8 +70,15 @@ fun SettingsScreen(navController: NavController) {
val hookStatus by HookStatusClient.status.collectAsState() val hookStatus by HookStatusClient.status.collectAsState()
val hasPendingPrivilegeDowngrade = HookModuleUpdateNotifier.isDowngrade(hookStatus) val hasPendingPrivilegeDowngrade = HookModuleUpdateNotifier.isDowngrade(hookStatus)
val hasPendingPrivilegeUpdate = HookModuleUpdateNotifier.isUpgrade(hookStatus) val hasPendingPrivilegeUpdate = HookModuleUpdateNotifier.isUpgrade(hookStatus)
var isBatteryOptimizationIgnored by remember { mutableStateOf(true) }
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
HookStatusClient.refresh() HookStatusClient.refresh()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
val pm = context.getSystemService(PowerManager::class.java)
isBatteryOptimizationIgnored =
pm?.isIgnoringBatteryOptimizations(context.packageName) == true
}
} }
Column( Column(
@@ -155,6 +167,11 @@ fun SettingsScreen(navController: NavController) {
tint = MaterialTheme.colorScheme.primary, tint = MaterialTheme.colorScheme.primary,
) )
}, },
trailingContent = {
if (!isBatteryOptimizationIgnored) {
Badge(containerColor = MaterialTheme.colorScheme.primary)
}
},
modifier = Modifier.clickable { navController.navigate("settings/service") }, modifier = Modifier.clickable { navController.navigate("settings/service") },
colors = colors =
ListItemDefaults.colors( ListItemDefaults.colors(
@@ -5,10 +5,7 @@ object SettingsKey {
const val SERVICE_MODE = "service_mode" const val SERVICE_MODE = "service_mode"
const val CHECK_UPDATE_ENABLED = "check_update_enabled" const val CHECK_UPDATE_ENABLED = "check_update_enabled"
const val UPDATE_CHECK_PROMPTED = "update_check_prompted" const val UPDATE_CHECK_PROMPTED = "update_check_prompted"
const val UPDATE_SOURCE = "update_source"
const val UPDATE_TRACK = "update_track" const val UPDATE_TRACK = "update_track"
const val FDROID_MIRROR_URL = "fdroid_mirror_url"
const val FDROID_CUSTOM_MIRRORS = "fdroid_custom_mirrors"
const val SILENT_INSTALL_ENABLED = "silent_install_enabled" const val SILENT_INSTALL_ENABLED = "silent_install_enabled"
const val SILENT_INSTALL_METHOD = "silent_install_method" const val SILENT_INSTALL_METHOD = "silent_install_method"
const val AUTO_UPDATE_ENABLED = "auto_update_enabled" const val AUTO_UPDATE_ENABLED = "auto_update_enabled"
@@ -23,7 +20,6 @@ object SettingsKey {
const val PER_APP_PROXY_MANAGED_LIST = "per_app_proxy_managed_list" const val PER_APP_PROXY_MANAGED_LIST = "per_app_proxy_managed_list"
const val PER_APP_PROXY_PACKAGE_QUERY_MODE = "per_app_proxy_package_query_mode" const val PER_APP_PROXY_PACKAGE_QUERY_MODE = "per_app_proxy_package_query_mode"
const val ALLOW_BYPASS = "allow_bypass"
const val SYSTEM_PROXY_ENABLED = "system_proxy_enabled" const val SYSTEM_PROXY_ENABLED = "system_proxy_enabled"
const val PRIVILEGE_SETTINGS_ENABLED = "hide_settings_enabled" const val PRIVILEGE_SETTINGS_ENABLED = "hide_settings_enabled"
@@ -41,7 +41,6 @@ object Settings {
var serviceMode by dataStore.string(SettingsKey.SERVICE_MODE) { ServiceMode.NORMAL } var serviceMode by dataStore.string(SettingsKey.SERVICE_MODE) { ServiceMode.NORMAL }
var startedByUser by dataStore.boolean(SettingsKey.STARTED_BY_USER) var startedByUser by dataStore.boolean(SettingsKey.STARTED_BY_USER)
var updateSource by dataStore.string(SettingsKey.UPDATE_SOURCE) { "github" }
var checkUpdateEnabled by dataStore.boolean(SettingsKey.CHECK_UPDATE_ENABLED) { false } var checkUpdateEnabled by dataStore.boolean(SettingsKey.CHECK_UPDATE_ENABLED) { false }
var updateCheckPrompted by dataStore.boolean(SettingsKey.UPDATE_CHECK_PROMPTED) { false } var updateCheckPrompted by dataStore.boolean(SettingsKey.UPDATE_CHECK_PROMPTED) { false }
var updateTrack by dataStore.string(SettingsKey.UPDATE_TRACK) { var updateTrack by dataStore.string(SettingsKey.UPDATE_TRACK) {
@@ -63,8 +62,6 @@ object Settings {
"SHIZUKU" "SHIZUKU"
} }
} }
var fdroidMirrorUrl by dataStore.string(SettingsKey.FDROID_MIRROR_URL) { "https://f-droid.org/repo" }
var fdroidCustomMirrors by dataStore.stringSet(SettingsKey.FDROID_CUSTOM_MIRRORS) { emptySet() }
var autoUpdateEnabled by dataStore.boolean(SettingsKey.AUTO_UPDATE_ENABLED) { false } var autoUpdateEnabled by dataStore.boolean(SettingsKey.AUTO_UPDATE_ENABLED) { false }
var dynamicNotification by dataStore.boolean(SettingsKey.DYNAMIC_NOTIFICATION) { true } var dynamicNotification by dataStore.boolean(SettingsKey.DYNAMIC_NOTIFICATION) { true }
var disableDeprecatedWarnings by dataStore.boolean(SettingsKey.DISABLE_DEPRECATED_WARNINGS) { false } var disableDeprecatedWarnings by dataStore.boolean(SettingsKey.DISABLE_DEPRECATED_WARNINGS) { false }
@@ -96,7 +93,6 @@ object Settings {
perAppProxyList perAppProxyList
} }
var allowBypass by dataStore.boolean(SettingsKey.ALLOW_BYPASS) { false }
var systemProxyEnabled by dataStore.boolean(SettingsKey.SYSTEM_PROXY_ENABLED) { true } var systemProxyEnabled by dataStore.boolean(SettingsKey.SYSTEM_PROXY_ENABLED) { true }
var privilegeSettingsEnabled by dataStore.boolean(SettingsKey.PRIVILEGE_SETTINGS_ENABLED) { false } var privilegeSettingsEnabled by dataStore.boolean(SettingsKey.PRIVILEGE_SETTINGS_ENABLED) { false }
@@ -1,27 +0,0 @@
package io.nekohasekai.sfa.update
import android.content.Context
import io.nekohasekai.libbox.checkFDroidUpdate as libboxCheckFDroidUpdate
import io.nekohasekai.sfa.database.Settings
fun checkFDroidUpdate(context: Context): UpdateInfo? {
val packageName = context.packageName
@Suppress("DEPRECATION")
val versionCode = context.packageManager.getPackageInfo(packageName, 0).versionCode
val result = libboxCheckFDroidUpdate(
Settings.fdroidMirrorUrl,
packageName,
versionCode,
context.cacheDir.absolutePath,
) ?: return null
return UpdateInfo(
versionCode = result.versionCode,
versionName = result.versionName,
downloadUrl = result.downloadURL,
releaseUrl = "https://f-droid.org/packages/$packageName/",
releaseNotes = null,
isPrerelease = false,
fileSize = result.fileSize,
)
}
@@ -1,14 +0,0 @@
package io.nekohasekai.sfa.update
enum class UpdateSource {
GITHUB,
FDROID,
;
companion object {
fun fromString(value: String): UpdateSource = when (value.lowercase()) {
"fdroid" -> FDROID
else -> GITHUB
}
}
}
@@ -11,7 +11,6 @@ object UpdateState {
val isChecking = mutableStateOf(false) val isChecking = mutableStateOf(false)
val isDownloading = mutableStateOf(false) val isDownloading = mutableStateOf(false)
val downloadProgress = mutableStateOf<Float?>(null)
val downloadError = mutableStateOf<String?>(null) val downloadError = mutableStateOf<String?>(null)
val cachedApkFile = mutableStateOf<File?>(null) val cachedApkFile = mutableStateOf<File?>(null)
@@ -39,7 +38,6 @@ object UpdateState {
hasUpdate.value = false hasUpdate.value = false
updateInfo.value = null updateInfo.value = null
isDownloading.value = false isDownloading.value = false
downloadProgress.value = null
downloadError.value = null downloadError.value = null
installStatus.value = InstallStatus.Idle installStatus.value = InstallStatus.Idle
cachedApkFile.value = null cachedApkFile.value = null
@@ -48,7 +46,6 @@ object UpdateState {
fun resetDownload() { fun resetDownload() {
isDownloading.value = false isDownloading.value = false
downloadProgress.value = null
downloadError.value = null downloadError.value = null
} }
@@ -100,12 +100,7 @@ open class CommandClient(
} }
options.statusInterval = 1 * 1000 * 1000 * 1000 options.statusInterval = 1 * 1000 * 1000 * 1000
val commandClient = CommandClient(clientHandler, options) val commandClient = CommandClient(clientHandler, options)
try {
commandClient.connect() commandClient.connect()
} catch (e: Exception) {
Log.d("CommandClient", "connect failed", e)
return
}
this.commandClient = commandClient this.commandClient = commandClient
} }
@@ -4,7 +4,6 @@ import android.app.Activity
import androidx.camera.core.ImageAnalysis import androidx.camera.core.ImageAnalysis
import io.nekohasekai.sfa.compose.screen.qrscan.QRCodeCropArea import io.nekohasekai.sfa.compose.screen.qrscan.QRCodeCropArea
import io.nekohasekai.sfa.update.UpdateInfo import io.nekohasekai.sfa.update.UpdateInfo
import io.nekohasekai.sfa.update.UpdateSource
interface VendorInterface { interface VendorInterface {
fun checkUpdate(activity: Activity, byUser: Boolean) fun checkUpdate(activity: Activity, byUser: Boolean)
@@ -15,17 +14,53 @@ interface VendorInterface {
onCropArea: ((QRCodeCropArea?) -> Unit)? = null, onCropArea: ((QRCodeCropArea?) -> Unit)? = null,
): ImageAnalysis.Analyzer? ): ImageAnalysis.Analyzer?
/**
* Check if Per-app Proxy feature is available
* @return true if available, false if disabled (e.g., for Play Store builds)
*/
fun isPerAppProxyAvailable(): Boolean = true fun isPerAppProxyAvailable(): Boolean = true
val hasCustomUpdate: Boolean get() = false /**
* Check if track selection is available (e.g., stable/beta)
val updateSources: List<UpdateSource> get() = listOf(UpdateSource.GITHUB) * @return true if track selection is supported
*/
fun supportsTrackSelection(): Boolean = false
/**
* Check for updates asynchronously
* @return UpdateInfo if update is available, null otherwise
*/
fun checkUpdateAsync(): UpdateInfo? = null fun checkUpdateAsync(): UpdateInfo? = null
/**
* Check if silent install feature is available
* @return true if silent install is supported (Other flavor only)
*/
fun supportsSilentInstall(): Boolean = false
/**
* Check if auto update feature is available
* @return true if auto update is supported (Other flavor only)
*/
fun supportsAutoUpdate(): Boolean = false
/**
* Schedule auto update worker
*/
fun scheduleAutoUpdate() {} fun scheduleAutoUpdate() {}
/**
* Verify if the specified silent install method is available
* @param method The install method (SHIZUKU or ROOT)
* @return true if the method is available and working
*/
suspend fun verifySilentInstallMethod(method: String): Boolean = false suspend fun verifySilentInstallMethod(method: String): Boolean = false
/**
* Download and install an APK update
* @param context The context
* @param downloadUrl The URL to download the APK from
* @throws Exception if download or install fails
*/
suspend fun downloadAndInstall(context: android.content.Context, downloadUrl: String): Unit = throw UnsupportedOperationException("Not supported in this flavor") suspend fun downloadAndInstall(context: android.content.Context, downloadUrl: String): Unit = throw UnsupportedOperationException("Not supported in this flavor")
} }
@@ -1,49 +0,0 @@
package io.nekohasekai.sfa.xposed
import android.content.Context
import io.nekohasekai.sfa.xposed.hooks.HookIConnectivityManagerOnTransact
import io.nekohasekai.sfa.xposed.hooks.hidevpn.ConnectivityServiceHookHelper
import io.nekohasekai.sfa.xposed.hooks.hidevpn.HookNetworkCapabilitiesWriteToParcel
import io.nekohasekai.sfa.xposed.hooks.hidevpn.HookNetworkInterfaceGetName
import io.nekohasekai.sfa.xposed.hooks.hidevpnapp.HookPackageManagerGetInstalledPackages
object HookInstaller {
private const val TAG = "XposedInit"
private val activityThreadClass by lazy { Class.forName("android.app.ActivityThread") }
private val currentActivityThreadMethod by lazy { activityThreadClass.getMethod("currentActivityThread") }
private val getSystemContextMethod by lazy { activityThreadClass.getMethod("getSystemContext") }
fun install(classLoader: ClassLoader) {
val systemContext = resolveSystemContext()
HookErrorStore.i(TAG, "handleSystemServerLoaded")
val hooks = arrayOf(
ConnectivityServiceHookHelper(classLoader),
HookIConnectivityManagerOnTransact(classLoader, systemContext),
HookPackageManagerGetInstalledPackages(classLoader),
HookNetworkCapabilitiesWriteToParcel(),
HookNetworkInterfaceGetName(classLoader),
)
hooks.forEach { hook ->
try {
hook.injectHook()
} catch (e: Throwable) {
HookErrorStore.e(
TAG,
"Failed to inject ${hook.javaClass.simpleName}",
e,
)
}
}
}
private fun resolveSystemContext(): Context? = try {
val currentThread = currentActivityThreadMethod.invoke(null)
getSystemContextMethod.invoke(currentThread) as? Context
} catch (e: Throwable) {
HookErrorStore.e(TAG, "resolveSystemContext failed", e)
null
}
}
@@ -1,16 +1,54 @@
package io.nekohasekai.sfa.xposed package io.nekohasekai.sfa.xposed
import android.content.Context
import io.github.libxposed.api.XposedInterface import io.github.libxposed.api.XposedInterface
import io.github.libxposed.api.XposedModule import io.github.libxposed.api.XposedModule
import io.github.libxposed.api.XposedModuleInterface import io.github.libxposed.api.XposedModuleInterface
import io.nekohasekai.sfa.xposed.hooks.HookIConnectivityManagerOnTransact
import io.nekohasekai.sfa.xposed.hooks.hidevpn.ConnectivityServiceHookHelper
import io.nekohasekai.sfa.xposed.hooks.hidevpn.HookNetworkCapabilitiesWriteToParcel
import io.nekohasekai.sfa.xposed.hooks.hidevpn.HookNetworkInterfaceGetName
import io.nekohasekai.sfa.xposed.hooks.hidevpnapp.HookPackageManagerGetInstalledPackages
class XposedInit(base: XposedInterface, param: XposedModuleInterface.ModuleLoadedParam) : XposedModule(base, param) { class XposedInit(base: XposedInterface, param: XposedModuleInterface.ModuleLoadedParam) : XposedModule(base, param) {
private val activityThreadClass by lazy { Class.forName("android.app.ActivityThread") }
private val currentActivityThreadMethod by lazy { activityThreadClass.getMethod("currentActivityThread") }
private val getSystemContextMethod by lazy { activityThreadClass.getMethod("getSystemContext") }
override fun onSystemServerLoaded(param: XposedModuleInterface.SystemServerLoadedParam) { override fun onSystemServerLoaded(param: XposedModuleInterface.SystemServerLoadedParam) {
HookInstaller.install(param.classLoader) val systemContext = resolveSystemContext()
HookErrorStore.i("XposedInit", "handleSystemServerLoaded")
val hooks = arrayOf(
ConnectivityServiceHookHelper(param.classLoader),
HookIConnectivityManagerOnTransact(param.classLoader, systemContext),
HookPackageManagerGetInstalledPackages(param.classLoader),
HookNetworkCapabilitiesWriteToParcel(),
HookNetworkInterfaceGetName(param.classLoader),
)
hooks.forEach { hook ->
try {
hook.injectHook()
} catch (e: Throwable) {
HookErrorStore.e(
"XposedInit",
"Failed to inject ${hook.javaClass.simpleName}",
e,
)
}
}
} }
companion object { companion object {
const val TAG = "sing-box-lsposed" const val TAG = "sing-box-lsposed"
} }
private fun resolveSystemContext(): Context? = try {
val currentThread = currentActivityThreadMethod.invoke(null)
getSystemContextMethod.invoke(currentThread) as? Context
} catch (e: Throwable) {
HookErrorStore.e("XposedInit", "resolveSystemContext failed", e)
null
}
} }
@@ -1,11 +0,0 @@
package io.nekohasekai.sfa.xposed
import io.github.libxposed.api.XposedModule
import io.github.libxposed.api.XposedModuleInterface
class XposedInit101 : XposedModule() {
override fun onSystemServerStarting(param: XposedModuleInterface.SystemServerStartingParam) {
HookInstaller.install(param.classLoader)
}
}
@@ -6,7 +6,6 @@ import android.net.Network
import android.net.NetworkInfo import android.net.NetworkInfo
import android.os.Build import android.os.Build
import android.os.IBinder import android.os.IBinder
import android.os.Parcel
import de.robv.android.xposed.XC_MethodHook import de.robv.android.xposed.XC_MethodHook
import de.robv.android.xposed.XposedHelpers import de.robv.android.xposed.XposedHelpers
import io.nekohasekai.sfa.xposed.HookErrorStore import io.nekohasekai.sfa.xposed.HookErrorStore
@@ -27,7 +26,6 @@ class ConnectivityServiceHookHelper(private val classLoader: ClassLoader) : XHoo
private val hooked = AtomicBoolean(false) private val hooked = AtomicBoolean(false)
private val initializerHooked = AtomicBoolean(false) private val initializerHooked = AtomicBoolean(false)
private var classLoadUnhook: XC_MethodHook.Unhook? = null private var classLoadUnhook: XC_MethodHook.Unhook? = null
private var onTransactUnhook: XC_MethodHook.Unhook? = null
private val serviceManagerHooked = AtomicBoolean(false) private val serviceManagerHooked = AtomicBoolean(false)
private var connectivityClassLoader: ClassLoader = classLoader private var connectivityClassLoader: ClassLoader = classLoader
private val skipLogKeys = ConcurrentHashMap<String, Boolean>() private val skipLogKeys = ConcurrentHashMap<String, Boolean>()
@@ -55,7 +53,6 @@ class ConnectivityServiceHookHelper(private val classLoader: ClassLoader) : XHoo
} }
hookConnectivityServiceInitializer() hookConnectivityServiceInitializer()
hookClassLoaderFallback() hookClassLoaderFallback()
hookOnTransactFallback()
tryHookFromServiceManager() tryHookFromServiceManager()
} }
@@ -151,39 +148,12 @@ class ConnectivityServiceHookHelper(private val classLoader: ClassLoader) : XHoo
} }
} }
HookErrorStore.i(SOURCE, "ConnectivityService class not found in known classloaders") HookErrorStore.i(SOURCE, "ConnectivityService class not found in known classloaders")
val initializerNames = listOf(
"com.android.server.ConnectivityServiceInitializer",
"com.android.server.ConnectivityServiceInitializerB",
)
for (name in initializerNames) {
for (loader in loaders) {
val initCls = try {
if (loader != null) Class.forName(name, false, loader) else Class.forName(name)
} catch (_: Throwable) {
null
} ?: continue
try {
val field = initCls.getDeclaredField("mConnectivity")
val fieldType = field.type
if (fieldType.name.endsWith(".ConnectivityService")) {
HookErrorStore.i(
SOURCE,
"ConnectivityService class found via $name.mConnectivity: ${fieldType.name}",
)
return fieldType
}
} catch (_: Throwable) {
}
}
}
return null return null
} }
private fun hookConnectivityServiceInitializer() { private fun hookConnectivityServiceInitializer() {
if (sdkInt < 31) { if (sdkInt < 31 || sdkInt >= 33) {
HookErrorStore.d(SOURCE, "Skip ConnectivityServiceInitializer: sdk=$sdkInt (requires API 31+)") HookErrorStore.d(SOURCE, "Skip ConnectivityServiceInitializer: sdk=$sdkInt (only exists in API 31-32)")
return return
} }
val candidates = listOf( val candidates = listOf(
@@ -268,20 +238,20 @@ class ConnectivityServiceHookHelper(private val classLoader: ClassLoader) : XHoo
classLoadUnhook = null classLoadUnhook = null
return return
} }
when { when (name) {
name == "com.android.server.ConnectivityService" || "com.android.server.ConnectivityService" -> {
name.endsWith(".com.android.server.ConnectivityService") -> {
val cls = param.result as? Class<*> ?: return val cls = param.result as? Class<*> ?: return
HookErrorStore.i( HookErrorStore.i(
SOURCE, SOURCE,
"ConnectivityService loaded via ${param.thisObject.javaClass.name}: $name", "ConnectivityService loaded via ${param.thisObject.javaClass.name}",
) )
installHooks(cls, "loadClass") installHooks(cls, "loadClass")
classLoadUnhook?.unhook() classLoadUnhook?.unhook()
classLoadUnhook = null classLoadUnhook = null
} }
name == "com.android.server.ConnectivityServiceInitializer" || "com.android.server.ConnectivityServiceInitializer",
name == "com.android.server.ConnectivityServiceInitializerB" -> { "com.android.server.ConnectivityServiceInitializerB",
-> {
if (sdkInt < 31) return if (sdkInt < 31) return
if (initializerHooked.get()) return if (initializerHooked.get()) return
val cls = param.result as? Class<*> ?: return val cls = param.result as? Class<*> ?: return
@@ -352,41 +322,6 @@ class ConnectivityServiceHookHelper(private val classLoader: ClassLoader) : XHoo
} }
} }
private fun hookOnTransactFallback() {
if (onTransactUnhook != null) return
try {
val stub = XposedHelpers.findClass("android.net.IConnectivityManager\$Stub", classLoader)
onTransactUnhook = XposedHelpers.findAndHookMethod(
stub,
"onTransact",
Int::class.javaPrimitiveType,
Parcel::class.java,
Parcel::class.java,
Int::class.javaPrimitiveType,
object : SafeMethodHook(SOURCE) {
override fun beforeHook(param: MethodHookParam) {
if (hooked.get()) {
onTransactUnhook?.unhook()
onTransactUnhook = null
return
}
val serviceClass = param.thisObject.javaClass
HookErrorStore.i(
SOURCE,
"ConnectivityService discovered via onTransact: ${serviceClass.name}",
)
installHooks(serviceClass, "onTransact")
onTransactUnhook?.unhook()
onTransactUnhook = null
}
},
)
HookErrorStore.i(SOURCE, "Hooked IConnectivityManager.Stub.onTransact for discovery")
} catch (e: Throwable) {
HookErrorStore.w(SOURCE, "Hook onTransact fallback failed: ${e.message}", e)
}
}
private fun hookConnectivityServiceInitializerClass(cls: Class<*>) { private fun hookConnectivityServiceInitializerClass(cls: Class<*>) {
if (sdkInt < 31) return if (sdkInt < 31) return
if (initializerHooked.get()) return if (initializerHooked.get()) return
-4
View File
@@ -198,16 +198,12 @@
<string name="source_code">کد منبع</string> <string name="source_code">کد منبع</string>
<string name="sponsor">حامی مالی</string> <string name="sponsor">حامی مالی</string>
<string name="working_directory">پوشه کاری</string> <string name="working_directory">پوشه کاری</string>
<string name="beta_settings">تنظیمات بتا</string>
<string name="disable_deprecated_warnings">غیرفعال‌کردن هشدارهای منسوخ</string> <string name="disable_deprecated_warnings">غیرفعال‌کردن هشدارهای منسوخ</string>
<string name="notification_settings">اعلان‌ها</string> <string name="notification_settings">اعلان‌ها</string>
<string name="enable_notification">فعال‌کردن اعلان</string> <string name="enable_notification">فعال‌کردن اعلان</string>
<string name="dynamic_notification">نمایش سرعت بلادرنگ در اعلان</string> <string name="dynamic_notification">نمایش سرعت بلادرنگ در اعلان</string>
<string name="disable_notification_description">به دلیل محدودیت‌های اندروید، ابتدا باید مجوز اعلان را بدهید، سپس دسته‌بندی اعلان را در تنظیمات غیرفعال کنید.</string> <string name="disable_notification_description">به دلیل محدودیت‌های اندروید، ابتدا باید مجوز اعلان را بدهید، سپس دسته‌بندی اعلان را در تنظیمات غیرفعال کنید.</string>
<string name="disable_notification_description_legacy">به دلیل محدودیت‌های اندروید، ابتدا باید مجوز اعلان را بدهید، سپس اعلان‌ها را در اطلاعات برنامه غیرفعال کنید.</string> <string name="disable_notification_description_legacy">به دلیل محدودیت‌های اندروید، ابتدا باید مجوز اعلان را بدهید، سپس اعلان‌ها را در اطلاعات برنامه غیرفعال کنید.</string>
<string name="allow_bypass">اجازه دور زدن VPN</string>
<string name="allow_bypass_description">در صورت فعال بودن، برنامه‌ها می‌توانند این اتصال VPN را دور بزنند و مستقیماً از شبکه اصلی استفاده کنند.</string>
<string name="android_documentation">مستندات Android</string>
<string name="auto_redirect">تغییر مسیر خودکار</string> <string name="auto_redirect">تغییر مسیر خودکار</string>
<string name="auto_redirect_description">نیازمند دسترسی ROOT</string> <string name="auto_redirect_description">نیازمند دسترسی ROOT</string>
<string name="system_http_proxy">پراکسی HTTP سیستم</string> <string name="system_http_proxy">پراکسی HTTP سیستم</string>
@@ -198,16 +198,12 @@
<string name="source_code">Исходный код</string> <string name="source_code">Исходный код</string>
<string name="sponsor">Поддержать</string> <string name="sponsor">Поддержать</string>
<string name="working_directory">Рабочая директория</string> <string name="working_directory">Рабочая директория</string>
<string name="beta_settings">Бета-настройки</string>
<string name="disable_deprecated_warnings">Отключить предупреждения об устаревании</string> <string name="disable_deprecated_warnings">Отключить предупреждения об устаревании</string>
<string name="notification_settings">Уведомления</string> <string name="notification_settings">Уведомления</string>
<string name="enable_notification">Включить уведомления</string> <string name="enable_notification">Включить уведомления</string>
<string name="dynamic_notification">Отображать скорость в реальном времени в уведомлении</string> <string name="dynamic_notification">Отображать скорость в реальном времени в уведомлении</string>
<string name="disable_notification_description">Из-за ограничений Android необходимо сначала предоставить разрешение на уведомления, а затем отключить категорию уведомлений в настройках.</string> <string name="disable_notification_description">Из-за ограничений Android необходимо сначала предоставить разрешение на уведомления, а затем отключить категорию уведомлений в настройках.</string>
<string name="disable_notification_description_legacy">Из-за ограничений Android необходимо сначала предоставить разрешение на уведомления, а затем отключить уведомления в сведениях о приложении.</string> <string name="disable_notification_description_legacy">Из-за ограничений Android необходимо сначала предоставить разрешение на уведомления, а затем отключить уведомления в сведениях о приложении.</string>
<string name="allow_bypass">Разрешить обход VPN</string>
<string name="allow_bypass_description">Если включено, приложения могут обойти это VPN-соединение и использовать базовую сеть напрямую.</string>
<string name="android_documentation">Документация Android</string>
<string name="auto_redirect">Автоматическое перенаправление</string> <string name="auto_redirect">Автоматическое перенаправление</string>
<string name="auto_redirect_description">Требуются права ROOT</string> <string name="auto_redirect_description">Требуются права ROOT</string>
<string name="system_http_proxy">Системный HTTP-прокси</string> <string name="system_http_proxy">Системный HTTP-прокси</string>
+1 -23
View File
@@ -198,18 +198,12 @@
<string name="source_code">源代码</string> <string name="source_code">源代码</string>
<string name="sponsor">赞助</string> <string name="sponsor">赞助</string>
<string name="working_directory">工作目录</string> <string name="working_directory">工作目录</string>
<string name="beta_settings">Beta 版设置</string>
<string name="disable_deprecated_warnings">禁用弃用警告</string> <string name="disable_deprecated_warnings">禁用弃用警告</string>
<string name="cache_size">缓存大小</string>
<string name="clear_cache">清除缓存</string>
<string name="notification_settings">通知</string> <string name="notification_settings">通知</string>
<string name="enable_notification">启用通知</string> <string name="enable_notification">启用通知</string>
<string name="dynamic_notification">在通知中显示实时网速</string> <string name="dynamic_notification">在通知中显示实时网速</string>
<string name="disable_notification_description">由于 Android 限制,您需要先授权通知权限,然后前往系统设置中关闭通知类别。</string> <string name="disable_notification_description">由于 Android 限制,您需要先授权通知权限,然后前往系统设置中关闭通知类别。</string>
<string name="disable_notification_description_legacy">由于 Android 限制,您需要先授权通知权限,然后前往应用信息中关闭通知。</string> <string name="disable_notification_description_legacy">由于 Android 限制,您需要先授权通知权限,然后前往应用信息中关闭通知。</string>
<string name="allow_bypass">允许绕过 VPN</string>
<string name="allow_bypass_description">启用后,应用可以绕过此 VPN 连接,直接使用底层网络。</string>
<string name="android_documentation">Android 文档</string>
<string name="auto_redirect">自动重定向</string> <string name="auto_redirect">自动重定向</string>
<string name="auto_redirect_description">需要 ROOT 权限</string> <string name="auto_redirect_description">需要 ROOT 权限</string>
<string name="system_http_proxy">系统 HTTP 代理</string> <string name="system_http_proxy">系统 HTTP 代理</string>
@@ -272,7 +266,7 @@
<string name="check_update_prompt_github">是否启用从 **GitHub** 自动检查更新?</string> <string name="check_update_prompt_github">是否启用从 **GitHub** 自动检查更新?</string>
<string name="update_track">更新轨道</string> <string name="update_track">更新轨道</string>
<string name="update_track_stable">稳定版</string> <string name="update_track_stable">稳定版</string>
<string name="update_track_beta">Beta </string> <string name="update_track_beta">测试</string>
<string name="update_track_not_supported">当前轨道尚不支持检查更新</string> <string name="update_track_not_supported">当前轨道尚不支持检查更新</string>
<string name="view_release">查看发布</string> <string name="view_release">查看发布</string>
<string name="downloading">下载中…</string> <string name="downloading">下载中…</string>
@@ -281,22 +275,6 @@
<string name="new_version_available">有新版本可用:%s</string> <string name="new_version_available">有新版本可用:%s</string>
<string name="auto_update">自动更新</string> <string name="auto_update">自动更新</string>
<string name="auto_update_description">在后台自动下载和安装更新</string> <string name="auto_update_description">在后台自动下载和安装更新</string>
<string name="update_source">更新来源</string>
<string name="update_source_github">GitHub</string>
<string name="update_source_fdroid">F-Droid</string>
<string name="fdroid_mirror">F-Droid 镜像</string>
<string name="fdroid_mirror_test_all">根据延迟自动选择</string>
<string name="fdroid_mirror_testing">测试中…</string>
<string name="fdroid_mirror_latency">%d ms</string>
<string name="fdroid_mirror_failed">失败</string>
<string name="fdroid_mirror_untested"></string>
<string name="fdroid_mirror_add">添加镜像</string>
<string name="fdroid_mirror_name_hint">名称</string>
<string name="fdroid_mirror_url_hint">URL</string>
<string name="fdroid_mirror_custom">自定义</string>
<string name="fdroid_mirror_invalid_url">无效的 URL</string>
<string name="fdroid_mirror_add_action">添加</string>
<string name="fdroid_mirror_delete">删除</string>
<!-- Silent Install --> <!-- Silent Install -->
<string name="silent_install">静默安装</string> <string name="silent_install">静默安装</string>
+1 -23
View File
@@ -198,18 +198,12 @@
<string name="source_code">原始碼</string> <string name="source_code">原始碼</string>
<string name="sponsor">贊助</string> <string name="sponsor">贊助</string>
<string name="working_directory">工作目錄</string> <string name="working_directory">工作目錄</string>
<string name="beta_settings">Beta 版設定</string>
<string name="disable_deprecated_warnings">停用過時警告</string> <string name="disable_deprecated_warnings">停用過時警告</string>
<string name="cache_size">快取大小</string>
<string name="clear_cache">清除快取</string>
<string name="notification_settings">通知</string> <string name="notification_settings">通知</string>
<string name="enable_notification">啟用通知</string> <string name="enable_notification">啟用通知</string>
<string name="dynamic_notification">在通知中顯示即時網速</string> <string name="dynamic_notification">在通知中顯示即時網速</string>
<string name="disable_notification_description">由於 Android 限制,您需要先授權通知權限,然後前往系統設定中關閉通知類別。</string> <string name="disable_notification_description">由於 Android 限制,您需要先授權通知權限,然後前往系統設定中關閉通知類別。</string>
<string name="disable_notification_description_legacy">由於 Android 限制,您需要先授權通知權限,然後前往應用程式資訊中關閉通知。</string> <string name="disable_notification_description_legacy">由於 Android 限制,您需要先授權通知權限,然後前往應用程式資訊中關閉通知。</string>
<string name="allow_bypass">允許繞過 VPN</string>
<string name="allow_bypass_description">啟用後,應用程式可以繞過此 VPN 連線,直接使用底層網路。</string>
<string name="android_documentation">Android 文件</string>
<string name="auto_redirect">自動重定向</string> <string name="auto_redirect">自動重定向</string>
<string name="auto_redirect_description">需要 ROOT 權限</string> <string name="auto_redirect_description">需要 ROOT 權限</string>
<string name="system_http_proxy">系統 HTTP 代理</string> <string name="system_http_proxy">系統 HTTP 代理</string>
@@ -272,7 +266,7 @@
<string name="check_update_prompt_github">是否啟用從 **GitHub** 自動檢查更新?</string> <string name="check_update_prompt_github">是否啟用從 **GitHub** 自動檢查更新?</string>
<string name="update_track">更新通道</string> <string name="update_track">更新通道</string>
<string name="update_track_stable">穩定版</string> <string name="update_track_stable">穩定版</string>
<string name="update_track_beta">Beta </string> <string name="update_track_beta">測試</string>
<string name="update_track_not_supported">目前通道尚不支援檢查更新</string> <string name="update_track_not_supported">目前通道尚不支援檢查更新</string>
<string name="view_release">查看發布</string> <string name="view_release">查看發布</string>
<string name="downloading">下載中…</string> <string name="downloading">下載中…</string>
@@ -281,22 +275,6 @@
<string name="new_version_available">有新版本可用:%s</string> <string name="new_version_available">有新版本可用:%s</string>
<string name="auto_update">自動更新</string> <string name="auto_update">自動更新</string>
<string name="auto_update_description">在背景自動下載並安裝更新</string> <string name="auto_update_description">在背景自動下載並安裝更新</string>
<string name="update_source">更新來源</string>
<string name="update_source_github">GitHub</string>
<string name="update_source_fdroid">F-Droid</string>
<string name="fdroid_mirror">F-Droid 鏡像</string>
<string name="fdroid_mirror_test_all">依延遲自動選擇</string>
<string name="fdroid_mirror_testing">測試中…</string>
<string name="fdroid_mirror_latency">%d ms</string>
<string name="fdroid_mirror_failed">失敗</string>
<string name="fdroid_mirror_untested"></string>
<string name="fdroid_mirror_add">新增鏡像</string>
<string name="fdroid_mirror_name_hint">名稱</string>
<string name="fdroid_mirror_url_hint">URL</string>
<string name="fdroid_mirror_custom">自訂</string>
<string name="fdroid_mirror_invalid_url">無效的 URL</string>
<string name="fdroid_mirror_add_action">新增</string>
<string name="fdroid_mirror_delete">刪除</string>
<!-- Silent Install --> <!-- Silent Install -->
<string name="silent_install">靜默安裝</string> <string name="silent_install">靜默安裝</string>
-22
View File
@@ -198,18 +198,12 @@
<string name="source_code">Source Code</string> <string name="source_code">Source Code</string>
<string name="sponsor">Sponsor</string> <string name="sponsor">Sponsor</string>
<string name="working_directory">Working Directory</string> <string name="working_directory">Working Directory</string>
<string name="beta_settings">Beta Settings</string>
<string name="disable_deprecated_warnings">Disable Deprecated Warnings</string> <string name="disable_deprecated_warnings">Disable Deprecated Warnings</string>
<string name="cache_size">Cache Size</string>
<string name="clear_cache">Clear Cache</string>
<string name="notification_settings">Notification</string> <string name="notification_settings">Notification</string>
<string name="enable_notification">Enable Notification</string> <string name="enable_notification">Enable Notification</string>
<string name="dynamic_notification">Display realtime speed in notification</string> <string name="dynamic_notification">Display realtime speed in notification</string>
<string name="disable_notification_description">Due to Android restrictions, you must first grant notification permission, then go to Settings to disable the notification category.</string> <string name="disable_notification_description">Due to Android restrictions, you must first grant notification permission, then go to Settings to disable the notification category.</string>
<string name="disable_notification_description_legacy">Due to Android restrictions, you must first grant notification permission, then go to App Info to disable notifications.</string> <string name="disable_notification_description_legacy">Due to Android restrictions, you must first grant notification permission, then go to App Info to disable notifications.</string>
<string name="allow_bypass">Allow Bypass</string>
<string name="allow_bypass_description">If enabled, applications can bypass this VPN connection and instead use the underlying network directly.</string>
<string name="android_documentation">Android Documentation</string>
<string name="auto_redirect">Auto Redirect</string> <string name="auto_redirect">Auto Redirect</string>
<string name="auto_redirect_description">ROOT permission required</string> <string name="auto_redirect_description">ROOT permission required</string>
<string name="system_http_proxy">System HTTP Proxy</string> <string name="system_http_proxy">System HTTP Proxy</string>
@@ -270,9 +264,6 @@
<string name="check_update_automatic">Automatic Update Check</string> <string name="check_update_automatic">Automatic Update Check</string>
<string name="check_update_prompt_play">Would you like to enable automatic update checking from **Play Store**?</string> <string name="check_update_prompt_play">Would you like to enable automatic update checking from **Play Store**?</string>
<string name="check_update_prompt_github">Would you like to enable automatic update checking from **GitHub**?</string> <string name="check_update_prompt_github">Would you like to enable automatic update checking from **GitHub**?</string>
<string name="update_source">Update Source</string>
<string name="update_source_github">GitHub</string>
<string name="update_source_fdroid">F-Droid</string>
<string name="update_track">Update Track</string> <string name="update_track">Update Track</string>
<string name="update_track_stable">Stable</string> <string name="update_track_stable">Stable</string>
<string name="update_track_beta">Beta</string> <string name="update_track_beta">Beta</string>
@@ -284,19 +275,6 @@
<string name="new_version_available">New version available: %s</string> <string name="new_version_available">New version available: %s</string>
<string name="auto_update">Auto Update</string> <string name="auto_update">Auto Update</string>
<string name="auto_update_description">Automatically download and install updates in background</string> <string name="auto_update_description">Automatically download and install updates in background</string>
<string name="fdroid_mirror">F-Droid Mirror</string>
<string name="fdroid_mirror_test_all">Auto Select by Latency</string>
<string name="fdroid_mirror_testing">Testing…</string>
<string name="fdroid_mirror_latency">%d ms</string>
<string name="fdroid_mirror_failed">Failed</string>
<string name="fdroid_mirror_untested"></string>
<string name="fdroid_mirror_add">Add Mirror</string>
<string name="fdroid_mirror_name_hint">Name</string>
<string name="fdroid_mirror_url_hint">URL</string>
<string name="fdroid_mirror_custom">Custom</string>
<string name="fdroid_mirror_invalid_url">Invalid URL</string>
<string name="fdroid_mirror_add_action">Add</string>
<string name="fdroid_mirror_delete">Delete</string>
<!-- Silent Install --> <!-- Silent Install -->
<string name="silent_install">Silent Install</string> <string name="silent_install">Silent Install</string>
-3
View File
@@ -3,7 +3,4 @@
<cache-path <cache-path
name="cache" name="cache"
path="/" /> path="/" />
<external-files-path
name="external_files"
path="/" />
</paths> </paths>
@@ -1,2 +1 @@
io.nekohasekai.sfa.xposed.XposedInit io.nekohasekai.sfa.xposed.XposedInit
io.nekohasekai.sfa.xposed.XposedInit101
@@ -1,3 +1,3 @@
minApiVersion=100 minApiVersion=100
targetApiVersion=101 targetApiVersion=100
staticScope=true staticScope=true
+7 -10
View File
@@ -13,10 +13,8 @@ import io.nekohasekai.sfa.compose.screen.qrscan.QRCodeCropArea
import io.nekohasekai.sfa.database.Settings import io.nekohasekai.sfa.database.Settings
import io.nekohasekai.sfa.update.UpdateCheckException import io.nekohasekai.sfa.update.UpdateCheckException
import io.nekohasekai.sfa.update.UpdateInfo import io.nekohasekai.sfa.update.UpdateInfo
import io.nekohasekai.sfa.update.UpdateSource
import io.nekohasekai.sfa.update.UpdateState import io.nekohasekai.sfa.update.UpdateState
import io.nekohasekai.sfa.update.UpdateTrack import io.nekohasekai.sfa.update.UpdateTrack
import io.nekohasekai.sfa.update.checkFDroidUpdate
object Vendor : VendorInterface { object Vendor : VendorInterface {
private const val TAG = "Vendor" private const val TAG = "Vendor"
@@ -95,19 +93,18 @@ object Vendor : VendorInterface {
onCropArea: ((QRCodeCropArea?) -> Unit)?, onCropArea: ((QRCodeCropArea?) -> Unit)?,
): ImageAnalysis.Analyzer? = null ): ImageAnalysis.Analyzer? = null
override val hasCustomUpdate = true override fun supportsTrackSelection(): Boolean = true
override val updateSources = listOf(UpdateSource.GITHUB, UpdateSource.FDROID) override fun checkUpdateAsync(): UpdateInfo? {
override fun checkUpdateAsync(): UpdateInfo? = when (UpdateSource.fromString(Settings.updateSource)) {
UpdateSource.FDROID -> checkFDroidUpdate(Application.application)
UpdateSource.GITHUB -> {
val track = UpdateTrack.fromString(Settings.updateTrack) val track = UpdateTrack.fromString(Settings.updateTrack)
GitHubUpdateChecker().use { checker -> return GitHubUpdateChecker().use { checker ->
checker.checkUpdate(track) checker.checkUpdate(track)
} }
} }
}
override fun supportsSilentInstall(): Boolean = true
override fun supportsAutoUpdate(): Boolean = true
override fun scheduleAutoUpdate() { override fun scheduleAutoUpdate() {
UpdateWorker.schedule(io.nekohasekai.sfa.Application.application) UpdateWorker.schedule(io.nekohasekai.sfa.Application.application)
@@ -93,7 +93,7 @@ object Vendor : VendorInterface {
onCropArea: ((QRCodeCropArea?) -> Unit)?, onCropArea: ((QRCodeCropArea?) -> Unit)?,
): ImageAnalysis.Analyzer? = null ): ImageAnalysis.Analyzer? = null
override val hasCustomUpdate = true override fun supportsTrackSelection(): Boolean = true
override fun checkUpdateAsync(): UpdateInfo? { override fun checkUpdateAsync(): UpdateInfo? {
val track = UpdateTrack.fromString(Settings.updateTrack) val track = UpdateTrack.fromString(Settings.updateTrack)
@@ -102,6 +102,10 @@ object Vendor : VendorInterface {
} }
} }
override fun supportsSilentInstall(): Boolean = true
override fun supportsAutoUpdate(): Boolean = true
override fun scheduleAutoUpdate() { override fun scheduleAutoUpdate() {
UpdateWorker.schedule(io.nekohasekai.sfa.Application.application) UpdateWorker.schedule(io.nekohasekai.sfa.Application.application)
} }
+2
View File
@@ -92,5 +92,7 @@ object Vendor : VendorInterface {
} }
} }
override fun supportsTrackSelection(): Boolean = false
override fun checkUpdateAsync(): UpdateInfo? = null override fun checkUpdateAsync(): UpdateInfo? = null
} }
+1 -1
View File
@@ -6,7 +6,7 @@
# http://www.gradle.org/docs/current/userguide/build_environment.html # http://www.gradle.org/docs/current/userguide/build_environment.html
# Specifies the JVM arguments used for the daemon process. # Specifies the JVM arguments used for the daemon process.
# The setting is particularly useful for tweaking memory settings. # The setting is particularly useful for tweaking memory settings.
org.gradle.jvmargs=-Xmx4096m -Dfile.encoding=UTF-8 org.gradle.jvmargs=-Xmx8192m -Dfile.encoding=UTF-8
# When configured, Gradle will run in incubating parallel mode. # When configured, Gradle will run in incubating parallel mode.
# This option should only be used with decoupled projects. More details, visit # This option should only be used with decoupled projects. More details, visit
# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects # http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects
+1 -1
View File
@@ -1,5 +1,5 @@
[versions] [versions]
spotless = "8.2.1" spotless = "8.1.0"
ktlint = "1.7.1" ktlint = "1.7.1"
[plugins] [plugins]
+1 -1
View File
@@ -1,7 +1,7 @@
#Mon Jul 07 14:05:29 CST 2025 #Mon Jul 07 14:05:29 CST 2025
distributionBase=GRADLE_USER_HOME distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.1-bin.zip distributionUrl=https\://services.gradle.org/distributions/gradle-9.1.0-bin.zip
networkTimeout=10000 networkTimeout=10000
validateDistributionUrl=true validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME zipStoreBase=GRADLE_USER_HOME
@@ -21,19 +21,9 @@ import io.github.libxposed.api.utils.DexParser;
*/ */
public class XposedInterfaceWrapper implements XposedInterface { public class XposedInterfaceWrapper implements XposedInterface {
private volatile XposedInterface mBase; private final XposedInterface mBase;
public XposedInterfaceWrapper() { XposedInterfaceWrapper(@NonNull XposedInterface base) {
}
public XposedInterfaceWrapper(@NonNull XposedInterface base) {
mBase = base;
}
public final void attachFramework(@NonNull XposedInterface base) {
if (mBase != null) {
throw new IllegalStateException("Framework already attached");
}
mBase = base; mBase = base;
} }
@@ -9,16 +9,11 @@ import androidx.annotation.NonNull;
@SuppressWarnings("unused") @SuppressWarnings("unused")
public abstract class XposedModule extends XposedInterfaceWrapper implements XposedModuleInterface { public abstract class XposedModule extends XposedInterfaceWrapper implements XposedModuleInterface {
/** /**
* No-arg constructor for API 101 contract: the framework instantiates the module via * Instantiates a new Xposed module.<br/>
* {@code Class.getDeclaredConstructor()}, then calls {@link #attachFramework}. * When the module is loaded into the target process, the constructor will be called.
*/ *
public XposedModule() { * @param base The implementation interface provided by the framework, should not be used by the module
super(); * @param param Information about the process in which the module is loaded
}
/**
* Two-arg constructor for API 100 contract: the framework instantiates the module via
* {@code (XposedInterface, ModuleLoadedParam)} and attaches the framework base inline.
*/ */
public XposedModule(@NonNull XposedInterface base, @NonNull ModuleLoadedParam param) { public XposedModule(@NonNull XposedInterface base, @NonNull ModuleLoadedParam param) {
super(base); super(base);
@@ -1,6 +1,5 @@
package io.github.libxposed.api; package io.github.libxposed.api;
import android.app.AppComponentFactory;
import android.content.pm.ApplicationInfo; import android.content.pm.ApplicationInfo;
import android.os.Build; import android.os.Build;
@@ -33,7 +32,7 @@ public interface XposedModuleInterface {
} }
/** /**
* Wraps information about system server. API 100 flavor. * Wraps information about system server.
*/ */
interface SystemServerLoadedParam { interface SystemServerLoadedParam {
/** /**
@@ -45,26 +44,6 @@ public interface XposedModuleInterface {
ClassLoader getClassLoader(); ClassLoader getClassLoader();
} }
/**
* Wraps information about system server. API 101 flavor.
*/
interface SystemServerStartingParam {
@NonNull
ClassLoader getClassLoader();
}
/**
* Wraps information about a package whose classloader is ready. API 101.
*/
interface PackageReadyParam extends PackageLoadedParam {
@NonNull
ClassLoader getClassLoader();
@RequiresApi(Build.VERSION_CODES.P)
@NonNull
AppComponentFactory getAppComponentFactory();
}
/** /**
* Wraps information about the package being loaded. * Wraps information about the package being loaded.
*/ */
@@ -120,28 +99,10 @@ public interface XposedModuleInterface {
} }
/** /**
* Gets notified when the system server is loaded. API 100. * Gets notified when the system server is loaded.
* *
* @param param Information about system server * @param param Information about system server
*/ */
default void onSystemServerLoaded(@NonNull SystemServerLoadedParam param) { default void onSystemServerLoaded(@NonNull SystemServerLoadedParam param) {
} }
/**
* API 101: invoked once per process after the module instance is attached.
*/
default void onModuleLoaded(@NonNull ModuleLoadedParam param) {
}
/**
* API 101: invoked when a package's classloader is ready.
*/
default void onPackageReady(@NonNull PackageReadyParam param) {
}
/**
* API 101: replaces {@link #onSystemServerLoaded(SystemServerLoadedParam)}.
*/
default void onSystemServerStarting(@NonNull SystemServerStartingParam param) {
}
} }
+3 -3
View File
@@ -1,5 +1,5 @@
VERSION_CODE=662 VERSION_CODE=627
VERSION_NAME=1.13.11 VERSION_NAME=1.13.0-rc.7
GO_VERSION=go1.25.9 GO_VERSION=go1.25.7