Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4bfd43af1a | ||
|
|
3228793f4a |
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)) {
|
val track = UpdateTrack.fromString(Settings.updateTrack)
|
||||||
UpdateSource.FDROID -> checkFDroidUpdate(appContext)
|
val updateInfo = GitHubUpdateChecker().use { it.checkUpdate(track) }
|
||||||
UpdateSource.GITHUB -> {
|
|
||||||
val track = UpdateTrack.fromString(Settings.updateTrack)
|
|
||||||
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")
|
||||||
val files = root.listFiles() ?: emptyArray()
|
for (root in roots) {
|
||||||
for (file in files) {
|
if (!root.isDirectory) continue
|
||||||
if (!file.isFile) continue
|
val destPrefix = "framework/${root.name}"
|
||||||
if (file.name !in targetFiles) continue
|
val files = root.listFiles() ?: emptyArray()
|
||||||
if (addFileEntry(zip, file, "framework/${file.name}", warnings, noCompression = true)) {
|
for (file in files) {
|
||||||
count++
|
if (!file.isFile) continue
|
||||||
|
if (file.name !in targetFiles) continue
|
||||||
|
if (addFileEntry(zip, file, "$destPrefix/${file.name}", warnings)) {
|
||||||
|
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,22 +565,10 @@ 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()}%")
|
Text(stringResource(R.string.downloading))
|
||||||
} else {
|
|
||||||
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,
|
||||||
|
|||||||
+3
-6
@@ -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
|
||||||
|
|
||||||
|
|||||||
+3
-3
@@ -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
|
||||||
|
|||||||
+1
-1
@@ -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,19 +81,15 @@ 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() {
|
||||||
@@ -108,25 +104,23 @@ 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 {
|
val totalSize = allLogs.size + processedLogs.size
|
||||||
val totalSize = allLogs.size + processedLogs.size
|
val removeCount = (totalSize - maxLines).coerceAtLeast(0)
|
||||||
val removeCount = (totalSize - maxLines).coerceAtLeast(0)
|
|
||||||
|
|
||||||
if (removeCount > 0) {
|
if (removeCount > 0) {
|
||||||
repeat(removeCount) {
|
repeat(removeCount) {
|
||||||
allLogs.removeFirst()
|
allLogs.removeFirst()
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
allLogs.addAll(processedLogs)
|
allLogs.addAll(processedLogs)
|
||||||
updateDisplayedLogs()
|
updateDisplayedLogs()
|
||||||
|
|
||||||
if (_autoScrollEnabled.value && !_uiState.value.isPaused && !_uiState.value.isSearchActive) {
|
if (_autoScrollEnabled.value && !_uiState.value.isPaused && !_uiState.value.isSearchActive) {
|
||||||
scrollToBottom()
|
scrollToBottom()
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-1
@@ -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
|
||||||
|
|||||||
+5
-3
@@ -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,
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
|||||||
+54
-370
@@ -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,22 +223,10 @@ 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()}%")
|
Text(stringResource(R.string.downloading))
|
||||||
} else {
|
|
||||||
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,70 +381,39 @@ fun AppSettingsScreen(navController: NavController) {
|
|||||||
),
|
),
|
||||||
) {
|
) {
|
||||||
Column {
|
Column {
|
||||||
Box {
|
ListItem(
|
||||||
ListItem(
|
headlineContent = {
|
||||||
headlineContent = {
|
Text(
|
||||||
Text(
|
stringResource(R.string.app_version_title),
|
||||||
stringResource(R.string.app_version_title),
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
style = MaterialTheme.typography.bodyLarge,
|
)
|
||||||
)
|
},
|
||||||
},
|
supportingContent = {
|
||||||
supportingContent = {
|
Text(
|
||||||
Text(
|
BuildConfig.VERSION_NAME,
|
||||||
BuildConfig.VERSION_NAME,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
)
|
||||||
)
|
},
|
||||||
},
|
leadingContent = {
|
||||||
leadingContent = {
|
Icon(
|
||||||
Icon(
|
imageVector = Icons.Outlined.Info,
|
||||||
imageVector = Icons.Outlined.Info,
|
contentDescription = null,
|
||||||
contentDescription = null,
|
tint = MaterialTheme.colorScheme.primary,
|
||||||
tint = MaterialTheme.colorScheme.primary,
|
)
|
||||||
)
|
},
|
||||||
},
|
trailingContent = {
|
||||||
trailingContent = {
|
if (hasUpdate) {
|
||||||
if (hasUpdate) {
|
Badge(containerColor = MaterialTheme.colorScheme.primary) { Text("New") }
|
||||||
Badge(containerColor = MaterialTheme.colorScheme.primary) { Text("New") }
|
|
||||||
}
|
|
||||||
},
|
|
||||||
modifier =
|
|
||||||
Modifier
|
|
||||||
.clip(RoundedCornerShape(topStart = 12.dp, topEnd = 12.dp))
|
|
||||||
.combinedClickable(
|
|
||||||
onClick = {},
|
|
||||||
onLongClick = { showVersionMenu = true },
|
|
||||||
),
|
|
||||||
colors =
|
|
||||||
ListItemDefaults.colors(
|
|
||||||
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
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
}
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.clip(RoundedCornerShape(topStart = 12.dp, topEnd = 12.dp)),
|
||||||
|
colors =
|
||||||
|
ListItemDefaults.colors(
|
||||||
|
containerColor = Color.Transparent,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
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,13 +601,9 @@ fun AppSettingsScreen(navController: NavController) {
|
|||||||
)
|
)
|
||||||
},
|
},
|
||||||
supportingContent = {
|
supportingContent = {
|
||||||
val trackName = if (isFDroid) {
|
val trackName = when (UpdateTrack.fromString(currentTrack)) {
|
||||||
stringResource(R.string.update_track_stable)
|
UpdateTrack.STABLE -> stringResource(R.string.update_track_stable)
|
||||||
} else {
|
UpdateTrack.BETA -> stringResource(R.string.update_track_beta)
|
||||||
when (UpdateTrack.fromString(currentTrack)) {
|
|
||||||
UpdateTrack.STABLE -> stringResource(R.string.update_track_stable)
|
|
||||||
UpdateTrack.BETA -> stringResource(R.string.update_track_beta)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
Text(trackName, style = MaterialTheme.typography.bodyMedium)
|
Text(trackName, style = MaterialTheme.typography.bodyMedium)
|
||||||
},
|
},
|
||||||
@@ -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)
|
||||||
|
|||||||
+76
-118
@@ -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,66 +114,34 @@ fun CoreSettingsScreen(navController: NavController) {
|
|||||||
) {
|
) {
|
||||||
Column {
|
Column {
|
||||||
// Version Info
|
// Version Info
|
||||||
Box {
|
ListItem(
|
||||||
ListItem(
|
headlineContent = {
|
||||||
headlineContent = {
|
Text(
|
||||||
Text(
|
stringResource(R.string.core_version_title),
|
||||||
stringResource(R.string.core_version_title),
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
style = MaterialTheme.typography.bodyLarge,
|
)
|
||||||
)
|
},
|
||||||
},
|
supportingContent = {
|
||||||
supportingContent = {
|
Text(
|
||||||
Text(
|
version,
|
||||||
version,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
modifier = Modifier.padding(top = 4.dp),
|
||||||
modifier = Modifier.padding(top = 4.dp),
|
)
|
||||||
)
|
},
|
||||||
},
|
leadingContent = {
|
||||||
leadingContent = {
|
Icon(
|
||||||
Icon(
|
imageVector = Icons.Outlined.Info,
|
||||||
imageVector = Icons.Outlined.Info,
|
contentDescription = null,
|
||||||
contentDescription = null,
|
tint = MaterialTheme.colorScheme.primary,
|
||||||
tint = MaterialTheme.colorScheme.primary,
|
)
|
||||||
)
|
},
|
||||||
},
|
modifier = Modifier.clip(RoundedCornerShape(topStart = 12.dp, topEnd = 12.dp)),
|
||||||
modifier = Modifier
|
colors =
|
||||||
.clip(RoundedCornerShape(topStart = 12.dp, topEnd = 12.dp))
|
ListItemDefaults.colors(
|
||||||
.combinedClickable(
|
containerColor = Color.Transparent,
|
||||||
onClick = {},
|
),
|
||||||
onLongClick = { showVersionMenu = true },
|
)
|
||||||
),
|
|
||||||
colors =
|
|
||||||
ListItemDefaults.colors(
|
|
||||||
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,58 +181,57 @@ 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),
|
||||||
)
|
)
|
||||||
|
|
||||||
Card(
|
Card(
|
||||||
modifier =
|
modifier =
|
||||||
Modifier
|
Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(horizontal = 16.dp),
|
.padding(horizontal = 16.dp),
|
||||||
|
colors =
|
||||||
|
CardDefaults.cardColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.surfaceContainer,
|
||||||
|
),
|
||||||
|
) {
|
||||||
|
ListItem(
|
||||||
|
headlineContent = {
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.disable_deprecated_warnings),
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
leadingContent = {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Outlined.WarningAmber,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.primary,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
trailingContent = {
|
||||||
|
Switch(
|
||||||
|
checked = disableDeprecatedWarnings,
|
||||||
|
onCheckedChange = { checked ->
|
||||||
|
disableDeprecatedWarnings = checked
|
||||||
|
scope.launch(Dispatchers.IO) {
|
||||||
|
Settings.disableDeprecatedWarnings = checked
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
},
|
||||||
|
modifier = Modifier.clip(RoundedCornerShape(12.dp)),
|
||||||
colors =
|
colors =
|
||||||
CardDefaults.cardColors(
|
ListItemDefaults.colors(
|
||||||
containerColor = MaterialTheme.colorScheme.surfaceContainer,
|
containerColor = Color.Transparent,
|
||||||
),
|
),
|
||||||
) {
|
)
|
||||||
ListItem(
|
|
||||||
headlineContent = {
|
|
||||||
Text(
|
|
||||||
stringResource(R.string.disable_deprecated_warnings),
|
|
||||||
style = MaterialTheme.typography.bodyLarge,
|
|
||||||
)
|
|
||||||
},
|
|
||||||
leadingContent = {
|
|
||||||
Icon(
|
|
||||||
imageVector = Icons.Outlined.WarningAmber,
|
|
||||||
contentDescription = null,
|
|
||||||
tint = MaterialTheme.colorScheme.primary,
|
|
||||||
)
|
|
||||||
},
|
|
||||||
trailingContent = {
|
|
||||||
Switch(
|
|
||||||
checked = disableDeprecatedWarnings,
|
|
||||||
onCheckedChange = { checked ->
|
|
||||||
disableDeprecatedWarnings = checked
|
|
||||||
scope.launch(Dispatchers.IO) {
|
|
||||||
Settings.disableDeprecatedWarnings = checked
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
},
|
|
||||||
modifier = Modifier.clip(RoundedCornerShape(12.dp)),
|
|
||||||
colors =
|
|
||||||
ListItemDefaults.colors(
|
|
||||||
containerColor = Color.Transparent,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Working Directory Section
|
// Working Directory Section
|
||||||
|
|||||||
@@ -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 -> {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+5
-104
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+8
-73
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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,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
|
||||||
|
|||||||
+9
-12
@@ -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,20 +93,19 @@ 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? {
|
||||||
|
val track = UpdateTrack.fromString(Settings.updateTrack)
|
||||||
override fun checkUpdateAsync(): UpdateInfo? = when (UpdateSource.fromString(Settings.updateSource)) {
|
return GitHubUpdateChecker().use { checker ->
|
||||||
UpdateSource.FDROID -> checkFDroidUpdate(Application.application)
|
checker.checkUpdate(track)
|
||||||
UpdateSource.GITHUB -> {
|
|
||||||
val track = UpdateTrack.fromString(Settings.updateTrack)
|
|
||||||
GitHubUpdateChecker().use { checker ->
|
|
||||||
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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -92,5 +92,7 @@ object Vendor : VendorInterface {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun supportsTrackSelection(): Boolean = false
|
||||||
|
|
||||||
override fun checkUpdateAsync(): UpdateInfo? = null
|
override fun checkUpdateAsync(): UpdateInfo? = null
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -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,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
@@ -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
|
||||||
|
|||||||
Vendored
+2
-12
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+5
-10
@@ -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);
|
||||||
|
|||||||
+2
-41
@@ -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
@@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user