Add vpn hide xposed module
This commit is contained in:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -3,7 +3,7 @@
|
|||||||
/local.properties
|
/local.properties
|
||||||
/.idea/
|
/.idea/
|
||||||
.DS_Store
|
.DS_Store
|
||||||
/build
|
build/
|
||||||
/captures
|
/captures
|
||||||
.externalNativeBuild
|
.externalNativeBuild
|
||||||
.cxx
|
.cxx
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import org.gradle.api.file.DuplicatesStrategy
|
||||||
|
import org.gradle.api.tasks.Sync
|
||||||
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
|
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
|
||||||
import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
|
import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
|
||||||
import org.jlleitschuh.gradle.ktlint.reporter.ReporterType
|
import org.jlleitschuh.gradle.ktlint.reporter.ReporterType
|
||||||
@@ -125,6 +127,7 @@ android {
|
|||||||
}
|
}
|
||||||
getByName("otherLegacy") {
|
getByName("otherLegacy") {
|
||||||
java.srcDirs("src/minApi21/java", "src/github/java")
|
java.srcDirs("src/minApi21/java", "src/github/java")
|
||||||
|
aidl.srcDirs("src/minApi23/aidl")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -138,8 +141,8 @@ android {
|
|||||||
}
|
}
|
||||||
|
|
||||||
compileOptions {
|
compileOptions {
|
||||||
sourceCompatibility = JavaVersion.VERSION_1_8
|
sourceCompatibility = JavaVersion.VERSION_17
|
||||||
targetCompatibility = JavaVersion.VERSION_1_8
|
targetCompatibility = JavaVersion.VERSION_17
|
||||||
}
|
}
|
||||||
|
|
||||||
buildFeatures {
|
buildFeatures {
|
||||||
@@ -246,10 +249,8 @@ dependencies {
|
|||||||
val shizukuVersion = "12.2.0"
|
val shizukuVersion = "12.2.0"
|
||||||
"playImplementation"("dev.rikka.shizuku:api:$shizukuVersion")
|
"playImplementation"("dev.rikka.shizuku:api:$shizukuVersion")
|
||||||
"playImplementation"("dev.rikka.shizuku:provider:$shizukuVersion")
|
"playImplementation"("dev.rikka.shizuku:provider:$shizukuVersion")
|
||||||
"playImplementation"("org.lsposed.hiddenapibypass:hiddenapibypass:4.3")
|
|
||||||
"otherImplementation"("dev.rikka.shizuku:api:$shizukuVersion")
|
"otherImplementation"("dev.rikka.shizuku:api:$shizukuVersion")
|
||||||
"otherImplementation"("dev.rikka.shizuku:provider:$shizukuVersion")
|
"otherImplementation"("dev.rikka.shizuku:provider:$shizukuVersion")
|
||||||
"otherImplementation"("org.lsposed.hiddenapibypass:hiddenapibypass:4.3")
|
|
||||||
|
|
||||||
// libsu for ROOT package query (all flavors)
|
// libsu for ROOT package query (all flavors)
|
||||||
val libsuVersion = "6.0.0"
|
val libsuVersion = "6.0.0"
|
||||||
@@ -309,6 +310,10 @@ dependencies {
|
|||||||
implementation("sh.calvin.reorderable:reorderable:3.0.0")
|
implementation("sh.calvin.reorderable:reorderable:3.0.0")
|
||||||
implementation("com.github.jeziellago:compose-markdown:0.5.4")
|
implementation("com.github.jeziellago:compose-markdown:0.5.4")
|
||||||
implementation("org.kodein.emoji:emoji-kt:2.3.0")
|
implementation("org.kodein.emoji:emoji-kt:2.3.0")
|
||||||
|
|
||||||
|
// Xposed API for self-hooking VPN hide module
|
||||||
|
compileOnly("de.robv.android.xposed:api:82")
|
||||||
|
compileOnly(project(":libxposed-api"))
|
||||||
}
|
}
|
||||||
|
|
||||||
val playCredentialsJSON = rootProject.file("service-account-credentials.json")
|
val playCredentialsJSON = rootProject.file("service-account-credentials.json")
|
||||||
@@ -329,7 +334,7 @@ if (playCredentialsJSON.exists()) {
|
|||||||
|
|
||||||
tasks.withType<KotlinCompile>().configureEach {
|
tasks.withType<KotlinCompile>().configureEach {
|
||||||
compilerOptions {
|
compilerOptions {
|
||||||
jvmTarget.set(JvmTarget.JVM_1_8)
|
jvmTarget.set(JvmTarget.JVM_17)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -27,6 +27,14 @@ class GitHubUpdateChecker : Closeable {
|
|||||||
private val json = Json { ignoreUnknownKeys = true }
|
private val json = Json { ignoreUnknownKeys = true }
|
||||||
|
|
||||||
fun checkUpdate(track: UpdateTrack): UpdateInfo? {
|
fun checkUpdate(track: UpdateTrack): UpdateInfo? {
|
||||||
|
return getLatestUpdate(track, checkVersion = true)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun forceGetLatestUpdate(track: UpdateTrack): UpdateInfo? {
|
||||||
|
return getLatestUpdate(track, checkVersion = false)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getLatestUpdate(track: UpdateTrack, checkVersion: Boolean): UpdateInfo? {
|
||||||
val includePrerelease = track == UpdateTrack.BETA
|
val includePrerelease = track == UpdateTrack.BETA
|
||||||
val release = getLatestRelease(includePrerelease) ?: return null
|
val release = getLatestRelease(includePrerelease) ?: return null
|
||||||
|
|
||||||
@@ -36,7 +44,7 @@ class GitHubUpdateChecker : Closeable {
|
|||||||
|
|
||||||
val metadata = downloadMetadata(release)!!
|
val metadata = downloadMetadata(release)!!
|
||||||
|
|
||||||
if (metadata.versionCode <= BuildConfig.VERSION_CODE) {
|
if (checkVersion && metadata.versionCode <= BuildConfig.VERSION_CODE) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,18 @@
|
|||||||
package io.nekohasekai.sfa.vendor
|
package io.nekohasekai.sfa.vendor
|
||||||
|
|
||||||
|
import android.content.Intent
|
||||||
|
import android.content.ServiceConnection
|
||||||
|
import android.os.IBinder
|
||||||
|
import android.os.ParcelFileDescriptor
|
||||||
|
import com.topjohnwu.superuser.ipc.RootService
|
||||||
|
import io.nekohasekai.sfa.Application
|
||||||
|
import io.nekohasekai.sfa.bg.IRootService
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import java.io.BufferedReader
|
|
||||||
import java.io.BufferedWriter
|
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.io.InputStreamReader
|
import kotlin.coroutines.resume
|
||||||
import java.io.OutputStreamWriter
|
import kotlin.coroutines.resumeWithException
|
||||||
|
|
||||||
object RootInstaller {
|
object RootInstaller {
|
||||||
|
|
||||||
@@ -20,20 +26,63 @@ object RootInstaller {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun install(apkFile: File): Result<Unit> = withContext(Dispatchers.IO) {
|
suspend fun install(apkFile: File) {
|
||||||
try {
|
withContext(Dispatchers.IO) {
|
||||||
val process = Runtime.getRuntime().exec(arrayOf("su", "-c", "pm install -r \"${apkFile.absolutePath}\""))
|
bindRootService().use { handle ->
|
||||||
val reader = BufferedReader(InputStreamReader(process.inputStream))
|
ParcelFileDescriptor.open(apkFile, ParcelFileDescriptor.MODE_READ_ONLY).use { pfd ->
|
||||||
val output = reader.readText()
|
handle.service.installPackage(
|
||||||
val exitCode = process.waitFor()
|
pfd,
|
||||||
|
apkFile.length(),
|
||||||
|
android.os.Process.myUserHandle().hashCode()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (exitCode == 0 && output.contains("Success")) {
|
private suspend fun bindRootService(): RootServiceHandle {
|
||||||
Result.success(Unit)
|
return withContext(Dispatchers.Main) {
|
||||||
|
suspendCancellableCoroutine { continuation ->
|
||||||
|
val conn = object : ServiceConnection {
|
||||||
|
override fun onServiceConnected(name: android.content.ComponentName?, binder: IBinder?) {
|
||||||
|
val svc = if (binder != null && binder.pingBinder()) {
|
||||||
|
IRootService.Stub.asInterface(binder)
|
||||||
} else {
|
} else {
|
||||||
Result.failure(Exception("Installation failed: $output"))
|
null
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
if (svc == null) {
|
||||||
Result.failure(e)
|
continuation.resumeWithException(IllegalStateException("Invalid root service binder"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
continuation.resume(RootServiceHandle(this, svc))
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onServiceDisconnected(name: android.content.ComponentName?) {
|
||||||
|
// Ignored
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
val intent = Intent(Application.application, Class.forName("io.nekohasekai.sfa.bg.RootServer"))
|
||||||
|
RootService.bind(intent, conn)
|
||||||
|
} catch (e: Throwable) {
|
||||||
|
continuation.resumeWithException(e)
|
||||||
|
return@suspendCancellableCoroutine
|
||||||
|
}
|
||||||
|
|
||||||
|
continuation.invokeOnCancellation {
|
||||||
|
RootService.unbind(conn)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private data class RootServiceHandle(
|
||||||
|
val connection: ServiceConnection,
|
||||||
|
val service: IRootService
|
||||||
|
) : java.io.Closeable {
|
||||||
|
override fun close() {
|
||||||
|
RootService.unbind(connection)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,8 +14,7 @@ object SystemPackageInstaller {
|
|||||||
return Build.VERSION.SDK_INT >= Build.VERSION_CODES.S
|
return Build.VERSION.SDK_INT >= Build.VERSION_CODES.S
|
||||||
}
|
}
|
||||||
|
|
||||||
fun install(context: Context, apkFile: File): Result<Unit> {
|
fun install(context: Context, apkFile: File) {
|
||||||
return try {
|
|
||||||
val packageInstaller = context.packageManager.packageInstaller
|
val packageInstaller = context.packageManager.packageInstaller
|
||||||
val params = AndroidPackageInstaller.SessionParams(AndroidPackageInstaller.SessionParams.MODE_FULL_INSTALL)
|
val params = AndroidPackageInstaller.SessionParams(AndroidPackageInstaller.SessionParams.MODE_FULL_INSTALL)
|
||||||
params.setAppPackageName(context.packageName)
|
params.setAppPackageName(context.packageName)
|
||||||
@@ -44,9 +43,5 @@ object SystemPackageInstaller {
|
|||||||
|
|
||||||
session.commit(pendingIntent.intentSender)
|
session.commit(pendingIntent.intentSender)
|
||||||
}
|
}
|
||||||
Result.success(Unit)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Result.failure(e)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -77,13 +77,8 @@ class UpdateWorker(
|
|||||||
val apkFile = ApkDownloader().use { it.download(updateInfo.downloadUrl) }
|
val apkFile = ApkDownloader().use { it.download(updateInfo.downloadUrl) }
|
||||||
|
|
||||||
Log.d(TAG, "Installing update...")
|
Log.d(TAG, "Installing update...")
|
||||||
val result = ApkInstaller.install(appContext, apkFile)
|
ApkInstaller.install(appContext, apkFile)
|
||||||
|
|
||||||
if (result.isSuccess) {
|
|
||||||
Log.d(TAG, "Update installed successfully")
|
Log.d(TAG, "Update installed successfully")
|
||||||
} else {
|
|
||||||
Log.e(TAG, "Update installation failed", result.exceptionOrNull())
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
Log.d(TAG, "Silent install not available, update will be shown on next app launch")
|
Log.d(TAG, "Silent install not available, update will be shown on next app launch")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,6 +33,7 @@
|
|||||||
android:name=".Application"
|
android:name=".Application"
|
||||||
android:allowBackup="true"
|
android:allowBackup="true"
|
||||||
android:dataExtractionRules="@xml/data_extraction_rules"
|
android:dataExtractionRules="@xml/data_extraction_rules"
|
||||||
|
android:description="@string/xposed_description"
|
||||||
android:fullBackupContent="@xml/backup_rules"
|
android:fullBackupContent="@xml/backup_rules"
|
||||||
android:icon="@mipmap/ic_launcher"
|
android:icon="@mipmap/ic_launcher"
|
||||||
android:label="@string/app_name"
|
android:label="@string/app_name"
|
||||||
@@ -41,17 +42,22 @@
|
|||||||
tools:targetApi="31">
|
tools:targetApi="31">
|
||||||
|
|
||||||
<activity
|
<activity
|
||||||
android:name=".LauncherActivity"
|
android:name=".compose.MainActivity"
|
||||||
android:exported="true"
|
android:exported="true"
|
||||||
android:icon="@mipmap/ic_launcher"
|
android:icon="@mipmap/ic_launcher"
|
||||||
android:theme="@style/AppTheme.Translucent">
|
android:launchMode="singleTask"
|
||||||
|
android:theme="@style/AppTheme">
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.intent.action.MAIN" />
|
<action android:name="android.intent.action.MAIN" />
|
||||||
|
|
||||||
<category android:name="android.intent.category.LAUNCHER" />
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
|
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.MAIN" />
|
||||||
|
|
||||||
|
<category android:name="de.robv.android.xposed.category.MODULE_SETTINGS" />
|
||||||
|
</intent-filter>
|
||||||
|
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.intent.action.VIEW" />
|
<action android:name="android.intent.action.VIEW" />
|
||||||
@@ -90,32 +96,6 @@
|
|||||||
</intent-filter>
|
</intent-filter>
|
||||||
|
|
||||||
</activity>
|
</activity>
|
||||||
|
|
||||||
<activity
|
|
||||||
android:name=".compose.MainActivity"
|
|
||||||
android:exported="false"
|
|
||||||
android:icon="@mipmap/ic_launcher"
|
|
||||||
android:launchMode="singleTask"
|
|
||||||
android:theme="@style/AppTheme">
|
|
||||||
|
|
||||||
</activity>
|
|
||||||
|
|
||||||
<activity
|
|
||||||
android:name="io.nekohasekai.sfa.compose.NewProfileActivity"
|
|
||||||
android:exported="false"
|
|
||||||
android:theme="@style/AppTheme" />
|
|
||||||
<activity
|
|
||||||
android:name="io.nekohasekai.sfa.compose.EditProfileActivity"
|
|
||||||
android:exported="false"
|
|
||||||
android:theme="@style/AppTheme" />
|
|
||||||
<activity
|
|
||||||
android:name="io.nekohasekai.sfa.compose.GroupsActivity"
|
|
||||||
android:exported="false"
|
|
||||||
android:theme="@style/AppTheme" />
|
|
||||||
<activity
|
|
||||||
android:name="io.nekohasekai.sfa.ui.profileoverride.PerAppProxyActivity"
|
|
||||||
android:exported="false" />
|
|
||||||
|
|
||||||
<service
|
<service
|
||||||
android:name=".bg.TileService"
|
android:name=".bg.TileService"
|
||||||
android:directBootAware="true"
|
android:directBootAware="true"
|
||||||
@@ -159,6 +139,11 @@
|
|||||||
</intent-filter>
|
</intent-filter>
|
||||||
</receiver>
|
</receiver>
|
||||||
|
|
||||||
|
<provider
|
||||||
|
android:name="io.github.libxposed.service.XposedProvider"
|
||||||
|
android:authorities="${applicationId}.XposedService"
|
||||||
|
android:exported="true" />
|
||||||
|
|
||||||
<provider
|
<provider
|
||||||
android:name="androidx.core.content.FileProvider"
|
android:name="androidx.core.content.FileProvider"
|
||||||
android:authorities="${applicationId}.cache"
|
android:authorities="${applicationId}.cache"
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
package io.github.libxposed.service;
|
||||||
|
|
||||||
|
interface IXposedScopeCallback {
|
||||||
|
oneway void onScopeRequestPrompted(String packageName) = 1;
|
||||||
|
oneway void onScopeRequestApproved(String packageName) = 2;
|
||||||
|
oneway void onScopeRequestDenied(String packageName) = 3;
|
||||||
|
oneway void onScopeRequestTimeout(String packageName) = 4;
|
||||||
|
oneway void onScopeRequestFailed(String packageName, String message) = 5;
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
package io.github.libxposed.service;
|
||||||
|
import io.github.libxposed.service.IXposedScopeCallback;
|
||||||
|
|
||||||
|
interface IXposedService {
|
||||||
|
const int API = 100;
|
||||||
|
|
||||||
|
const int FRAMEWORK_PRIVILEGE_ROOT = 0;
|
||||||
|
const int FRAMEWORK_PRIVILEGE_CONTAINER = 1;
|
||||||
|
const int FRAMEWORK_PRIVILEGE_APP = 2;
|
||||||
|
const int FRAMEWORK_PRIVILEGE_EMBEDDED = 3;
|
||||||
|
|
||||||
|
const String AUTHORITY_SUFFIX = ".XposedService";
|
||||||
|
const String SEND_BINDER = "SendBinder";
|
||||||
|
|
||||||
|
// framework details
|
||||||
|
int getAPIVersion() = 1;
|
||||||
|
String getFrameworkName() = 2;
|
||||||
|
String getFrameworkVersion() = 3;
|
||||||
|
long getFrameworkVersionCode() = 4;
|
||||||
|
int getFrameworkPrivilege() = 5;
|
||||||
|
|
||||||
|
// scope utilities
|
||||||
|
List<String> getScope() = 10;
|
||||||
|
oneway void requestScope(String packageName, IXposedScopeCallback callback) = 11;
|
||||||
|
String removeScope(String packageName) = 12;
|
||||||
|
|
||||||
|
// remote preference utilities
|
||||||
|
Bundle requestRemotePreferences(String group) = 20;
|
||||||
|
void updateRemotePreferences(String group, in Bundle diff) = 21;
|
||||||
|
void deleteRemotePreferences(String group) = 22;
|
||||||
|
|
||||||
|
// remote file utilities
|
||||||
|
String[] listRemoteFiles() = 30;
|
||||||
|
ParcelFileDescriptor openRemoteFile(String name) = 31;
|
||||||
|
boolean deleteRemoteFile(String name) = 32;
|
||||||
|
}
|
||||||
14
app/src/main/aidl/io/nekohasekai/sfa/bg/IRootService.aidl
Normal file
14
app/src/main/aidl/io/nekohasekai/sfa/bg/IRootService.aidl
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
package io.nekohasekai.sfa.bg;
|
||||||
|
|
||||||
|
import android.os.ParcelFileDescriptor;
|
||||||
|
import io.nekohasekai.sfa.bg.ParceledListSlice;
|
||||||
|
|
||||||
|
interface IRootService {
|
||||||
|
void destroy() = 16777114; // Destroy method defined by Shizuku server
|
||||||
|
|
||||||
|
ParceledListSlice getInstalledPackages(int flags, int userId) = 1;
|
||||||
|
|
||||||
|
void installPackage(in ParcelFileDescriptor apk, long size, int userId) = 2;
|
||||||
|
|
||||||
|
String exportDebugInfo(String outputPath) = 3;
|
||||||
|
}
|
||||||
12
app/src/main/aidl/io/nekohasekai/sfa/bg/IShizukuService.aidl
Normal file
12
app/src/main/aidl/io/nekohasekai/sfa/bg/IShizukuService.aidl
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
package io.nekohasekai.sfa.bg;
|
||||||
|
|
||||||
|
import android.os.ParcelFileDescriptor;
|
||||||
|
import io.nekohasekai.sfa.bg.ParceledListSlice;
|
||||||
|
|
||||||
|
interface IShizukuService {
|
||||||
|
void destroy() = 16777114; // Destroy method defined by Shizuku server
|
||||||
|
|
||||||
|
ParceledListSlice getInstalledPackages(int flags, int userId) = 1;
|
||||||
|
|
||||||
|
void installPackage(in ParcelFileDescriptor apk, long size, int userId) = 2;
|
||||||
|
}
|
||||||
3
app/src/main/aidl/io/nekohasekai/sfa/bg/LogEntry.aidl
Normal file
3
app/src/main/aidl/io/nekohasekai/sfa/bg/LogEntry.aidl
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
package io.nekohasekai.sfa.bg;
|
||||||
|
|
||||||
|
parcelable LogEntry;
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
package io.nekohasekai.sfa.bg;
|
||||||
|
|
||||||
|
parcelable PackageEntry;
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
package io.nekohasekai.sfa.bg;
|
||||||
|
|
||||||
|
parcelable ParceledListSlice;
|
||||||
@@ -0,0 +1,236 @@
|
|||||||
|
package io.github.libxposed.service;
|
||||||
|
|
||||||
|
import android.content.SharedPreferences;
|
||||||
|
import android.os.Bundle;
|
||||||
|
import android.os.Handler;
|
||||||
|
import android.os.Looper;
|
||||||
|
import android.os.RemoteException;
|
||||||
|
import android.util.Log;
|
||||||
|
|
||||||
|
import androidx.annotation.NonNull;
|
||||||
|
import androidx.annotation.Nullable;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Collections;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.HashSet;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Set;
|
||||||
|
import java.util.TreeMap;
|
||||||
|
import java.util.WeakHashMap;
|
||||||
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
|
import java.util.concurrent.locks.Lock;
|
||||||
|
import java.util.concurrent.locks.ReentrantLock;
|
||||||
|
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
public final class RemotePreferences implements SharedPreferences {
|
||||||
|
|
||||||
|
private static final String TAG = "RemotePreferences";
|
||||||
|
private static final Object CONTENT = new Object();
|
||||||
|
private static final Handler HANDLER = new Handler(Looper.getMainLooper());
|
||||||
|
|
||||||
|
private final XposedService mService;
|
||||||
|
private final String mGroup;
|
||||||
|
private final Lock mLock = new ReentrantLock();
|
||||||
|
private final Map<String, Object> mMap = new ConcurrentHashMap<>();
|
||||||
|
private final Map<OnSharedPreferenceChangeListener, Object> mListeners = Collections.synchronizedMap(new WeakHashMap<>());
|
||||||
|
|
||||||
|
private volatile boolean isDeleted = false;
|
||||||
|
|
||||||
|
private RemotePreferences(XposedService service, String group) {
|
||||||
|
this.mService = service;
|
||||||
|
this.mGroup = group;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Nullable
|
||||||
|
static RemotePreferences newInstance(XposedService service, String group) throws RemoteException {
|
||||||
|
Bundle output = service.getRaw().requestRemotePreferences(group);
|
||||||
|
if (output == null) return null;
|
||||||
|
RemotePreferences prefs = new RemotePreferences(service, group);
|
||||||
|
if (output.containsKey("map")) {
|
||||||
|
prefs.mMap.putAll((Map<String, Object>) output.getSerializable("map"));
|
||||||
|
}
|
||||||
|
return prefs;
|
||||||
|
}
|
||||||
|
|
||||||
|
void setDeleted() {
|
||||||
|
this.isDeleted = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Map<String, ?> getAll() {
|
||||||
|
return new TreeMap<>(mMap);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Nullable
|
||||||
|
@Override
|
||||||
|
public String getString(String key, @Nullable String defValue) {
|
||||||
|
return (String) mMap.getOrDefault(key, defValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Nullable
|
||||||
|
@Override
|
||||||
|
public Set<String> getStringSet(String key, @Nullable Set<String> defValues) {
|
||||||
|
return (Set<String>) mMap.getOrDefault(key, defValues);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public int getInt(String key, int defValue) {
|
||||||
|
Integer v = (Integer) mMap.getOrDefault(key, defValue);
|
||||||
|
assert v != null;
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public long getLong(String key, long defValue) {
|
||||||
|
Long v = (Long) mMap.getOrDefault(key, defValue);
|
||||||
|
assert v != null;
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public float getFloat(String key, float defValue) {
|
||||||
|
Float v = (Float) mMap.getOrDefault(key, defValue);
|
||||||
|
assert v != null;
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean getBoolean(String key, boolean defValue) {
|
||||||
|
Boolean v = (Boolean) mMap.getOrDefault(key, defValue);
|
||||||
|
assert v != null;
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean contains(String key) {
|
||||||
|
return mMap.containsKey(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void registerOnSharedPreferenceChangeListener(OnSharedPreferenceChangeListener listener) {
|
||||||
|
mListeners.put(listener, CONTENT);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void unregisterOnSharedPreferenceChangeListener(OnSharedPreferenceChangeListener listener) {
|
||||||
|
mListeners.remove(listener);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Editor edit() {
|
||||||
|
return new Editor();
|
||||||
|
}
|
||||||
|
|
||||||
|
public class Editor implements SharedPreferences.Editor {
|
||||||
|
|
||||||
|
private final HashSet<String> mDelete = new HashSet<>();
|
||||||
|
private final HashMap<String, Object> mPut = new HashMap<>();
|
||||||
|
|
||||||
|
private void put(String key, @NonNull Object value) {
|
||||||
|
mDelete.remove(key);
|
||||||
|
mPut.put(key, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public SharedPreferences.Editor putString(String key, @Nullable String value) {
|
||||||
|
if (value == null) remove(key);
|
||||||
|
else put(key, value);
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public SharedPreferences.Editor putStringSet(String key, @Nullable Set<String> values) {
|
||||||
|
if (values == null) remove(key);
|
||||||
|
else put(key, values);
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public SharedPreferences.Editor putInt(String key, int value) {
|
||||||
|
put(key, value);
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public SharedPreferences.Editor putLong(String key, long value) {
|
||||||
|
put(key, value);
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public SharedPreferences.Editor putFloat(String key, float value) {
|
||||||
|
put(key, value);
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public SharedPreferences.Editor putBoolean(String key, boolean value) {
|
||||||
|
put(key, value);
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public SharedPreferences.Editor remove(String key) {
|
||||||
|
mDelete.add(key);
|
||||||
|
mPut.remove(key);
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public SharedPreferences.Editor clear() {
|
||||||
|
mDelete.clear();
|
||||||
|
mPut.clear();
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void doUpdate(boolean throwing) {
|
||||||
|
mService.deletionLock.readLock().lock();
|
||||||
|
try {
|
||||||
|
if (isDeleted) {
|
||||||
|
throw new IllegalStateException("This preferences group has been deleted");
|
||||||
|
}
|
||||||
|
mDelete.forEach(mMap::remove);
|
||||||
|
mMap.putAll(mPut);
|
||||||
|
List<String> changes = new ArrayList<>(mDelete.size() + mMap.size());
|
||||||
|
changes.addAll(mDelete);
|
||||||
|
changes.addAll(mMap.keySet());
|
||||||
|
for (String key : changes) {
|
||||||
|
mListeners.keySet().forEach(listener -> listener.onSharedPreferenceChanged(RemotePreferences.this, key));
|
||||||
|
}
|
||||||
|
|
||||||
|
Bundle bundle = new Bundle();
|
||||||
|
bundle.putSerializable("delete", mDelete);
|
||||||
|
bundle.putSerializable("put", mPut);
|
||||||
|
try {
|
||||||
|
mService.getRaw().updateRemotePreferences(mGroup, bundle);
|
||||||
|
} catch (RemoteException e) {
|
||||||
|
if (throwing) {
|
||||||
|
throw new RuntimeException(e);
|
||||||
|
} else {
|
||||||
|
Log.e(TAG, "Failed to update remote preferences", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
mService.deletionLock.readLock().unlock();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean commit() {
|
||||||
|
if (!mLock.tryLock()) return false;
|
||||||
|
try {
|
||||||
|
doUpdate(true);
|
||||||
|
return true;
|
||||||
|
} finally {
|
||||||
|
mLock.unlock();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void apply() {
|
||||||
|
HANDLER.post(() -> doUpdate(false));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
package io.github.libxposed.service;
|
||||||
|
|
||||||
|
import android.content.ContentProvider;
|
||||||
|
import android.content.ContentValues;
|
||||||
|
import android.database.Cursor;
|
||||||
|
import android.net.Uri;
|
||||||
|
import android.os.Bundle;
|
||||||
|
import android.os.IBinder;
|
||||||
|
import android.util.Log;
|
||||||
|
|
||||||
|
import androidx.annotation.NonNull;
|
||||||
|
import androidx.annotation.Nullable;
|
||||||
|
|
||||||
|
public final class XposedProvider extends ContentProvider {
|
||||||
|
|
||||||
|
private static final String TAG = "XposedProvider";
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean onCreate() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Nullable
|
||||||
|
@Override
|
||||||
|
public Cursor query(@NonNull Uri uri, @Nullable String[] projection, @Nullable String selection, @Nullable String[] selectionArgs, @Nullable String sortOrder) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Nullable
|
||||||
|
@Override
|
||||||
|
public String getType(@NonNull Uri uri) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Nullable
|
||||||
|
@Override
|
||||||
|
public Uri insert(@NonNull Uri uri, @Nullable ContentValues values) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public int delete(@NonNull Uri uri, @Nullable String selection, @Nullable String[] selectionArgs) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public int update(@NonNull Uri uri, @Nullable ContentValues values, @Nullable String selection, @Nullable String[] selectionArgs) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Nullable
|
||||||
|
@Override
|
||||||
|
public Bundle call(@NonNull String method, @Nullable String arg, @Nullable Bundle extras) {
|
||||||
|
if (method.equals(IXposedService.SEND_BINDER) && extras != null) {
|
||||||
|
IBinder binder = extras.getBinder("binder");
|
||||||
|
if (binder != null) {
|
||||||
|
Log.d(TAG, "binder received: " + binder);
|
||||||
|
XposedServiceHelper.onBinderReceived(binder);
|
||||||
|
}
|
||||||
|
return new Bundle();
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
378
app/src/main/java/io/github/libxposed/service/XposedService.java
Normal file
378
app/src/main/java/io/github/libxposed/service/XposedService.java
Normal file
@@ -0,0 +1,378 @@
|
|||||||
|
package io.github.libxposed.service;
|
||||||
|
|
||||||
|
import android.content.SharedPreferences;
|
||||||
|
import android.os.ParcelFileDescriptor;
|
||||||
|
import android.os.RemoteException;
|
||||||
|
|
||||||
|
import androidx.annotation.NonNull;
|
||||||
|
import androidx.annotation.Nullable;
|
||||||
|
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.WeakHashMap;
|
||||||
|
import java.util.concurrent.locks.ReentrantReadWriteLock;
|
||||||
|
|
||||||
|
@SuppressWarnings("unused")
|
||||||
|
public final class XposedService {
|
||||||
|
|
||||||
|
public final static class ServiceException extends RuntimeException {
|
||||||
|
ServiceException(String message) {
|
||||||
|
super(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
ServiceException(RemoteException e) {
|
||||||
|
super("Xposed service error", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private final static Map<OnScopeEventListener, IXposedScopeCallback> scopeCallbacks = new WeakHashMap<>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Callback interface for module scope request.
|
||||||
|
*/
|
||||||
|
public interface OnScopeEventListener {
|
||||||
|
/**
|
||||||
|
* Callback when the request notification / window prompted.
|
||||||
|
*
|
||||||
|
* @param packageName Package name of requested app
|
||||||
|
*/
|
||||||
|
default void onScopeRequestPrompted(String packageName) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Callback when the request is approved.
|
||||||
|
*
|
||||||
|
* @param packageName Package name of requested app
|
||||||
|
*/
|
||||||
|
default void onScopeRequestApproved(String packageName) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Callback when the request is denied.
|
||||||
|
*
|
||||||
|
* @param packageName Package name of requested app
|
||||||
|
*/
|
||||||
|
default void onScopeRequestDenied(String packageName) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Callback when the request is timeout or revoked.
|
||||||
|
*
|
||||||
|
* @param packageName Package name of requested app
|
||||||
|
*/
|
||||||
|
default void onScopeRequestTimeout(String packageName) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Callback when the request is failed.
|
||||||
|
*
|
||||||
|
* @param packageName Package name of requested app
|
||||||
|
* @param message Error message
|
||||||
|
*/
|
||||||
|
default void onScopeRequestFailed(String packageName, String message) {
|
||||||
|
}
|
||||||
|
|
||||||
|
private IXposedScopeCallback asInterface() {
|
||||||
|
return scopeCallbacks.computeIfAbsent(this, (listener) -> new IXposedScopeCallback.Stub() {
|
||||||
|
@Override
|
||||||
|
public void onScopeRequestPrompted(String packageName) {
|
||||||
|
listener.onScopeRequestPrompted(packageName);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onScopeRequestApproved(String packageName) {
|
||||||
|
listener.onScopeRequestApproved(packageName);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onScopeRequestDenied(String packageName) {
|
||||||
|
listener.onScopeRequestDenied(packageName);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onScopeRequestTimeout(String packageName) {
|
||||||
|
listener.onScopeRequestTimeout(packageName);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onScopeRequestFailed(String packageName, String message) {
|
||||||
|
listener.onScopeRequestFailed(packageName, message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum Privilege {
|
||||||
|
/**
|
||||||
|
* Unknown privilege value.
|
||||||
|
*/
|
||||||
|
FRAMEWORK_PRIVILEGE_UNKNOWN,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The framework is running as root.
|
||||||
|
*/
|
||||||
|
FRAMEWORK_PRIVILEGE_ROOT,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The framework is running in a container with a fake system_server.
|
||||||
|
*/
|
||||||
|
FRAMEWORK_PRIVILEGE_CONTAINER,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The framework is running as a different app, which may have at most shell permission.
|
||||||
|
*/
|
||||||
|
FRAMEWORK_PRIVILEGE_APP,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The framework is embedded in the hooked app, which means {@link #getRemotePreferences} will be null and remote file is unsupported.
|
||||||
|
*/
|
||||||
|
FRAMEWORK_PRIVILEGE_EMBEDDED
|
||||||
|
}
|
||||||
|
|
||||||
|
private final IXposedService mService;
|
||||||
|
private final Map<String, RemotePreferences> mRemotePrefs = new HashMap<>();
|
||||||
|
|
||||||
|
final ReentrantReadWriteLock deletionLock = new ReentrantReadWriteLock();
|
||||||
|
|
||||||
|
XposedService(IXposedService service) {
|
||||||
|
mService = service;
|
||||||
|
}
|
||||||
|
|
||||||
|
IXposedService getRaw() {
|
||||||
|
return mService;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the Xposed API version of current implementation.
|
||||||
|
*
|
||||||
|
* @return API version
|
||||||
|
* @throws ServiceException If the service is dead or an error occurred
|
||||||
|
*/
|
||||||
|
public int getAPIVersion() {
|
||||||
|
try {
|
||||||
|
return mService.getAPIVersion();
|
||||||
|
} catch (RemoteException e) {
|
||||||
|
throw new ServiceException(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the Xposed framework name of current implementation.
|
||||||
|
*
|
||||||
|
* @return Framework name
|
||||||
|
* @throws ServiceException If the service is dead or an error occurred
|
||||||
|
*/
|
||||||
|
@NonNull
|
||||||
|
public String getFrameworkName() {
|
||||||
|
try {
|
||||||
|
return mService.getFrameworkName();
|
||||||
|
} catch (RemoteException e) {
|
||||||
|
throw new ServiceException(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the Xposed framework version of current implementation.
|
||||||
|
*
|
||||||
|
* @return Framework version
|
||||||
|
* @throws ServiceException If the service is dead or an error occurred
|
||||||
|
*/
|
||||||
|
@NonNull
|
||||||
|
public String getFrameworkVersion() {
|
||||||
|
try {
|
||||||
|
return mService.getFrameworkVersion();
|
||||||
|
} catch (RemoteException e) {
|
||||||
|
throw new ServiceException(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the Xposed framework version code of current implementation.
|
||||||
|
*
|
||||||
|
* @return Framework version code
|
||||||
|
* @throws ServiceException If the service is dead or an error occurred
|
||||||
|
*/
|
||||||
|
public long getFrameworkVersionCode() {
|
||||||
|
try {
|
||||||
|
return mService.getFrameworkVersionCode();
|
||||||
|
} catch (RemoteException e) {
|
||||||
|
throw new ServiceException(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the Xposed framework privilege of current implementation.
|
||||||
|
*
|
||||||
|
* @return Framework privilege
|
||||||
|
* @throws ServiceException If the service is dead or an error occurred
|
||||||
|
*/
|
||||||
|
@NonNull
|
||||||
|
public Privilege getFrameworkPrivilege() {
|
||||||
|
try {
|
||||||
|
int value = mService.getFrameworkPrivilege();
|
||||||
|
return (value >= 0 && value <= 3) ? Privilege.values()[value + 1] : Privilege.FRAMEWORK_PRIVILEGE_UNKNOWN;
|
||||||
|
} catch (RemoteException e) {
|
||||||
|
throw new ServiceException(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the application scope of current module.
|
||||||
|
*
|
||||||
|
* @return Module scope
|
||||||
|
* @throws ServiceException If the service is dead or an error occurred
|
||||||
|
*/
|
||||||
|
@NonNull
|
||||||
|
public List<String> getScope() {
|
||||||
|
try {
|
||||||
|
return mService.getScope();
|
||||||
|
} catch (RemoteException e) {
|
||||||
|
throw new ServiceException(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Request to add a new app to the module scope.
|
||||||
|
*
|
||||||
|
* @param packageName Package name of the app to be added
|
||||||
|
* @param callback Callback to be invoked when the request is completed or error occurred
|
||||||
|
* @throws ServiceException If the service is dead or an error occurred
|
||||||
|
*/
|
||||||
|
public void requestScope(@NonNull String packageName, @NonNull OnScopeEventListener callback) {
|
||||||
|
try {
|
||||||
|
mService.requestScope(packageName, callback.asInterface());
|
||||||
|
} catch (RemoteException e) {
|
||||||
|
throw new ServiceException(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove an app from the module scope.
|
||||||
|
*
|
||||||
|
* @param packageName Package name of the app to be added
|
||||||
|
* @return null if successful, or non-null with error message
|
||||||
|
* @throws ServiceException If the service is dead or an error occurred
|
||||||
|
*/
|
||||||
|
@Nullable
|
||||||
|
public String removeScope(@NonNull String packageName) {
|
||||||
|
try {
|
||||||
|
return mService.removeScope(packageName);
|
||||||
|
} catch (RemoteException e) {
|
||||||
|
throw new ServiceException(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get remote preferences from Xposed framework. If the group does not exist, it will be created.
|
||||||
|
*
|
||||||
|
* @param group Group name
|
||||||
|
* @return The preferences
|
||||||
|
* @throws ServiceException If the service is dead or an error occurred
|
||||||
|
* @throws UnsupportedOperationException If the framework is embedded
|
||||||
|
*/
|
||||||
|
@NonNull
|
||||||
|
public SharedPreferences getRemotePreferences(@NonNull String group) {
|
||||||
|
return mRemotePrefs.computeIfAbsent(group, k -> {
|
||||||
|
try {
|
||||||
|
RemotePreferences instance = RemotePreferences.newInstance(this, k);
|
||||||
|
if (instance == null) {
|
||||||
|
throw new ServiceException("Framework returns null");
|
||||||
|
}
|
||||||
|
return instance;
|
||||||
|
} catch (RemoteException e) {
|
||||||
|
if (e.getCause() instanceof UnsupportedOperationException cause) {
|
||||||
|
throw cause;
|
||||||
|
}
|
||||||
|
throw new ServiceException(e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete a group of remote preferences.
|
||||||
|
*
|
||||||
|
* @param group Group name
|
||||||
|
* @throws ServiceException If the service is dead or an error occurred
|
||||||
|
* @throws UnsupportedOperationException If the framework is embedded
|
||||||
|
*/
|
||||||
|
public void deleteRemotePreferences(@NonNull String group) {
|
||||||
|
deletionLock.writeLock().lock();
|
||||||
|
try {
|
||||||
|
mService.deleteRemotePreferences(group);
|
||||||
|
mRemotePrefs.computeIfPresent(group, (k, v) -> {
|
||||||
|
v.setDeleted();
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
} catch (RemoteException e) {
|
||||||
|
if (e.getCause() instanceof UnsupportedOperationException cause) {
|
||||||
|
throw cause;
|
||||||
|
}
|
||||||
|
throw new ServiceException(e);
|
||||||
|
} finally {
|
||||||
|
deletionLock.writeLock().unlock();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List all files in the module's shared data directory.
|
||||||
|
*
|
||||||
|
* @return The file list
|
||||||
|
* @throws ServiceException If the service is dead or an error occurred
|
||||||
|
* @throws UnsupportedOperationException If the framework is embedded
|
||||||
|
*/
|
||||||
|
@NonNull
|
||||||
|
public String[] listRemoteFiles() {
|
||||||
|
try {
|
||||||
|
String[] files = mService.listRemoteFiles();
|
||||||
|
if (files == null) throw new ServiceException("Framework returns null");
|
||||||
|
return files;
|
||||||
|
} catch (RemoteException e) {
|
||||||
|
if (e.getCause() instanceof UnsupportedOperationException cause) {
|
||||||
|
throw cause;
|
||||||
|
}
|
||||||
|
throw new ServiceException(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Open a file in the module's shared data directory. The file will be created if not exists.
|
||||||
|
*
|
||||||
|
* @param name File name, must not contain path separators and . or ..
|
||||||
|
* @return The file descriptor
|
||||||
|
* @throws ServiceException If the service is dead or an error occurred
|
||||||
|
* @throws UnsupportedOperationException If the framework is embedded
|
||||||
|
*/
|
||||||
|
@NonNull
|
||||||
|
public ParcelFileDescriptor openRemoteFile(@NonNull String name) {
|
||||||
|
try {
|
||||||
|
ParcelFileDescriptor file = mService.openRemoteFile(name);
|
||||||
|
if (file == null) throw new ServiceException("Framework returns null");
|
||||||
|
return file;
|
||||||
|
} catch (RemoteException e) {
|
||||||
|
if (e.getCause() instanceof UnsupportedOperationException cause) {
|
||||||
|
throw cause;
|
||||||
|
}
|
||||||
|
throw new ServiceException(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete a file in the module's shared data directory.
|
||||||
|
*
|
||||||
|
* @param name File name, must not contain path separators and . or ..
|
||||||
|
* @return true if successful, false if the file does not exist
|
||||||
|
* @throws ServiceException If the service is dead or an error occurred
|
||||||
|
* @throws UnsupportedOperationException If the framework is embedded
|
||||||
|
*/
|
||||||
|
public boolean deleteRemoteFile(@NonNull String name) {
|
||||||
|
try {
|
||||||
|
return mService.deleteRemoteFile(name);
|
||||||
|
} catch (RemoteException e) {
|
||||||
|
if (e.getCause() instanceof UnsupportedOperationException cause) {
|
||||||
|
throw cause;
|
||||||
|
}
|
||||||
|
throw new ServiceException(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
package io.github.libxposed.service;
|
||||||
|
|
||||||
|
import android.os.IBinder;
|
||||||
|
import android.util.Log;
|
||||||
|
|
||||||
|
import androidx.annotation.NonNull;
|
||||||
|
|
||||||
|
import java.util.HashSet;
|
||||||
|
import java.util.Iterator;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
@SuppressWarnings("unused")
|
||||||
|
public final class XposedServiceHelper {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Callback interface for Xposed service.
|
||||||
|
*/
|
||||||
|
public interface OnServiceListener {
|
||||||
|
/**
|
||||||
|
* Callback when the service is connected.<br/>
|
||||||
|
* This method could be called multiple times if multiple Xposed frameworks exist.
|
||||||
|
*
|
||||||
|
* @param service Service instance
|
||||||
|
*/
|
||||||
|
void onServiceBind(@NonNull XposedService service);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Callback when the service is dead.
|
||||||
|
*/
|
||||||
|
void onServiceDied(@NonNull XposedService service);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static final String TAG = "XposedServiceHelper";
|
||||||
|
private static final Set<XposedService> mCache = new HashSet<>();
|
||||||
|
private static OnServiceListener mListener = null;
|
||||||
|
|
||||||
|
static void onBinderReceived(IBinder binder) {
|
||||||
|
if (binder == null) return;
|
||||||
|
synchronized (mCache) {
|
||||||
|
try {
|
||||||
|
XposedService service = new XposedService(IXposedService.Stub.asInterface(binder));
|
||||||
|
if (mListener == null) {
|
||||||
|
mCache.add(service);
|
||||||
|
} else {
|
||||||
|
binder.linkToDeath(() -> mListener.onServiceDied(service), 0);
|
||||||
|
mListener.onServiceBind(service);
|
||||||
|
}
|
||||||
|
} catch (Throwable t) {
|
||||||
|
Log.e(TAG, "onBinderReceived", t);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register a ServiceListener to receive service binders from Xposed frameworks.<br/>
|
||||||
|
* This method should only be called once.
|
||||||
|
*
|
||||||
|
* @param listener Listener to register
|
||||||
|
*/
|
||||||
|
public static void registerListener(OnServiceListener listener) {
|
||||||
|
synchronized (mCache) {
|
||||||
|
mListener = listener;
|
||||||
|
if (!mCache.isEmpty()) {
|
||||||
|
for (Iterator<XposedService> it = mCache.iterator(); it.hasNext(); ) {
|
||||||
|
try {
|
||||||
|
XposedService service = it.next();
|
||||||
|
service.getRaw().asBinder().linkToDeath(() -> mListener.onServiceDied(service), 0);
|
||||||
|
mListener.onServiceBind(service);
|
||||||
|
} catch (Throwable t) {
|
||||||
|
Log.e(TAG, "registerListener", t);
|
||||||
|
it.remove();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
mCache.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,6 +16,9 @@ import io.nekohasekai.libbox.SetupOptions
|
|||||||
import io.nekohasekai.sfa.bg.AppChangeReceiver
|
import io.nekohasekai.sfa.bg.AppChangeReceiver
|
||||||
import io.nekohasekai.sfa.bg.UpdateProfileWork
|
import io.nekohasekai.sfa.bg.UpdateProfileWork
|
||||||
import io.nekohasekai.sfa.constant.Bugs
|
import io.nekohasekai.sfa.constant.Bugs
|
||||||
|
import io.nekohasekai.sfa.utils.HookModuleUpdateNotifier
|
||||||
|
import io.nekohasekai.sfa.utils.PrivilegeSettingsClient
|
||||||
|
import io.nekohasekai.sfa.utils.HookStatusClient
|
||||||
import io.nekohasekai.sfa.vendor.Vendor
|
import io.nekohasekai.sfa.vendor.Vendor
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.GlobalScope
|
import kotlinx.coroutines.GlobalScope
|
||||||
@@ -35,11 +38,14 @@ class Application : Application() {
|
|||||||
|
|
||||||
Seq.setContext(this)
|
Seq.setContext(this)
|
||||||
Libbox.setLocale(Locale.getDefault().toLanguageTag().replace("-", "_"))
|
Libbox.setLocale(Locale.getDefault().toLanguageTag().replace("-", "_"))
|
||||||
|
HookStatusClient.register(this)
|
||||||
|
PrivilegeSettingsClient.register(this)
|
||||||
|
|
||||||
@Suppress("OPT_IN_USAGE")
|
@Suppress("OPT_IN_USAGE")
|
||||||
GlobalScope.launch(Dispatchers.IO) {
|
GlobalScope.launch(Dispatchers.IO) {
|
||||||
initialize()
|
initialize()
|
||||||
UpdateProfileWork.reconfigureUpdater()
|
UpdateProfileWork.reconfigureUpdater()
|
||||||
|
HookModuleUpdateNotifier.sync(this@Application)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (Vendor.isPerAppProxyAvailable()) {
|
if (Vendor.isPerAppProxyAvailable()) {
|
||||||
|
|||||||
@@ -1,24 +0,0 @@
|
|||||||
package io.nekohasekai.sfa
|
|
||||||
|
|
||||||
import android.app.Activity
|
|
||||||
import android.content.Intent
|
|
||||||
import android.os.Bundle
|
|
||||||
import io.nekohasekai.sfa.compose.MainActivity
|
|
||||||
|
|
||||||
class LauncherActivity : Activity() {
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
|
||||||
super.onCreate(savedInstanceState)
|
|
||||||
|
|
||||||
val launchIntent =
|
|
||||||
Intent(this, MainActivity::class.java).apply {
|
|
||||||
intent?.let {
|
|
||||||
action = it.action
|
|
||||||
data = it.data
|
|
||||||
it.extras?.let { extras -> putExtras(extras) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
startActivity(launchIntent)
|
|
||||||
finish()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -9,7 +9,7 @@ import android.util.Log
|
|||||||
import android.widget.Toast
|
import android.widget.Toast
|
||||||
import io.nekohasekai.sfa.R
|
import io.nekohasekai.sfa.R
|
||||||
import io.nekohasekai.sfa.database.Settings
|
import io.nekohasekai.sfa.database.Settings
|
||||||
import io.nekohasekai.sfa.ui.profileoverride.PerAppProxyActivity
|
import io.nekohasekai.sfa.compose.screen.profileoverride.PerAppProxyScanner
|
||||||
import io.nekohasekai.sfa.vendor.PackageQueryManager
|
import io.nekohasekai.sfa.vendor.PackageQueryManager
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
@@ -64,7 +64,7 @@ class AppChangeReceiver : BroadcastReceiver() {
|
|||||||
val installedPackages = PackageQueryManager.getInstalledPackages(packageManagerFlags)
|
val installedPackages = PackageQueryManager.getInstalledPackages(packageManagerFlags)
|
||||||
val chinaApps = mutableSetOf<String>()
|
val chinaApps = mutableSetOf<String>()
|
||||||
for (packageInfo in installedPackages) {
|
for (packageInfo in installedPackages) {
|
||||||
if (PerAppProxyActivity.scanChinaPackage(packageInfo)) {
|
if (PerAppProxyScanner.scanChinaPackage(packageInfo)) {
|
||||||
chinaApps.add(packageInfo.packageName)
|
chinaApps.add(packageInfo.packageName)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
331
app/src/main/java/io/nekohasekai/sfa/bg/DebugInfoExporter.kt
Normal file
331
app/src/main/java/io/nekohasekai/sfa/bg/DebugInfoExporter.kt
Normal file
@@ -0,0 +1,331 @@
|
|||||||
|
package io.nekohasekai.sfa.bg
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.util.Log
|
||||||
|
import io.nekohasekai.sfa.utils.HookErrorClient
|
||||||
|
import java.io.BufferedInputStream
|
||||||
|
import java.io.BufferedOutputStream
|
||||||
|
import java.io.File
|
||||||
|
import java.io.FileInputStream
|
||||||
|
import java.io.FileOutputStream
|
||||||
|
import java.io.PrintWriter
|
||||||
|
import java.io.StringWriter
|
||||||
|
import java.text.SimpleDateFormat
|
||||||
|
import java.util.Date
|
||||||
|
import java.util.Locale
|
||||||
|
import java.util.zip.Deflater
|
||||||
|
import java.util.zip.ZipEntry
|
||||||
|
import java.util.zip.ZipOutputStream
|
||||||
|
|
||||||
|
object DebugInfoExporter {
|
||||||
|
private const val TAG = "DebugInfoExporter"
|
||||||
|
|
||||||
|
fun export(context: Context, outputPath: String, packageName: String): String {
|
||||||
|
Log.i(TAG, "export start: output=$outputPath, package=$packageName")
|
||||||
|
val outFile = File(outputPath)
|
||||||
|
if (!outFile.name.lowercase(Locale.US).endsWith(".zip")) {
|
||||||
|
Log.e(TAG, "export failed: output path must end with .zip")
|
||||||
|
throw IllegalArgumentException("output path must end with .zip")
|
||||||
|
}
|
||||||
|
val parent = outFile.parentFile!!
|
||||||
|
if (!parent.exists()) {
|
||||||
|
Log.i(TAG, "creating output directory: ${parent.path}")
|
||||||
|
if (!parent.mkdirs()) {
|
||||||
|
Log.e(TAG, "export failed: failed to create output directory: ${parent.path}")
|
||||||
|
throw IllegalStateException("failed to create output directory")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val warnings = mutableListOf<String>()
|
||||||
|
var entriesAdded = 0
|
||||||
|
try {
|
||||||
|
ZipOutputStream(BufferedOutputStream(FileOutputStream(outFile))).use { zip ->
|
||||||
|
Log.i(TAG, "adding export_info.txt")
|
||||||
|
addTextEntry(zip, "system/export_info.txt", buildExportInfo(context, packageName))
|
||||||
|
entriesAdded++
|
||||||
|
Log.i(TAG, "adding framework entries")
|
||||||
|
val frameworkCount = addFrameworkEntries(zip, warnings)
|
||||||
|
entriesAdded += frameworkCount
|
||||||
|
Log.i(TAG, "added $frameworkCount framework entries")
|
||||||
|
Log.i(TAG, "adding apex entries")
|
||||||
|
val apexCount = addApexEntries(zip, warnings)
|
||||||
|
entriesAdded += apexCount
|
||||||
|
Log.i(TAG, "added $apexCount apex entries")
|
||||||
|
Log.i(TAG, "adding log entries")
|
||||||
|
val logCount = addLogEntries(zip, warnings, context)
|
||||||
|
entriesAdded += logCount
|
||||||
|
Log.i(TAG, "added $logCount log entries")
|
||||||
|
Log.i(TAG, "adding system entries")
|
||||||
|
val systemCount = addSystemEntries(zip, warnings, packageName)
|
||||||
|
entriesAdded += systemCount
|
||||||
|
Log.i(TAG, "added $systemCount system entries")
|
||||||
|
if (warnings.isNotEmpty()) {
|
||||||
|
addTextEntry(zip, "logs/debug_export.txt", warnings.joinToString("\n"))
|
||||||
|
entriesAdded++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Log.i(TAG, "zip closed, total entries: $entriesAdded, file size: ${outFile.length()}")
|
||||||
|
} catch (e: Throwable) {
|
||||||
|
outFile.delete()
|
||||||
|
val error = buildError("zip", "export failed", e, warnings, outputPath)
|
||||||
|
Log.e(TAG, error, e)
|
||||||
|
throw e
|
||||||
|
}
|
||||||
|
if (outFile.length() == 0L) {
|
||||||
|
val error = "output file is empty after writing $entriesAdded entries"
|
||||||
|
Log.e(TAG, error)
|
||||||
|
outFile.delete()
|
||||||
|
throw IllegalStateException(error)
|
||||||
|
}
|
||||||
|
outFile.setReadable(true, false)
|
||||||
|
if (warnings.isNotEmpty()) {
|
||||||
|
Log.w(TAG, "export finished with ${warnings.size} warnings, output size: ${outFile.length()}")
|
||||||
|
} else {
|
||||||
|
Log.i(TAG, "export finished: output=$outputPath, size=${outFile.length()}")
|
||||||
|
}
|
||||||
|
return outFile.absolutePath
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun buildExportInfo(context: Context, packageName: String): String {
|
||||||
|
val sb = StringBuilder()
|
||||||
|
sb.append("package=").append(packageName).append('\n')
|
||||||
|
sb.append("timestamp=").append(System.currentTimeMillis()).append('\n')
|
||||||
|
sb.append("context_class=").append(context.javaClass.name).append('\n')
|
||||||
|
return sb.toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun addFrameworkEntries(zip: ZipOutputStream, warnings: MutableList<String>): Int {
|
||||||
|
var count = 0
|
||||||
|
val roots =
|
||||||
|
listOf(
|
||||||
|
File("/system/framework"),
|
||||||
|
File("/system_ext/framework"),
|
||||||
|
File("/product/framework"),
|
||||||
|
File("/vendor/framework"),
|
||||||
|
)
|
||||||
|
val targetFiles = setOf("framework.jar", "services.jar")
|
||||||
|
for (root in roots) {
|
||||||
|
if (!root.isDirectory) continue
|
||||||
|
val destPrefix = "framework/${root.name}"
|
||||||
|
val files = root.listFiles() ?: emptyArray()
|
||||||
|
for (file in files) {
|
||||||
|
if (!file.isFile) continue
|
||||||
|
if (file.name !in targetFiles) continue
|
||||||
|
if (addFileEntry(zip, file, "$destPrefix/${file.name}", warnings)) {
|
||||||
|
count++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return count
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun addApexEntries(zip: ZipOutputStream, warnings: MutableList<String>): Int {
|
||||||
|
var count = 0
|
||||||
|
val tetheringApex = File("/apex/com.android.tethering/javalib")
|
||||||
|
if (!tetheringApex.isDirectory) 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 count
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun addLogEntries(
|
||||||
|
zip: ZipOutputStream,
|
||||||
|
warnings: MutableList<String>,
|
||||||
|
context: Context,
|
||||||
|
): Int {
|
||||||
|
var count = 0
|
||||||
|
if (streamCommandToZip(zip, "logs/logcat.txt", warnings, listOf("logcat", "-d", "-b", "all")) != null) count++
|
||||||
|
if (streamCommandToZip(zip, "logs/dmesg.txt", warnings, listOf("dmesg")) != null) count++
|
||||||
|
val serviceLogsResult = HookErrorClient.query(context)
|
||||||
|
if (serviceLogsResult.logs.isNotEmpty()) {
|
||||||
|
val formatted = formatLogEntries(serviceLogsResult.logs)
|
||||||
|
addTextEntry(zip, "logs/service_logs.txt", formatted)
|
||||||
|
count++
|
||||||
|
} else if (serviceLogsResult.failure != null) {
|
||||||
|
warnings.add("service logs: ${serviceLogsResult.failure}${serviceLogsResult.detail?.let { " ($it)" } ?: ""}")
|
||||||
|
}
|
||||||
|
val lspdDir = File("/data/adb/lspd/log")
|
||||||
|
if (lspdDir.isDirectory) {
|
||||||
|
val files = lspdDir.listFiles() ?: emptyArray()
|
||||||
|
for (file in files) {
|
||||||
|
if (!file.isFile) continue
|
||||||
|
if (addFileEntry(zip, file, "logs/lspd/${file.name}", warnings)) count++
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
warnings.add("lspd logs not found: /data/adb/lspd/log")
|
||||||
|
}
|
||||||
|
return count
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun formatLogEntries(entries: List<LogEntry>): String {
|
||||||
|
val dateFormat = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.US)
|
||||||
|
return entries.joinToString("\n---\n") { entry ->
|
||||||
|
val levelName = when (entry.level) {
|
||||||
|
LogEntry.LEVEL_DEBUG -> "DEBUG"
|
||||||
|
LogEntry.LEVEL_INFO -> "INFO"
|
||||||
|
LogEntry.LEVEL_WARN -> "WARN"
|
||||||
|
LogEntry.LEVEL_ERROR -> "ERROR"
|
||||||
|
else -> "UNKNOWN"
|
||||||
|
}
|
||||||
|
val timestamp = dateFormat.format(Date(entry.timestamp))
|
||||||
|
buildString {
|
||||||
|
append(levelName).append("[").append(timestamp).append("] ")
|
||||||
|
append("[").append(entry.source).append("]: ")
|
||||||
|
append(entry.message)
|
||||||
|
if (!entry.stackTrace.isNullOrEmpty()) {
|
||||||
|
append("\n").append(entry.stackTrace)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun addSystemEntries(
|
||||||
|
zip: ZipOutputStream,
|
||||||
|
warnings: MutableList<String>,
|
||||||
|
packageName: String,
|
||||||
|
): Int {
|
||||||
|
var count = 0
|
||||||
|
if (streamCommandToZip(zip, "system/getprop.txt", warnings, listOf("getprop")) != null) count++
|
||||||
|
if (streamCommandToZip(zip, "system/uname.txt", warnings, listOf("uname", "-a")) != null) count++
|
||||||
|
if (streamCommandToZip(zip, "system/id.txt", warnings, listOf("id")) != null) count++
|
||||||
|
if (addFileEntry(zip, File("/proc/version"), "system/proc_version.txt", warnings)) count++
|
||||||
|
if (addFileEntry(zip, File("/proc/cpuinfo"), "system/cpuinfo.txt", warnings)) count++
|
||||||
|
if (addFileEntry(zip, File("/proc/meminfo"), "system/meminfo.txt", warnings)) count++
|
||||||
|
if (addFileEntry(zip, File("/proc/pressure/cpu"), "system/pressure_cpu.txt", warnings)) count++
|
||||||
|
if (addFileEntry(zip, File("/proc/pressure/memory"), "system/pressure_memory.txt", warnings)) count++
|
||||||
|
if (addFileEntry(zip, File("/proc/pressure/io"), "system/pressure_io.txt", warnings)) count++
|
||||||
|
val cmdPackages =
|
||||||
|
streamCommandToZip(
|
||||||
|
zip,
|
||||||
|
"system/packages_cmd.txt",
|
||||||
|
warnings,
|
||||||
|
listOf("cmd", "package", "list", "packages", "-f"),
|
||||||
|
)
|
||||||
|
if (cmdPackages != null) count++
|
||||||
|
if ((cmdPackages == null || cmdPackages.bytes == 0L) && (cmdPackages?.exitCode ?: 1) != 0) {
|
||||||
|
if (streamCommandToZip(
|
||||||
|
zip,
|
||||||
|
"system/packages_pm.txt",
|
||||||
|
warnings,
|
||||||
|
listOf("pm", "list", "packages", "-f"),
|
||||||
|
) != null) count++
|
||||||
|
}
|
||||||
|
if (streamCommandToZip(
|
||||||
|
zip,
|
||||||
|
"system/dumpsys_package_${packageName}.txt",
|
||||||
|
warnings,
|
||||||
|
listOf("dumpsys", "package", packageName),
|
||||||
|
) != null) count++
|
||||||
|
return count
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun addFileEntry(
|
||||||
|
zip: ZipOutputStream,
|
||||||
|
file: File,
|
||||||
|
entryName: String,
|
||||||
|
warnings: MutableList<String>,
|
||||||
|
): Boolean {
|
||||||
|
if (!file.isFile) {
|
||||||
|
warnings.add("missing file: ${file.path}")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
val entry = ZipEntry(entryName)
|
||||||
|
zip.putNextEntry(entry)
|
||||||
|
BufferedInputStream(FileInputStream(file)).use { input ->
|
||||||
|
val buffer = ByteArray(16 * 1024)
|
||||||
|
while (true) {
|
||||||
|
val read = input.read(buffer)
|
||||||
|
if (read <= 0) break
|
||||||
|
zip.write(buffer, 0, read)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
zip.closeEntry()
|
||||||
|
return true
|
||||||
|
} catch (e: Throwable) {
|
||||||
|
warnings.add("zip failed ${file.path}: ${e.message}")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun addTextEntry(zip: ZipOutputStream, entryName: String, content: String) {
|
||||||
|
val entry = ZipEntry(entryName)
|
||||||
|
zip.putNextEntry(entry)
|
||||||
|
val bytes = content.toByteArray()
|
||||||
|
zip.write(bytes)
|
||||||
|
zip.closeEntry()
|
||||||
|
}
|
||||||
|
|
||||||
|
private data class CommandResult(
|
||||||
|
val exitCode: Int,
|
||||||
|
val bytes: Long,
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun streamCommandToZip(
|
||||||
|
zip: ZipOutputStream,
|
||||||
|
entryName: String,
|
||||||
|
warnings: MutableList<String>,
|
||||||
|
command: List<String>,
|
||||||
|
): CommandResult? {
|
||||||
|
return try {
|
||||||
|
val process = ProcessBuilder(command).redirectErrorStream(true).start()
|
||||||
|
val entry = ZipEntry(entryName)
|
||||||
|
zip.putNextEntry(entry)
|
||||||
|
var bytes = 0L
|
||||||
|
process.inputStream.use { input ->
|
||||||
|
val buffer = ByteArray(16 * 1024)
|
||||||
|
while (true) {
|
||||||
|
val read = input.read(buffer)
|
||||||
|
if (read <= 0) break
|
||||||
|
zip.write(buffer, 0, read)
|
||||||
|
bytes += read
|
||||||
|
}
|
||||||
|
}
|
||||||
|
zip.closeEntry()
|
||||||
|
val code = process.waitFor()
|
||||||
|
if (code != 0) {
|
||||||
|
warnings.add("command failed (${command.joinToString(" ")}): exit=$code")
|
||||||
|
}
|
||||||
|
CommandResult(code, bytes)
|
||||||
|
} catch (e: Throwable) {
|
||||||
|
warnings.add("command failed (${command.joinToString(" ")}): ${e.message}")
|
||||||
|
runCatching { zip.closeEntry() }
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun buildError(
|
||||||
|
stage: String,
|
||||||
|
detail: String,
|
||||||
|
throwable: Throwable?,
|
||||||
|
warnings: List<String>,
|
||||||
|
outputPath: String?,
|
||||||
|
): String {
|
||||||
|
val sb = StringBuilder()
|
||||||
|
sb.append("stage=").append(stage).append('\n')
|
||||||
|
if (!outputPath.isNullOrBlank()) {
|
||||||
|
sb.append("output=").append(outputPath).append('\n')
|
||||||
|
}
|
||||||
|
if (detail.isNotBlank()) {
|
||||||
|
sb.append("detail=").append(detail).append('\n')
|
||||||
|
}
|
||||||
|
if (throwable != null) {
|
||||||
|
sb.append("exception=").append(throwable.javaClass.name)
|
||||||
|
.append(": ").append(throwable.message ?: "").append('\n')
|
||||||
|
val sw = StringWriter()
|
||||||
|
throwable.printStackTrace(PrintWriter(sw))
|
||||||
|
sb.append(sw.toString())
|
||||||
|
}
|
||||||
|
if (warnings.isNotEmpty()) {
|
||||||
|
if (!sb.endsWith('\n')) sb.append('\n')
|
||||||
|
sb.append("warnings:\n").append(warnings.joinToString("\n"))
|
||||||
|
}
|
||||||
|
return sb.toString().trimEnd()
|
||||||
|
}
|
||||||
|
}
|
||||||
65
app/src/main/java/io/nekohasekai/sfa/bg/LogEntry.java
Normal file
65
app/src/main/java/io/nekohasekai/sfa/bg/LogEntry.java
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
package io.nekohasekai.sfa.bg;
|
||||||
|
|
||||||
|
import android.os.Parcel;
|
||||||
|
import android.os.Parcelable;
|
||||||
|
|
||||||
|
import androidx.annotation.NonNull;
|
||||||
|
import androidx.annotation.Nullable;
|
||||||
|
|
||||||
|
public class LogEntry implements Parcelable {
|
||||||
|
public static final int LEVEL_DEBUG = 0;
|
||||||
|
public static final int LEVEL_INFO = 1;
|
||||||
|
public static final int LEVEL_WARN = 2;
|
||||||
|
public static final int LEVEL_ERROR = 3;
|
||||||
|
|
||||||
|
public final int level;
|
||||||
|
public final long timestamp;
|
||||||
|
@NonNull
|
||||||
|
public final String source;
|
||||||
|
@NonNull
|
||||||
|
public final String message;
|
||||||
|
@Nullable
|
||||||
|
public final String stackTrace;
|
||||||
|
|
||||||
|
public LogEntry(int level, long timestamp, @NonNull String source, @NonNull String message, @Nullable String stackTrace) {
|
||||||
|
this.level = level;
|
||||||
|
this.timestamp = timestamp;
|
||||||
|
this.source = source;
|
||||||
|
this.message = message;
|
||||||
|
this.stackTrace = stackTrace;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected LogEntry(Parcel in) {
|
||||||
|
level = in.readInt();
|
||||||
|
timestamp = in.readLong();
|
||||||
|
source = in.readString();
|
||||||
|
message = in.readString();
|
||||||
|
stackTrace = in.readString();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void writeToParcel(@NonNull Parcel dest, int flags) {
|
||||||
|
dest.writeInt(level);
|
||||||
|
dest.writeLong(timestamp);
|
||||||
|
dest.writeString(source);
|
||||||
|
dest.writeString(message);
|
||||||
|
dest.writeString(stackTrace);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public int describeContents() {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static final Creator<LogEntry> CREATOR = new Creator<>() {
|
||||||
|
@Override
|
||||||
|
public LogEntry createFromParcel(Parcel in) {
|
||||||
|
return new LogEntry(in);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public LogEntry[] newArray(int size) {
|
||||||
|
return new LogEntry[size];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
41
app/src/main/java/io/nekohasekai/sfa/bg/PackageEntry.java
Normal file
41
app/src/main/java/io/nekohasekai/sfa/bg/PackageEntry.java
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
package io.nekohasekai.sfa.bg;
|
||||||
|
|
||||||
|
import android.os.Parcel;
|
||||||
|
import android.os.Parcelable;
|
||||||
|
|
||||||
|
import androidx.annotation.NonNull;
|
||||||
|
|
||||||
|
public class PackageEntry implements Parcelable {
|
||||||
|
@NonNull
|
||||||
|
public final String packageName;
|
||||||
|
|
||||||
|
public PackageEntry(@NonNull String packageName) {
|
||||||
|
this.packageName = packageName;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected PackageEntry(Parcel in) {
|
||||||
|
packageName = in.readString();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void writeToParcel(@NonNull Parcel dest, int flags) {
|
||||||
|
dest.writeString(packageName);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public int describeContents() {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static final Creator<PackageEntry> CREATOR = new Creator<>() {
|
||||||
|
@Override
|
||||||
|
public PackageEntry createFromParcel(Parcel in) {
|
||||||
|
return new PackageEntry(in);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public PackageEntry[] newArray(int size) {
|
||||||
|
return new PackageEntry[size];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
152
app/src/main/java/io/nekohasekai/sfa/bg/ParceledListSlice.java
Normal file
152
app/src/main/java/io/nekohasekai/sfa/bg/ParceledListSlice.java
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (C) 2011 The Android Open Source Project
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.nekohasekai.sfa.bg;
|
||||||
|
|
||||||
|
import android.os.Binder;
|
||||||
|
import android.os.IBinder;
|
||||||
|
import android.os.Parcel;
|
||||||
|
import android.os.Parcelable;
|
||||||
|
import android.os.RemoteException;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
public class ParceledListSlice<T extends Parcelable> implements Parcelable {
|
||||||
|
private static final int MAX_IPC_SIZE = 64 * 1024;
|
||||||
|
|
||||||
|
private final List<T> mList;
|
||||||
|
|
||||||
|
public ParceledListSlice(List<T> list) {
|
||||||
|
mList = list;
|
||||||
|
}
|
||||||
|
|
||||||
|
private ParceledListSlice(Parcel in, ClassLoader loader) {
|
||||||
|
final int n = in.readInt();
|
||||||
|
mList = new ArrayList<>(n);
|
||||||
|
if (n <= 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
int i = 0;
|
||||||
|
while (i < n) {
|
||||||
|
if (in.readInt() == 0) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
T item = (T) in.readParcelable(loader);
|
||||||
|
mList.add(item);
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
if (i >= n) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final IBinder retriever = in.readStrongBinder();
|
||||||
|
while (i < n) {
|
||||||
|
Parcel data = Parcel.obtain();
|
||||||
|
Parcel reply = Parcel.obtain();
|
||||||
|
data.writeInt(i);
|
||||||
|
try {
|
||||||
|
retriever.transact(IBinder.FIRST_CALL_TRANSACTION, data, reply, 0);
|
||||||
|
} catch (RemoteException e) {
|
||||||
|
reply.recycle();
|
||||||
|
data.recycle();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
while (i < n && reply.readInt() != 0) {
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
T item = (T) reply.readParcelable(loader);
|
||||||
|
mList.add(item);
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
reply.recycle();
|
||||||
|
data.recycle();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<T> getList() {
|
||||||
|
return mList;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public int describeContents() {
|
||||||
|
int contents = 0;
|
||||||
|
for (int i = 0; i < mList.size(); i++) {
|
||||||
|
contents |= mList.get(i).describeContents();
|
||||||
|
}
|
||||||
|
return contents;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void writeToParcel(Parcel dest, int flags) {
|
||||||
|
final int n = mList.size();
|
||||||
|
dest.writeInt(n);
|
||||||
|
if (n <= 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
int i = 0;
|
||||||
|
while (i < n && dest.dataSize() < MAX_IPC_SIZE) {
|
||||||
|
dest.writeInt(1);
|
||||||
|
dest.writeParcelable(mList.get(i), flags);
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
if (i < n) {
|
||||||
|
dest.writeInt(0);
|
||||||
|
final int start = i;
|
||||||
|
Binder retriever = new Binder() {
|
||||||
|
@Override
|
||||||
|
protected boolean onTransact(int code, Parcel data, Parcel reply, int flags)
|
||||||
|
throws RemoteException {
|
||||||
|
if (code != FIRST_CALL_TRANSACTION) {
|
||||||
|
return super.onTransact(code, data, reply, flags);
|
||||||
|
}
|
||||||
|
int i = data.readInt();
|
||||||
|
if (i < start || i > n) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
while (i < n && reply.dataSize() < MAX_IPC_SIZE) {
|
||||||
|
reply.writeInt(1);
|
||||||
|
reply.writeParcelable(mList.get(i), flags);
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
if (i < n) {
|
||||||
|
reply.writeInt(0);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
dest.writeStrongBinder(retriever);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static final Parcelable.ClassLoaderCreator<ParceledListSlice> CREATOR =
|
||||||
|
new Parcelable.ClassLoaderCreator<ParceledListSlice>() {
|
||||||
|
@Override
|
||||||
|
public ParceledListSlice createFromParcel(Parcel in) {
|
||||||
|
return new ParceledListSlice(in, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public ParceledListSlice createFromParcel(Parcel in, ClassLoader loader) {
|
||||||
|
return new ParceledListSlice(in, loader);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public ParceledListSlice[] newArray(int size) {
|
||||||
|
return new ParceledListSlice[size];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,10 +1,11 @@
|
|||||||
package io.nekohasekai.sfa.vendor
|
package io.nekohasekai.sfa.bg
|
||||||
|
|
||||||
import android.content.ComponentName
|
import android.content.ComponentName
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.content.ServiceConnection
|
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 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
|
||||||
@@ -17,10 +18,8 @@ import kotlinx.coroutines.sync.Mutex
|
|||||||
import kotlinx.coroutines.sync.withLock
|
import kotlinx.coroutines.sync.withLock
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import kotlin.coroutines.resume
|
import kotlin.coroutines.resume
|
||||||
import kotlin.coroutines.resumeWithException
|
|
||||||
|
|
||||||
object RootPackageManager {
|
|
||||||
|
|
||||||
|
object RootClient {
|
||||||
init {
|
init {
|
||||||
Shell.enableVerboseLogging = BuildConfig.DEBUG
|
Shell.enableVerboseLogging = BuildConfig.DEBUG
|
||||||
Shell.setDefaultBuilder(
|
Shell.setDefaultBuilder(
|
||||||
@@ -36,7 +35,7 @@ object RootPackageManager {
|
|||||||
private val _serviceConnected = MutableStateFlow(false)
|
private val _serviceConnected = MutableStateFlow(false)
|
||||||
val serviceConnected: StateFlow<Boolean> = _serviceConnected
|
val serviceConnected: StateFlow<Boolean> = _serviceConnected
|
||||||
|
|
||||||
private var service: IRootPackageManager? = null
|
private var service: IRootService? = null
|
||||||
private var connection: ServiceConnection? = null
|
private var connection: ServiceConnection? = null
|
||||||
private val connectionMutex = Mutex()
|
private val connectionMutex = Mutex()
|
||||||
|
|
||||||
@@ -51,14 +50,14 @@ object RootPackageManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun bindService(): IRootPackageManager = connectionMutex.withLock {
|
suspend fun bindService(): IRootService = connectionMutex.withLock {
|
||||||
service?.let { return it }
|
service?.let { return it }
|
||||||
|
|
||||||
return withContext(Dispatchers.Main) {
|
return withContext(Dispatchers.Main) {
|
||||||
suspendCancellableCoroutine { continuation ->
|
suspendCancellableCoroutine { continuation ->
|
||||||
val conn = object : ServiceConnection {
|
val conn = object : ServiceConnection {
|
||||||
override fun onServiceConnected(name: ComponentName?, binder: IBinder?) {
|
override fun onServiceConnected(name: ComponentName?, binder: IBinder?) {
|
||||||
val svc = IRootPackageManager.Stub.asInterface(binder)
|
val svc = IRootService.Stub.asInterface(binder)
|
||||||
service = svc
|
service = svc
|
||||||
connection = this
|
connection = this
|
||||||
_serviceConnected.value = true
|
_serviceConnected.value = true
|
||||||
@@ -72,7 +71,7 @@ object RootPackageManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val intent = Intent(Application.application, RootPackageManagerService::class.java)
|
val intent = Intent(Application.application, RootServer::class.java)
|
||||||
RootService.bind(intent, conn)
|
RootService.bind(intent, conn)
|
||||||
|
|
||||||
continuation.invokeOnCancellation {
|
continuation.invokeOnCancellation {
|
||||||
@@ -91,18 +90,16 @@ object RootPackageManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private const val CHUNK_SIZE = 50
|
|
||||||
|
|
||||||
suspend fun getInstalledPackages(flags: Int): List<PackageInfo> {
|
suspend fun getInstalledPackages(flags: Int): List<PackageInfo> {
|
||||||
|
val userId = android.os.Process.myUserHandle().hashCode()
|
||||||
val svc = bindService()
|
val svc = bindService()
|
||||||
val result = mutableListOf<PackageInfo>()
|
return try {
|
||||||
var offset = 0
|
val slice = svc.getInstalledPackages(flags, userId)
|
||||||
while (true) {
|
@Suppress("UNCHECKED_CAST")
|
||||||
val chunk = svc.getInstalledPackages(flags, offset, CHUNK_SIZE)
|
val list = slice.list as List<PackageInfo>
|
||||||
if (chunk.isEmpty()) break
|
list
|
||||||
result.addAll(chunk)
|
} catch (e: RemoteException) {
|
||||||
offset += chunk.size
|
throw e.rethrowFromSystemServer()
|
||||||
}
|
}
|
||||||
return result
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
45
app/src/main/java/io/nekohasekai/sfa/bg/RootServer.kt
Normal file
45
app/src/main/java/io/nekohasekai/sfa/bg/RootServer.kt
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
package io.nekohasekai.sfa.bg
|
||||||
|
|
||||||
|
import io.nekohasekai.sfa.vendor.PrivilegedServiceUtils
|
||||||
|
|
||||||
|
import android.content.Intent
|
||||||
|
import android.content.pm.PackageInfo
|
||||||
|
import android.os.IBinder
|
||||||
|
import android.os.ParcelFileDescriptor
|
||||||
|
import com.topjohnwu.superuser.ipc.RootService
|
||||||
|
import io.nekohasekai.sfa.BuildConfig
|
||||||
|
import java.io.IOException
|
||||||
|
|
||||||
|
class RootServer : RootService() {
|
||||||
|
|
||||||
|
private val binder = object : IRootService.Stub() {
|
||||||
|
override fun destroy() {
|
||||||
|
stopSelf()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getInstalledPackages(
|
||||||
|
flags: Int,
|
||||||
|
userId: Int
|
||||||
|
): ParceledListSlice<PackageInfo> {
|
||||||
|
val allPackages = PrivilegedServiceUtils.getInstalledPackages(flags, userId)
|
||||||
|
return ParceledListSlice(allPackages)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun installPackage(apk: ParcelFileDescriptor?, size: Long, userId: Int) {
|
||||||
|
if (apk == null) throw IOException("APK file descriptor is null")
|
||||||
|
PrivilegedServiceUtils.installPackage(apk, size, userId)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun exportDebugInfo(outputPath: String?): String {
|
||||||
|
return DebugInfoExporter.export(
|
||||||
|
this@RootServer,
|
||||||
|
outputPath!!,
|
||||||
|
BuildConfig.APPLICATION_ID
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onBind(intent: Intent): IBinder {
|
||||||
|
return binder
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,7 +16,7 @@ import androidx.lifecycle.MutableLiveData
|
|||||||
import io.nekohasekai.libbox.Libbox
|
import io.nekohasekai.libbox.Libbox
|
||||||
import io.nekohasekai.libbox.StatusMessage
|
import io.nekohasekai.libbox.StatusMessage
|
||||||
import io.nekohasekai.sfa.Application
|
import io.nekohasekai.sfa.Application
|
||||||
import io.nekohasekai.sfa.LauncherActivity
|
import io.nekohasekai.sfa.compose.MainActivity
|
||||||
import io.nekohasekai.sfa.R
|
import io.nekohasekai.sfa.R
|
||||||
import io.nekohasekai.sfa.constant.Action
|
import io.nekohasekai.sfa.constant.Action
|
||||||
import io.nekohasekai.sfa.constant.Status
|
import io.nekohasekai.sfa.constant.Status
|
||||||
@@ -61,7 +61,7 @@ class ServiceNotification(
|
|||||||
0,
|
0,
|
||||||
Intent(
|
Intent(
|
||||||
service,
|
service,
|
||||||
LauncherActivity::class.java,
|
MainActivity::class.java,
|
||||||
).setFlags(Intent.FLAG_ACTIVITY_REORDER_TO_FRONT),
|
).setFlags(Intent.FLAG_ACTIVITY_REORDER_TO_FRONT),
|
||||||
flags,
|
flags,
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -1,207 +0,0 @@
|
|||||||
package io.nekohasekai.sfa.compose
|
|
||||||
|
|
||||||
import android.os.Bundle
|
|
||||||
import androidx.activity.ComponentActivity
|
|
||||||
import androidx.activity.compose.setContent
|
|
||||||
import androidx.activity.enableEdgeToEdge
|
|
||||||
import androidx.compose.animation.AnimatedContentTransitionScope
|
|
||||||
import androidx.compose.animation.core.tween
|
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.Surface
|
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
|
||||||
import androidx.navigation.NavType
|
|
||||||
import androidx.navigation.compose.NavHost
|
|
||||||
import androidx.navigation.compose.composable
|
|
||||||
import androidx.navigation.compose.rememberNavController
|
|
||||||
import androidx.navigation.navArgument
|
|
||||||
import io.nekohasekai.sfa.compose.screen.profile.EditProfileContentScreen
|
|
||||||
import io.nekohasekai.sfa.compose.screen.profile.EditProfileScreen
|
|
||||||
import io.nekohasekai.sfa.compose.screen.profile.EditProfileViewModel
|
|
||||||
import io.nekohasekai.sfa.compose.screen.profile.IconSelectionScreen
|
|
||||||
import io.nekohasekai.sfa.compose.theme.SFATheme
|
|
||||||
|
|
||||||
class EditProfileActivity : ComponentActivity() {
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
|
||||||
super.onCreate(savedInstanceState)
|
|
||||||
enableEdgeToEdge()
|
|
||||||
|
|
||||||
val profileId = intent.getLongExtra("profile_id", -1L)
|
|
||||||
if (profileId == -1L) {
|
|
||||||
finish()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
setContent {
|
|
||||||
SFATheme {
|
|
||||||
Surface(
|
|
||||||
modifier = Modifier.fillMaxSize(),
|
|
||||||
color = MaterialTheme.colorScheme.background,
|
|
||||||
) {
|
|
||||||
val navController = rememberNavController()
|
|
||||||
|
|
||||||
// Create a shared ViewModel at the activity level
|
|
||||||
val sharedViewModel: EditProfileViewModel = viewModel()
|
|
||||||
|
|
||||||
// Initialize the ViewModel with the profile ID
|
|
||||||
LaunchedEffect(profileId) {
|
|
||||||
sharedViewModel.loadProfile(profileId)
|
|
||||||
}
|
|
||||||
|
|
||||||
NavHost(
|
|
||||||
navController = navController,
|
|
||||||
startDestination = "edit_profile",
|
|
||||||
) {
|
|
||||||
composable(
|
|
||||||
route = "edit_profile",
|
|
||||||
enterTransition = {
|
|
||||||
slideIntoContainer(
|
|
||||||
AnimatedContentTransitionScope.SlideDirection.Left,
|
|
||||||
animationSpec = tween(300),
|
|
||||||
)
|
|
||||||
},
|
|
||||||
exitTransition = {
|
|
||||||
slideOutOfContainer(
|
|
||||||
AnimatedContentTransitionScope.SlideDirection.Left,
|
|
||||||
animationSpec = tween(300),
|
|
||||||
)
|
|
||||||
},
|
|
||||||
popEnterTransition = {
|
|
||||||
slideIntoContainer(
|
|
||||||
AnimatedContentTransitionScope.SlideDirection.Right,
|
|
||||||
animationSpec = tween(300),
|
|
||||||
)
|
|
||||||
},
|
|
||||||
popExitTransition = {
|
|
||||||
slideOutOfContainer(
|
|
||||||
AnimatedContentTransitionScope.SlideDirection.Right,
|
|
||||||
animationSpec = tween(300),
|
|
||||||
)
|
|
||||||
},
|
|
||||||
) {
|
|
||||||
EditProfileScreen(
|
|
||||||
profileId = profileId,
|
|
||||||
onNavigateBack = { finish() },
|
|
||||||
onNavigateToIconSelection = { currentIconId ->
|
|
||||||
navController.navigate("icon_selection/${currentIconId ?: "null"}") {
|
|
||||||
launchSingleTop = true
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onNavigateToEditContent = { profileName, isReadOnly ->
|
|
||||||
navController.navigate("edit_content/$profileName/$isReadOnly") {
|
|
||||||
launchSingleTop = true
|
|
||||||
}
|
|
||||||
},
|
|
||||||
viewModel = sharedViewModel,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
composable(
|
|
||||||
route = "icon_selection/{currentIconId}",
|
|
||||||
arguments =
|
|
||||||
listOf(
|
|
||||||
navArgument("currentIconId") {
|
|
||||||
type = NavType.StringType
|
|
||||||
nullable = true
|
|
||||||
},
|
|
||||||
),
|
|
||||||
enterTransition = {
|
|
||||||
slideIntoContainer(
|
|
||||||
AnimatedContentTransitionScope.SlideDirection.Left,
|
|
||||||
animationSpec = tween(300),
|
|
||||||
)
|
|
||||||
},
|
|
||||||
exitTransition = {
|
|
||||||
slideOutOfContainer(
|
|
||||||
AnimatedContentTransitionScope.SlideDirection.Left,
|
|
||||||
animationSpec = tween(300),
|
|
||||||
)
|
|
||||||
},
|
|
||||||
popEnterTransition = {
|
|
||||||
slideIntoContainer(
|
|
||||||
AnimatedContentTransitionScope.SlideDirection.Right,
|
|
||||||
animationSpec = tween(300),
|
|
||||||
)
|
|
||||||
},
|
|
||||||
popExitTransition = {
|
|
||||||
slideOutOfContainer(
|
|
||||||
AnimatedContentTransitionScope.SlideDirection.Right,
|
|
||||||
animationSpec = tween(300),
|
|
||||||
)
|
|
||||||
},
|
|
||||||
) { backStackEntry ->
|
|
||||||
val currentIconId =
|
|
||||||
backStackEntry.arguments?.getString("currentIconId")
|
|
||||||
?.takeIf { it != "null" }
|
|
||||||
|
|
||||||
IconSelectionScreen(
|
|
||||||
currentIconId = currentIconId,
|
|
||||||
onIconSelected = { iconId ->
|
|
||||||
// Update the shared ViewModel directly
|
|
||||||
sharedViewModel.updateIcon(iconId)
|
|
||||||
navController.popBackStack("edit_profile", inclusive = false)
|
|
||||||
},
|
|
||||||
onNavigateBack = {
|
|
||||||
navController.popBackStack("edit_profile", inclusive = false)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
composable(
|
|
||||||
route = "edit_content/{profileName}/{isReadOnly}",
|
|
||||||
arguments =
|
|
||||||
listOf(
|
|
||||||
navArgument("profileName") {
|
|
||||||
type = NavType.StringType
|
|
||||||
defaultValue = ""
|
|
||||||
},
|
|
||||||
navArgument("isReadOnly") {
|
|
||||||
type = NavType.BoolType
|
|
||||||
defaultValue = false
|
|
||||||
},
|
|
||||||
),
|
|
||||||
enterTransition = {
|
|
||||||
slideIntoContainer(
|
|
||||||
AnimatedContentTransitionScope.SlideDirection.Left,
|
|
||||||
animationSpec = tween(300),
|
|
||||||
)
|
|
||||||
},
|
|
||||||
exitTransition = {
|
|
||||||
slideOutOfContainer(
|
|
||||||
AnimatedContentTransitionScope.SlideDirection.Left,
|
|
||||||
animationSpec = tween(300),
|
|
||||||
)
|
|
||||||
},
|
|
||||||
popEnterTransition = {
|
|
||||||
slideIntoContainer(
|
|
||||||
AnimatedContentTransitionScope.SlideDirection.Right,
|
|
||||||
animationSpec = tween(300),
|
|
||||||
)
|
|
||||||
},
|
|
||||||
popExitTransition = {
|
|
||||||
slideOutOfContainer(
|
|
||||||
AnimatedContentTransitionScope.SlideDirection.Right,
|
|
||||||
animationSpec = tween(300),
|
|
||||||
)
|
|
||||||
},
|
|
||||||
) { backStackEntry ->
|
|
||||||
val profileName = backStackEntry.arguments?.getString("profileName") ?: ""
|
|
||||||
val isReadOnly = backStackEntry.arguments?.getBoolean("isReadOnly") ?: false
|
|
||||||
|
|
||||||
EditProfileContentScreen(
|
|
||||||
profileId = profileId,
|
|
||||||
onNavigateBack = {
|
|
||||||
navController.popBackStack("edit_profile", inclusive = false)
|
|
||||||
},
|
|
||||||
profileName = profileName,
|
|
||||||
isReadOnly = isReadOnly,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,124 +0,0 @@
|
|||||||
package io.nekohasekai.sfa.compose
|
|
||||||
|
|
||||||
import android.os.Bundle
|
|
||||||
import androidx.activity.ComponentActivity
|
|
||||||
import androidx.activity.compose.setContent
|
|
||||||
import androidx.activity.enableEdgeToEdge
|
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.material.icons.Icons
|
|
||||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
|
||||||
import androidx.compose.material.icons.filled.UnfoldLess
|
|
||||||
import androidx.compose.material.icons.filled.UnfoldMore
|
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
|
||||||
import androidx.compose.material3.Icon
|
|
||||||
import androidx.compose.material3.IconButton
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.Scaffold
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.material3.TopAppBar
|
|
||||||
import androidx.compose.material3.TopAppBarDefaults
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.collectAsState
|
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.runtime.mutableStateOf
|
|
||||||
import androidx.compose.runtime.setValue
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.res.stringResource
|
|
||||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
|
||||||
import io.nekohasekai.sfa.R
|
|
||||||
import io.nekohasekai.sfa.bg.ServiceConnection
|
|
||||||
import io.nekohasekai.sfa.compose.screen.dashboard.GroupsCard
|
|
||||||
import io.nekohasekai.sfa.compose.screen.dashboard.groups.GroupsViewModel
|
|
||||||
import io.nekohasekai.sfa.compose.theme.SFATheme
|
|
||||||
import io.nekohasekai.sfa.constant.Alert
|
|
||||||
import io.nekohasekai.sfa.constant.Status
|
|
||||||
|
|
||||||
class GroupsActivity : ComponentActivity(), ServiceConnection.Callback {
|
|
||||||
private val connection = ServiceConnection(this, this)
|
|
||||||
private var currentServiceStatus by mutableStateOf(Status.Stopped)
|
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
|
||||||
super.onCreate(savedInstanceState)
|
|
||||||
enableEdgeToEdge()
|
|
||||||
|
|
||||||
connection.reconnect()
|
|
||||||
|
|
||||||
setContent {
|
|
||||||
SFATheme {
|
|
||||||
GroupsApp()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
|
||||||
@Composable
|
|
||||||
fun GroupsApp() {
|
|
||||||
val viewModel: GroupsViewModel = viewModel()
|
|
||||||
val uiState by viewModel.uiState.collectAsState()
|
|
||||||
val allCollapsed = uiState.expandedGroups.isEmpty()
|
|
||||||
|
|
||||||
Scaffold(
|
|
||||||
modifier = Modifier.fillMaxSize(),
|
|
||||||
topBar = {
|
|
||||||
TopAppBar(
|
|
||||||
title = { Text(stringResource(R.string.title_groups)) },
|
|
||||||
navigationIcon = {
|
|
||||||
IconButton(onClick = { finish() }) {
|
|
||||||
Icon(
|
|
||||||
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
|
|
||||||
contentDescription = stringResource(R.string.content_description_back),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
actions = {
|
|
||||||
if (uiState.groups.isNotEmpty()) {
|
|
||||||
IconButton(onClick = { viewModel.toggleAllGroups() }) {
|
|
||||||
Icon(
|
|
||||||
imageVector =
|
|
||||||
if (allCollapsed) {
|
|
||||||
Icons.Default.UnfoldMore
|
|
||||||
} else {
|
|
||||||
Icons.Default.UnfoldLess
|
|
||||||
},
|
|
||||||
contentDescription =
|
|
||||||
if (allCollapsed) {
|
|
||||||
stringResource(R.string.expand_all)
|
|
||||||
} else {
|
|
||||||
stringResource(R.string.collapse_all)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
colors =
|
|
||||||
TopAppBarDefaults.topAppBarColors(
|
|
||||||
containerColor = MaterialTheme.colorScheme.surface,
|
|
||||||
titleContentColor = MaterialTheme.colorScheme.onSurface,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
},
|
|
||||||
) { paddingValues ->
|
|
||||||
GroupsCard(
|
|
||||||
serviceStatus = currentServiceStatus,
|
|
||||||
modifier = Modifier.padding(paddingValues),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onServiceStatusChanged(status: Status) {
|
|
||||||
currentServiceStatus = status
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onServiceAlert(
|
|
||||||
type: Alert,
|
|
||||||
message: String?,
|
|
||||||
) {
|
|
||||||
// Handle alerts if needed
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onDestroy() {
|
|
||||||
connection.disconnect()
|
|
||||||
super.onDestroy()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -23,13 +23,7 @@ import androidx.compose.foundation.layout.fillMaxWidth
|
|||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
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.filled.Close
|
|
||||||
import androidx.compose.material.icons.filled.ExpandLess
|
|
||||||
import androidx.compose.material.icons.filled.MoreVert
|
|
||||||
import androidx.compose.material.icons.filled.Pause
|
|
||||||
import androidx.compose.material.icons.filled.PlayArrow
|
import androidx.compose.material.icons.filled.PlayArrow
|
||||||
import androidx.compose.material.icons.filled.Search
|
|
||||||
import androidx.compose.material.icons.filled.Stop
|
import androidx.compose.material.icons.filled.Stop
|
||||||
import androidx.compose.material.icons.filled.UnfoldLess
|
import androidx.compose.material.icons.filled.UnfoldLess
|
||||||
import androidx.compose.material.icons.filled.UnfoldMore
|
import androidx.compose.material.icons.filled.UnfoldMore
|
||||||
@@ -64,10 +58,9 @@ import androidx.compose.material3.SnackbarHost
|
|||||||
import androidx.compose.material3.SnackbarHostState
|
import androidx.compose.material3.SnackbarHostState
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TextButton
|
import androidx.compose.material3.TextButton
|
||||||
import androidx.compose.material3.TopAppBar
|
|
||||||
import androidx.compose.material3.TopAppBarDefaults
|
|
||||||
import androidx.compose.material3.rememberModalBottomSheetState
|
import androidx.compose.material3.rememberModalBottomSheetState
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
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
|
||||||
@@ -94,13 +87,19 @@ import io.nekohasekai.sfa.R
|
|||||||
import io.nekohasekai.sfa.bg.ServiceConnection
|
import io.nekohasekai.sfa.bg.ServiceConnection
|
||||||
import io.nekohasekai.sfa.bg.ServiceNotification
|
import io.nekohasekai.sfa.bg.ServiceNotification
|
||||||
import io.nekohasekai.sfa.compose.base.GlobalEventBus
|
import io.nekohasekai.sfa.compose.base.GlobalEventBus
|
||||||
|
import io.nekohasekai.sfa.compose.base.SelectableMessageDialog
|
||||||
import io.nekohasekai.sfa.compose.base.UiEvent
|
import io.nekohasekai.sfa.compose.base.UiEvent
|
||||||
import io.nekohasekai.sfa.compose.component.ServiceStatusBar
|
import io.nekohasekai.sfa.compose.component.ServiceStatusBar
|
||||||
import io.nekohasekai.sfa.compose.component.UptimeText
|
import io.nekohasekai.sfa.compose.component.UptimeText
|
||||||
import io.nekohasekai.sfa.compose.component.UpdateAvailableDialog
|
import io.nekohasekai.sfa.compose.component.UpdateAvailableDialog
|
||||||
|
import io.nekohasekai.sfa.compose.navigation.NewProfileArgs
|
||||||
|
import io.nekohasekai.sfa.compose.navigation.ProfileRoutes
|
||||||
import io.nekohasekai.sfa.compose.navigation.SFANavHost
|
import io.nekohasekai.sfa.compose.navigation.SFANavHost
|
||||||
import io.nekohasekai.sfa.compose.navigation.Screen
|
import io.nekohasekai.sfa.compose.navigation.Screen
|
||||||
import io.nekohasekai.sfa.compose.navigation.bottomNavigationScreens
|
import io.nekohasekai.sfa.compose.navigation.bottomNavigationScreens
|
||||||
|
import io.nekohasekai.sfa.compose.topbar.LocalTopBarController
|
||||||
|
import io.nekohasekai.sfa.compose.topbar.TopBarEntry
|
||||||
|
import io.nekohasekai.sfa.compose.topbar.TopBarController
|
||||||
import io.nekohasekai.sfa.compose.screen.dashboard.CardGroup
|
import io.nekohasekai.sfa.compose.screen.dashboard.CardGroup
|
||||||
import io.nekohasekai.sfa.compose.screen.dashboard.DashboardViewModel
|
import io.nekohasekai.sfa.compose.screen.dashboard.DashboardViewModel
|
||||||
import io.nekohasekai.sfa.compose.screen.dashboard.GroupsCard
|
import io.nekohasekai.sfa.compose.screen.dashboard.GroupsCard
|
||||||
@@ -131,6 +130,7 @@ class MainActivity : ComponentActivity(), ServiceConnection.Callback {
|
|||||||
private var showBackgroundLocationDialog by mutableStateOf(false)
|
private var showBackgroundLocationDialog by mutableStateOf(false)
|
||||||
private var showImportProfileDialog by mutableStateOf(false)
|
private var showImportProfileDialog by mutableStateOf(false)
|
||||||
private var pendingImportProfile by mutableStateOf<Triple<String, String, String>?>(null)
|
private var pendingImportProfile by mutableStateOf<Triple<String, String, String>?>(null)
|
||||||
|
private var newProfileArgs by mutableStateOf(NewProfileArgs())
|
||||||
|
|
||||||
private val notificationPermissionLauncher =
|
private val notificationPermissionLauncher =
|
||||||
registerForActivityResult(
|
registerForActivityResult(
|
||||||
@@ -171,6 +171,7 @@ class MainActivity : ComponentActivity(), ServiceConnection.Callback {
|
|||||||
onServiceAlert(Alert.RequestVPNPermission, null)
|
onServiceAlert(Alert.RequestVPNPermission, null)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
private val pendingNavigationRoute = mutableStateOf<String?>(null)
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
@@ -204,7 +205,13 @@ class MainActivity : ComponentActivity(), ServiceConnection.Callback {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun handleIntent(intent: Intent?) {
|
private fun handleIntent(intent: Intent?) {
|
||||||
val uri = intent?.data ?: return
|
if (intent == null) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (intent.categories?.contains("de.robv.android.xposed.category.MODULE_SETTINGS") == true) {
|
||||||
|
pendingNavigationRoute.value = "settings/privilege"
|
||||||
|
}
|
||||||
|
val uri = intent.data ?: 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())
|
||||||
@@ -286,6 +293,15 @@ class MainActivity : ComponentActivity(), ServiceConnection.Callback {
|
|||||||
// Error dialog state for UiEvent.ShowError
|
// Error dialog state for UiEvent.ShowError
|
||||||
var showErrorDialog by remember { mutableStateOf(false) }
|
var showErrorDialog by remember { mutableStateOf(false) }
|
||||||
var errorMessage by remember { mutableStateOf("") }
|
var errorMessage by remember { mutableStateOf("") }
|
||||||
|
val topBarState = remember { mutableStateOf(emptyList<TopBarEntry>()) }
|
||||||
|
val topBarController = remember { TopBarController(topBarState) }
|
||||||
|
val topBarOverride = topBarState.value.lastOrNull()?.content
|
||||||
|
val openNewProfile: (NewProfileArgs) -> Unit = { args ->
|
||||||
|
newProfileArgs = args
|
||||||
|
navController.navigate(ProfileRoutes.NewProfile) {
|
||||||
|
launchSingleTop = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Handle service alerts
|
// Handle service alerts
|
||||||
currentAlert?.let { (alertType, message) ->
|
currentAlert?.let { (alertType, message) ->
|
||||||
@@ -298,15 +314,10 @@ class MainActivity : ComponentActivity(), ServiceConnection.Callback {
|
|||||||
|
|
||||||
// Handle UiEvent.ShowError dialog
|
// Handle UiEvent.ShowError dialog
|
||||||
if (showErrorDialog) {
|
if (showErrorDialog) {
|
||||||
AlertDialog(
|
SelectableMessageDialog(
|
||||||
onDismissRequest = { showErrorDialog = false },
|
title = stringResource(R.string.error_title),
|
||||||
title = { Text(stringResource(R.string.error_title)) },
|
message = errorMessage,
|
||||||
text = { Text(errorMessage) },
|
onDismiss = { showErrorDialog = false },
|
||||||
confirmButton = {
|
|
||||||
TextButton(onClick = { showErrorDialog = false }) {
|
|
||||||
Text(stringResource(R.string.ok))
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -341,11 +352,11 @@ class MainActivity : ComponentActivity(), ServiceConnection.Callback {
|
|||||||
text = { Text(stringResource(R.string.import_remote_profile_message, name, host)) },
|
text = { Text(stringResource(R.string.import_remote_profile_message, name, host)) },
|
||||||
confirmButton = {
|
confirmButton = {
|
||||||
TextButton(onClick = {
|
TextButton(onClick = {
|
||||||
startActivity(
|
openNewProfile(
|
||||||
Intent(this@MainActivity, NewProfileActivity::class.java).apply {
|
NewProfileArgs(
|
||||||
putExtra(NewProfileActivity.EXTRA_IMPORT_NAME, name)
|
importName = name,
|
||||||
putExtra(NewProfileActivity.EXTRA_IMPORT_URL, url)
|
importUrl = url,
|
||||||
},
|
),
|
||||||
)
|
)
|
||||||
showImportProfileDialog = false
|
showImportProfileDialog = false
|
||||||
pendingImportProfile = null
|
pendingImportProfile = null
|
||||||
@@ -432,17 +443,13 @@ class MainActivity : ComponentActivity(), ServiceConnection.Callback {
|
|||||||
downloadError = null
|
downloadError = null
|
||||||
downloadJob = scope.launch {
|
downloadJob = scope.launch {
|
||||||
try {
|
try {
|
||||||
val result = withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
Vendor.downloadAndInstall(
|
Vendor.downloadAndInstall(
|
||||||
this@MainActivity,
|
this@MainActivity,
|
||||||
updateInfo!!.downloadUrl,
|
updateInfo!!.downloadUrl,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (result.isFailure) {
|
|
||||||
downloadError = result.exceptionOrNull()?.message
|
|
||||||
} else {
|
|
||||||
showDownloadDialog = false
|
showDownloadDialog = false
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
downloadError = e.message
|
downloadError = e.message
|
||||||
}
|
}
|
||||||
@@ -496,40 +503,23 @@ class MainActivity : ComponentActivity(), ServiceConnection.Callback {
|
|||||||
|
|
||||||
val isSettingsSubScreen = currentRoute?.startsWith("settings/") == true
|
val isSettingsSubScreen = currentRoute?.startsWith("settings/") == true
|
||||||
val isConnectionsDetail = currentRoute?.startsWith("connections/detail") == true
|
val isConnectionsDetail = currentRoute?.startsWith("connections/detail") == true
|
||||||
|
val isProfileRoute = currentRoute?.startsWith("profile/") == true
|
||||||
val currentRootRoute =
|
val currentRootRoute =
|
||||||
when {
|
when {
|
||||||
isSettingsSubScreen -> Screen.Settings.route
|
isSettingsSubScreen -> Screen.Settings.route
|
||||||
currentRoute?.startsWith(Screen.Connections.route) == true -> Screen.Connections.route
|
currentRoute?.startsWith(Screen.Connections.route) == true -> Screen.Connections.route
|
||||||
currentRoute?.startsWith(Screen.Log.route) == true -> Screen.Log.route
|
currentRoute?.startsWith(Screen.Log.route) == true -> Screen.Log.route
|
||||||
|
isProfileRoute -> Screen.Dashboard.route
|
||||||
else -> currentRoute
|
else -> currentRoute
|
||||||
}
|
}
|
||||||
val isConnectionsRoute = currentRootRoute == Screen.Connections.route
|
val isConnectionsRoute = currentRootRoute == Screen.Connections.route
|
||||||
val isGroupsRoute = currentRootRoute == Screen.Groups.route
|
val isGroupsRoute = currentRootRoute == Screen.Groups.route
|
||||||
|
val isLogRoute = currentRootRoute == Screen.Log.route
|
||||||
|
|
||||||
// Determine current screen title
|
val isSubScreen = isSettingsSubScreen || isConnectionsDetail || isProfileRoute
|
||||||
val currentScreen =
|
|
||||||
when (currentRootRoute) {
|
|
||||||
Screen.Dashboard.route -> Screen.Dashboard
|
|
||||||
Screen.Groups.route -> Screen.Groups
|
|
||||||
Screen.Connections.route -> Screen.Connections
|
|
||||||
Screen.Log.route -> Screen.Log
|
|
||||||
Screen.Settings.route -> Screen.Settings
|
|
||||||
else -> Screen.Dashboard
|
|
||||||
}
|
|
||||||
|
|
||||||
val isSubScreen = isSettingsSubScreen || isConnectionsDetail
|
|
||||||
val settingsScreenTitle =
|
|
||||||
when (currentRoute) {
|
|
||||||
"settings/app" -> stringResource(R.string.title_app_settings)
|
|
||||||
"settings/core" -> stringResource(R.string.core)
|
|
||||||
"settings/service" -> stringResource(R.string.service)
|
|
||||||
"settings/profile_override" -> stringResource(R.string.profile_override)
|
|
||||||
else -> null
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get LogViewModel instance if we're on the Log screen
|
// Get LogViewModel instance if we're on the Log screen
|
||||||
val logViewModel: LogViewModel? =
|
val logViewModel: LogViewModel? =
|
||||||
if (currentScreen == Screen.Log) {
|
if (isLogRoute) {
|
||||||
viewModel()
|
viewModel()
|
||||||
} else {
|
} else {
|
||||||
null
|
null
|
||||||
@@ -593,6 +583,16 @@ class MainActivity : ComponentActivity(), ServiceConnection.Callback {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val pendingRoute = pendingNavigationRoute.value
|
||||||
|
LaunchedEffect(pendingRoute) {
|
||||||
|
if (pendingRoute != null) {
|
||||||
|
navController.navigate(pendingRoute) {
|
||||||
|
launchSingleTop = true
|
||||||
|
}
|
||||||
|
pendingNavigationRoute.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
LaunchedEffect(allowedRoutes, currentRootRoute, useNavigationRail) {
|
LaunchedEffect(allowedRoutes, currentRootRoute, useNavigationRail) {
|
||||||
if (currentRootRoute != null && !allowedRoutes.contains(currentRootRoute)) {
|
if (currentRootRoute != null && !allowedRoutes.contains(currentRootRoute)) {
|
||||||
navController.navigate(Screen.Dashboard.route) {
|
navController.navigate(Screen.Dashboard.route) {
|
||||||
@@ -627,10 +627,9 @@ class MainActivity : ComponentActivity(), ServiceConnection.Callback {
|
|||||||
}
|
}
|
||||||
|
|
||||||
is UiEvent.EditProfile -> {
|
is UiEvent.EditProfile -> {
|
||||||
val intent =
|
navController.navigate(ProfileRoutes.editProfile(event.profileId)) {
|
||||||
Intent(this@MainActivity, EditProfileActivity::class.java)
|
launchSingleTop = true
|
||||||
intent.putExtra("profile_id", event.profileId)
|
}
|
||||||
startActivity(intent)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
is UiEvent.RestartToTakeEffect -> {
|
is UiEvent.RestartToTakeEffect -> {
|
||||||
@@ -656,133 +655,7 @@ class MainActivity : ComponentActivity(), ServiceConnection.Callback {
|
|||||||
}
|
}
|
||||||
|
|
||||||
val topBarContent: @Composable () -> Unit = {
|
val topBarContent: @Composable () -> Unit = {
|
||||||
TopAppBar(
|
topBarOverride?.invoke()
|
||||||
title = {
|
|
||||||
Text(
|
|
||||||
when {
|
|
||||||
isSettingsSubScreen && settingsScreenTitle != null -> settingsScreenTitle
|
|
||||||
isConnectionsDetail -> stringResource(R.string.connection_details)
|
|
||||||
else -> stringResource(currentScreen.titleRes)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
},
|
|
||||||
navigationIcon = {
|
|
||||||
if (isSubScreen) {
|
|
||||||
IconButton(onClick = { navController.navigateUp() }) {
|
|
||||||
Icon(
|
|
||||||
imageVector = Icons.AutoMirrored.Default.ArrowBack,
|
|
||||||
contentDescription = stringResource(R.string.content_description_back),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
actions = {
|
|
||||||
// Show Others menu for Dashboard screen (but not in settings sub-screens)
|
|
||||||
if (currentScreen == Screen.Dashboard && !isSettingsSubScreen) {
|
|
||||||
// More options button
|
|
||||||
IconButton(onClick = { dashboardViewModel.toggleCardSettingsDialog() }) {
|
|
||||||
Icon(
|
|
||||||
imageVector = Icons.Default.MoreVert,
|
|
||||||
contentDescription = stringResource(R.string.title_others),
|
|
||||||
tint = MaterialTheme.colorScheme.onSurface,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (currentScreen == Screen.Groups && groupsViewModel != null) {
|
|
||||||
val groupsUiState by groupsViewModel.uiState.collectAsState()
|
|
||||||
val allCollapsed = groupsUiState.expandedGroups.isEmpty()
|
|
||||||
if (groupsUiState.groups.isNotEmpty()) {
|
|
||||||
IconButton(onClick = { groupsViewModel.toggleAllGroups() }) {
|
|
||||||
Icon(
|
|
||||||
imageVector = if (allCollapsed) Icons.Default.UnfoldMore else Icons.Default.UnfoldLess,
|
|
||||||
contentDescription =
|
|
||||||
if (allCollapsed) {
|
|
||||||
stringResource(R.string.expand_all)
|
|
||||||
} else {
|
|
||||||
stringResource(R.string.collapse_all)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isConnectionsDetail && connectionsViewModel != null) {
|
|
||||||
val connectionsUiState by connectionsViewModel.uiState.collectAsState()
|
|
||||||
val connectionId = navBackStackEntry?.arguments?.getString("connectionId")
|
|
||||||
val detailConnection =
|
|
||||||
connectionsUiState.allConnections.find { it.id == connectionId }
|
|
||||||
?: connectionsUiState.connections.find { it.id == connectionId }
|
|
||||||
if (detailConnection?.isActive == true) {
|
|
||||||
IconButton(onClick = { connectionsViewModel.closeConnection(detailConnection.id) }) {
|
|
||||||
Icon(
|
|
||||||
imageVector = Icons.Default.Close,
|
|
||||||
contentDescription = stringResource(R.string.connection_close),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (currentScreen == Screen.Log && logViewModel != null) {
|
|
||||||
val logUiState by logViewModel.uiState.collectAsState()
|
|
||||||
|
|
||||||
if (!logUiState.isSelectionMode) {
|
|
||||||
IconButton(onClick = { logViewModel.togglePause() }) {
|
|
||||||
Icon(
|
|
||||||
imageVector =
|
|
||||||
if (logUiState.isPaused) {
|
|
||||||
Icons.Default.PlayArrow
|
|
||||||
} else {
|
|
||||||
Icons.Default.Pause
|
|
||||||
},
|
|
||||||
contentDescription =
|
|
||||||
if (logUiState.isPaused) {
|
|
||||||
stringResource(
|
|
||||||
R.string.content_description_resume_logs,
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
stringResource(R.string.content_description_pause_logs)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
IconButton(onClick = { logViewModel.toggleSearch() }) {
|
|
||||||
Icon(
|
|
||||||
imageVector =
|
|
||||||
if (logUiState.isSearchActive) {
|
|
||||||
Icons.Default.ExpandLess
|
|
||||||
} else {
|
|
||||||
Icons.Default.Search
|
|
||||||
},
|
|
||||||
contentDescription =
|
|
||||||
if (logUiState.isSearchActive) {
|
|
||||||
stringResource(
|
|
||||||
R.string.content_description_collapse_search,
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
stringResource(R.string.content_description_search_logs)
|
|
||||||
},
|
|
||||||
tint =
|
|
||||||
if (logUiState.isSearchActive) {
|
|
||||||
MaterialTheme.colorScheme.primary
|
|
||||||
} else {
|
|
||||||
MaterialTheme.colorScheme.onSurface
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
IconButton(onClick = { logViewModel.toggleOptionsMenu() }) {
|
|
||||||
Icon(
|
|
||||||
imageVector = Icons.Default.MoreVert,
|
|
||||||
contentDescription = stringResource(R.string.more_options),
|
|
||||||
tint = MaterialTheme.colorScheme.onSurface,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
colors = TopAppBarDefaults.topAppBarColors(),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
val scaffoldContent: @Composable (PaddingValues) -> Unit = { paddingValues ->
|
val scaffoldContent: @Composable (PaddingValues) -> Unit = { paddingValues ->
|
||||||
@@ -802,6 +675,9 @@ class MainActivity : ComponentActivity(), ServiceConnection.Callback {
|
|||||||
serviceStatus = currentServiceStatus,
|
serviceStatus = currentServiceStatus,
|
||||||
showStartFab = showStartFab,
|
showStartFab = showStartFab,
|
||||||
showStatusBar = showStatusBar,
|
showStatusBar = showStatusBar,
|
||||||
|
newProfileArgs = newProfileArgs,
|
||||||
|
onClearNewProfileArgs = { newProfileArgs = NewProfileArgs() },
|
||||||
|
onOpenNewProfile = openNewProfile,
|
||||||
dashboardViewModel = dashboardViewModel,
|
dashboardViewModel = dashboardViewModel,
|
||||||
logViewModel = logViewModel,
|
logViewModel = logViewModel,
|
||||||
groupsViewModel = groupsViewModel,
|
groupsViewModel = groupsViewModel,
|
||||||
@@ -816,7 +692,7 @@ class MainActivity : ComponentActivity(), ServiceConnection.Callback {
|
|||||||
groupsCount = dashboardUiState.groupsCount,
|
groupsCount = dashboardUiState.groupsCount,
|
||||||
hasGroups = dashboardUiState.hasGroups,
|
hasGroups = dashboardUiState.hasGroups,
|
||||||
onGroupsClick = { showGroupsSheet = true },
|
onGroupsClick = { showGroupsSheet = true },
|
||||||
connectionsCount = dashboardUiState.connectionsOut.toIntOrNull() ?: 0,
|
connectionsCount = dashboardUiState.connectionsCount,
|
||||||
onConnectionsClick = { showConnectionsSheet = true },
|
onConnectionsClick = { showConnectionsSheet = true },
|
||||||
onStopClick = { dashboardViewModel.toggleService() },
|
onStopClick = { dashboardViewModel.toggleService() },
|
||||||
modifier = Modifier.align(Alignment.BottomCenter),
|
modifier = Modifier.align(Alignment.BottomCenter),
|
||||||
@@ -936,6 +812,7 @@ class MainActivity : ComponentActivity(), ServiceConnection.Callback {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
CompositionLocalProvider(LocalTopBarController provides topBarController) {
|
||||||
if (useNavigationRail) {
|
if (useNavigationRail) {
|
||||||
Row(modifier = Modifier.fillMaxSize()) {
|
Row(modifier = Modifier.fillMaxSize()) {
|
||||||
Surface(tonalElevation = 1.dp) {
|
Surface(tonalElevation = 1.dp) {
|
||||||
@@ -1026,6 +903,7 @@ class MainActivity : ComponentActivity(), ServiceConnection.Callback {
|
|||||||
scaffoldContent(paddingValues)
|
scaffoldContent(paddingValues)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Groups ModalBottomSheet
|
// Groups ModalBottomSheet
|
||||||
if (showGroupsSheet && !useNavigationRail) {
|
if (showGroupsSheet && !useNavigationRail) {
|
||||||
|
|||||||
@@ -1,55 +0,0 @@
|
|||||||
package io.nekohasekai.sfa.compose
|
|
||||||
|
|
||||||
import android.content.Intent
|
|
||||||
import android.os.Bundle
|
|
||||||
import androidx.activity.ComponentActivity
|
|
||||||
import androidx.activity.compose.setContent
|
|
||||||
import androidx.activity.enableEdgeToEdge
|
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.Surface
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import io.nekohasekai.sfa.compose.screen.configuration.NewProfileScreen
|
|
||||||
import io.nekohasekai.sfa.compose.theme.SFATheme
|
|
||||||
|
|
||||||
class NewProfileActivity : ComponentActivity() {
|
|
||||||
companion object {
|
|
||||||
const val EXTRA_PROFILE_ID = "profile_id"
|
|
||||||
const val EXTRA_IMPORT_NAME = "import_name"
|
|
||||||
const val EXTRA_IMPORT_URL = "import_url"
|
|
||||||
const val EXTRA_QRS_DATA = "qrs_data"
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
|
||||||
super.onCreate(savedInstanceState)
|
|
||||||
enableEdgeToEdge()
|
|
||||||
|
|
||||||
val importName = intent.getStringExtra(EXTRA_IMPORT_NAME)
|
|
||||||
val importUrl = intent.getStringExtra(EXTRA_IMPORT_URL)
|
|
||||||
val qrsData = intent.getByteArrayExtra(EXTRA_QRS_DATA)
|
|
||||||
|
|
||||||
setContent {
|
|
||||||
SFATheme {
|
|
||||||
Surface(
|
|
||||||
modifier = Modifier.fillMaxSize(),
|
|
||||||
color = MaterialTheme.colorScheme.background,
|
|
||||||
) {
|
|
||||||
NewProfileScreen(
|
|
||||||
importName = importName,
|
|
||||||
importUrl = importUrl,
|
|
||||||
qrsData = qrsData,
|
|
||||||
onNavigateBack = { finish() },
|
|
||||||
onProfileCreated = { profileId ->
|
|
||||||
val resultIntent =
|
|
||||||
Intent().apply {
|
|
||||||
putExtra(EXTRA_PROFILE_ID, profileId)
|
|
||||||
}
|
|
||||||
setResult(RESULT_OK, resultIntent)
|
|
||||||
finish()
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
package io.nekohasekai.sfa.compose.base
|
||||||
|
|
||||||
|
import android.widget.Toast
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.heightIn
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.text.selection.SelectionContainer
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material3.AlertDialog
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.platform.LocalClipboardManager
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.AnnotatedString
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import io.nekohasekai.sfa.R
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun SelectableMessageDialog(
|
||||||
|
title: String,
|
||||||
|
message: String,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
) {
|
||||||
|
val clipboard = LocalClipboardManager.current
|
||||||
|
val context = LocalContext.current
|
||||||
|
val scrollState = rememberScrollState()
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
title = { Text(title) },
|
||||||
|
text = {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.heightIn(max = 320.dp)
|
||||||
|
.verticalScroll(scrollState),
|
||||||
|
) {
|
||||||
|
SelectionContainer {
|
||||||
|
Text(message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(
|
||||||
|
onClick = {
|
||||||
|
clipboard.setText(AnnotatedString(message))
|
||||||
|
Toast.makeText(context, context.getString(R.string.copied_to_clipboard), Toast.LENGTH_SHORT).show()
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
Text(stringResource(R.string.per_app_proxy_action_copy))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(onClick = onDismiss) {
|
||||||
|
Text(stringResource(R.string.ok))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -56,7 +56,7 @@ import androidx.lifecycle.viewmodel.compose.viewModel
|
|||||||
import io.nekohasekai.sfa.R
|
import io.nekohasekai.sfa.R
|
||||||
import io.nekohasekai.sfa.compose.screen.qrscan.QRScanResult
|
import io.nekohasekai.sfa.compose.screen.qrscan.QRScanResult
|
||||||
import io.nekohasekai.sfa.compose.screen.qrscan.QRScanViewModel
|
import io.nekohasekai.sfa.compose.screen.qrscan.QRScanViewModel
|
||||||
import io.nekohasekai.sfa.ui.profile.QRCodeCropArea
|
import io.nekohasekai.sfa.compose.screen.qrscan.QRCodeCropArea
|
||||||
import kotlin.math.max
|
import kotlin.math.max
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
package io.nekohasekai.sfa.compose.navigation
|
||||||
|
|
||||||
|
data class NewProfileArgs(
|
||||||
|
val importName: String? = null,
|
||||||
|
val importUrl: String? = null,
|
||||||
|
val qrsData: ByteArray? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
object ProfileRoutes {
|
||||||
|
const val NewProfile = "profile/new"
|
||||||
|
const val EditProfile = "profile/edit/{profileId}"
|
||||||
|
const val EditProfileBase = "profile/edit"
|
||||||
|
|
||||||
|
fun editProfile(profileId: Long): String = "$EditProfileBase/$profileId"
|
||||||
|
}
|
||||||
@@ -5,25 +5,50 @@ import androidx.compose.animation.AnimatedContentTransitionScope
|
|||||||
import androidx.compose.animation.core.tween
|
import androidx.compose.animation.core.tween
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.DisposableEffect
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.navigation.NavHostController
|
import androidx.navigation.NavHostController
|
||||||
|
import androidx.navigation.NavType
|
||||||
import androidx.navigation.compose.NavHost
|
import androidx.navigation.compose.NavHost
|
||||||
import androidx.navigation.compose.composable
|
import androidx.navigation.compose.composable
|
||||||
|
import androidx.navigation.navArgument
|
||||||
import io.nekohasekai.sfa.compose.screen.dashboard.DashboardScreen
|
import io.nekohasekai.sfa.compose.screen.dashboard.DashboardScreen
|
||||||
import io.nekohasekai.sfa.compose.screen.dashboard.DashboardViewModel
|
import io.nekohasekai.sfa.compose.screen.dashboard.DashboardViewModel
|
||||||
import io.nekohasekai.sfa.compose.screen.dashboard.GroupsCard
|
import io.nekohasekai.sfa.compose.screen.dashboard.GroupsCard
|
||||||
import io.nekohasekai.sfa.compose.screen.dashboard.groups.GroupsViewModel
|
import io.nekohasekai.sfa.compose.screen.dashboard.groups.GroupsViewModel
|
||||||
import io.nekohasekai.sfa.compose.screen.connections.ConnectionDetailsRoute
|
import io.nekohasekai.sfa.compose.screen.connections.ConnectionDetailsRoute
|
||||||
import io.nekohasekai.sfa.compose.screen.connections.ConnectionsPage
|
import io.nekohasekai.sfa.compose.screen.connections.ConnectionsPage
|
||||||
|
import io.nekohasekai.sfa.compose.screen.log.HookLogScreen
|
||||||
import io.nekohasekai.sfa.compose.screen.log.LogScreen
|
import io.nekohasekai.sfa.compose.screen.log.LogScreen
|
||||||
import io.nekohasekai.sfa.compose.screen.log.LogViewModel
|
import io.nekohasekai.sfa.compose.screen.log.LogViewModel
|
||||||
import io.nekohasekai.sfa.compose.screen.connections.ConnectionsViewModel
|
import io.nekohasekai.sfa.compose.screen.connections.ConnectionsViewModel
|
||||||
|
import io.nekohasekai.sfa.compose.screen.configuration.NewProfileScreen
|
||||||
|
import io.nekohasekai.sfa.compose.screen.profile.EditProfileRoute
|
||||||
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.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
|
||||||
import io.nekohasekai.sfa.compose.screen.settings.SettingsScreen
|
import io.nekohasekai.sfa.compose.screen.settings.SettingsScreen
|
||||||
import io.nekohasekai.sfa.constant.Status
|
import io.nekohasekai.sfa.constant.Status
|
||||||
|
import io.nekohasekai.sfa.compose.screen.privilegesettings.PrivilegeSettingsManageScreen
|
||||||
|
import io.nekohasekai.sfa.compose.screen.profileoverride.PerAppProxyScreen
|
||||||
|
|
||||||
|
private val slideInFromRight: AnimatedContentTransitionScope<*>.() -> androidx.compose.animation.EnterTransition = {
|
||||||
|
slideIntoContainer(AnimatedContentTransitionScope.SlideDirection.Left, animationSpec = tween(300))
|
||||||
|
}
|
||||||
|
|
||||||
|
private val slideOutToRight: AnimatedContentTransitionScope<*>.() -> androidx.compose.animation.ExitTransition = {
|
||||||
|
slideOutOfContainer(AnimatedContentTransitionScope.SlideDirection.Right, animationSpec = tween(300))
|
||||||
|
}
|
||||||
|
|
||||||
|
private val slideInFromLeft: AnimatedContentTransitionScope<*>.() -> androidx.compose.animation.EnterTransition = {
|
||||||
|
slideIntoContainer(AnimatedContentTransitionScope.SlideDirection.Right, animationSpec = tween(300))
|
||||||
|
}
|
||||||
|
|
||||||
|
private val slideOutToLeft: AnimatedContentTransitionScope<*>.() -> androidx.compose.animation.ExitTransition = {
|
||||||
|
slideOutOfContainer(AnimatedContentTransitionScope.SlideDirection.Left, animationSpec = tween(300))
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun SFANavHost(
|
fun SFANavHost(
|
||||||
@@ -31,6 +56,9 @@ fun SFANavHost(
|
|||||||
serviceStatus: Status = Status.Stopped,
|
serviceStatus: Status = Status.Stopped,
|
||||||
showStartFab: Boolean = false,
|
showStartFab: Boolean = false,
|
||||||
showStatusBar: Boolean = false,
|
showStatusBar: Boolean = false,
|
||||||
|
newProfileArgs: NewProfileArgs = NewProfileArgs(),
|
||||||
|
onClearNewProfileArgs: () -> Unit = {},
|
||||||
|
onOpenNewProfile: (NewProfileArgs) -> Unit = {},
|
||||||
dashboardViewModel: DashboardViewModel? = null,
|
dashboardViewModel: DashboardViewModel? = null,
|
||||||
logViewModel: LogViewModel? = null,
|
logViewModel: LogViewModel? = null,
|
||||||
groupsViewModel: GroupsViewModel? = null,
|
groupsViewModel: GroupsViewModel? = null,
|
||||||
@@ -48,6 +76,7 @@ fun SFANavHost(
|
|||||||
serviceStatus = serviceStatus,
|
serviceStatus = serviceStatus,
|
||||||
showStartFab = showStartFab,
|
showStartFab = showStartFab,
|
||||||
showStatusBar = showStatusBar,
|
showStatusBar = showStatusBar,
|
||||||
|
onOpenNewProfile = onOpenNewProfile,
|
||||||
viewModel = dashboardViewModel,
|
viewModel = dashboardViewModel,
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
@@ -55,6 +84,7 @@ fun SFANavHost(
|
|||||||
serviceStatus = serviceStatus,
|
serviceStatus = serviceStatus,
|
||||||
showStartFab = showStartFab,
|
showStartFab = showStartFab,
|
||||||
showStatusBar = showStatusBar,
|
showStatusBar = showStatusBar,
|
||||||
|
onOpenNewProfile = onOpenNewProfile,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -81,11 +111,13 @@ fun SFANavHost(
|
|||||||
GroupsCard(
|
GroupsCard(
|
||||||
serviceStatus = serviceStatus,
|
serviceStatus = serviceStatus,
|
||||||
viewModel = groupsViewModel,
|
viewModel = groupsViewModel,
|
||||||
|
showTopBar = true,
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
GroupsCard(
|
GroupsCard(
|
||||||
serviceStatus = serviceStatus,
|
serviceStatus = serviceStatus,
|
||||||
|
showTopBar = true,
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -97,6 +129,7 @@ fun SFANavHost(
|
|||||||
serviceStatus = serviceStatus,
|
serviceStatus = serviceStatus,
|
||||||
viewModel = connectionsViewModel,
|
viewModel = connectionsViewModel,
|
||||||
showTitle = false,
|
showTitle = false,
|
||||||
|
showTopBar = true,
|
||||||
onConnectionClick = { connectionId ->
|
onConnectionClick = { connectionId ->
|
||||||
navController.navigate("connections/detail/${Uri.encode(connectionId)}")
|
navController.navigate("connections/detail/${Uri.encode(connectionId)}")
|
||||||
},
|
},
|
||||||
@@ -106,6 +139,7 @@ fun SFANavHost(
|
|||||||
ConnectionsPage(
|
ConnectionsPage(
|
||||||
serviceStatus = serviceStatus,
|
serviceStatus = serviceStatus,
|
||||||
showTitle = false,
|
showTitle = false,
|
||||||
|
showTopBar = true,
|
||||||
onConnectionClick = { connectionId ->
|
onConnectionClick = { connectionId ->
|
||||||
navController.navigate("connections/detail/${Uri.encode(connectionId)}")
|
navController.navigate("connections/detail/${Uri.encode(connectionId)}")
|
||||||
},
|
},
|
||||||
@@ -114,6 +148,45 @@ fun SFANavHost(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
composable(ProfileRoutes.NewProfile) {
|
||||||
|
DisposableEffect(Unit) {
|
||||||
|
onDispose { onClearNewProfileArgs() }
|
||||||
|
}
|
||||||
|
NewProfileScreen(
|
||||||
|
importName = newProfileArgs.importName,
|
||||||
|
importUrl = newProfileArgs.importUrl,
|
||||||
|
qrsData = newProfileArgs.qrsData,
|
||||||
|
onNavigateBack = {
|
||||||
|
onClearNewProfileArgs()
|
||||||
|
navController.navigateUp()
|
||||||
|
},
|
||||||
|
onProfileCreated = { profileId ->
|
||||||
|
onClearNewProfileArgs()
|
||||||
|
navController.navigate(ProfileRoutes.editProfile(profileId)) {
|
||||||
|
popUpTo(ProfileRoutes.NewProfile) {
|
||||||
|
inclusive = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
composable(
|
||||||
|
route = ProfileRoutes.EditProfile,
|
||||||
|
arguments = listOf(
|
||||||
|
navArgument("profileId") {
|
||||||
|
type = NavType.LongType
|
||||||
|
},
|
||||||
|
),
|
||||||
|
) { backStackEntry ->
|
||||||
|
val profileId = backStackEntry.arguments?.getLong("profileId") ?: -1L
|
||||||
|
EditProfileRoute(
|
||||||
|
profileId = profileId,
|
||||||
|
onNavigateBack = { navController.navigateUp() },
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
composable("connections/detail/{connectionId}") { backStackEntry ->
|
composable("connections/detail/{connectionId}") { backStackEntry ->
|
||||||
val connectionId = backStackEntry.arguments?.getString("connectionId")
|
val connectionId = backStackEntry.arguments?.getString("connectionId")
|
||||||
if (connectionId != null) {
|
if (connectionId != null) {
|
||||||
@@ -143,122 +216,82 @@ fun SFANavHost(
|
|||||||
// Settings subscreens with slide animations
|
// Settings subscreens with slide animations
|
||||||
composable(
|
composable(
|
||||||
route = "settings/app",
|
route = "settings/app",
|
||||||
enterTransition = {
|
enterTransition = slideInFromRight,
|
||||||
slideIntoContainer(
|
exitTransition = slideOutToLeft,
|
||||||
AnimatedContentTransitionScope.SlideDirection.Left,
|
popEnterTransition = slideInFromLeft,
|
||||||
animationSpec = tween(300),
|
popExitTransition = slideOutToRight,
|
||||||
)
|
|
||||||
},
|
|
||||||
exitTransition = {
|
|
||||||
slideOutOfContainer(
|
|
||||||
AnimatedContentTransitionScope.SlideDirection.Left,
|
|
||||||
animationSpec = tween(300),
|
|
||||||
)
|
|
||||||
},
|
|
||||||
popEnterTransition = {
|
|
||||||
slideIntoContainer(
|
|
||||||
AnimatedContentTransitionScope.SlideDirection.Right,
|
|
||||||
animationSpec = tween(300),
|
|
||||||
)
|
|
||||||
},
|
|
||||||
popExitTransition = {
|
|
||||||
slideOutOfContainer(
|
|
||||||
AnimatedContentTransitionScope.SlideDirection.Right,
|
|
||||||
animationSpec = tween(300),
|
|
||||||
)
|
|
||||||
},
|
|
||||||
) {
|
) {
|
||||||
AppSettingsScreen(navController = navController)
|
AppSettingsScreen(navController = navController)
|
||||||
}
|
}
|
||||||
|
|
||||||
composable(
|
composable(
|
||||||
route = "settings/core",
|
route = "settings/core",
|
||||||
enterTransition = {
|
enterTransition = slideInFromRight,
|
||||||
slideIntoContainer(
|
exitTransition = slideOutToRight,
|
||||||
AnimatedContentTransitionScope.SlideDirection.Left,
|
popEnterTransition = slideInFromRight,
|
||||||
animationSpec = tween(300),
|
popExitTransition = slideOutToRight,
|
||||||
)
|
|
||||||
},
|
|
||||||
exitTransition = {
|
|
||||||
slideOutOfContainer(
|
|
||||||
AnimatedContentTransitionScope.SlideDirection.Right,
|
|
||||||
animationSpec = tween(300),
|
|
||||||
)
|
|
||||||
},
|
|
||||||
popEnterTransition = {
|
|
||||||
slideIntoContainer(
|
|
||||||
AnimatedContentTransitionScope.SlideDirection.Left,
|
|
||||||
animationSpec = tween(300),
|
|
||||||
)
|
|
||||||
},
|
|
||||||
popExitTransition = {
|
|
||||||
slideOutOfContainer(
|
|
||||||
AnimatedContentTransitionScope.SlideDirection.Right,
|
|
||||||
animationSpec = tween(300),
|
|
||||||
)
|
|
||||||
},
|
|
||||||
) {
|
) {
|
||||||
CoreSettingsScreen(navController = navController)
|
CoreSettingsScreen(navController = navController)
|
||||||
}
|
}
|
||||||
|
|
||||||
composable(
|
composable(
|
||||||
route = "settings/service",
|
route = "settings/service",
|
||||||
enterTransition = {
|
enterTransition = slideInFromRight,
|
||||||
slideIntoContainer(
|
exitTransition = slideOutToLeft,
|
||||||
AnimatedContentTransitionScope.SlideDirection.Left,
|
popEnterTransition = slideInFromLeft,
|
||||||
animationSpec = tween(300),
|
popExitTransition = slideOutToRight,
|
||||||
)
|
|
||||||
},
|
|
||||||
exitTransition = {
|
|
||||||
slideOutOfContainer(
|
|
||||||
AnimatedContentTransitionScope.SlideDirection.Left,
|
|
||||||
animationSpec = tween(300),
|
|
||||||
)
|
|
||||||
},
|
|
||||||
popEnterTransition = {
|
|
||||||
slideIntoContainer(
|
|
||||||
AnimatedContentTransitionScope.SlideDirection.Right,
|
|
||||||
animationSpec = tween(300),
|
|
||||||
)
|
|
||||||
},
|
|
||||||
popExitTransition = {
|
|
||||||
slideOutOfContainer(
|
|
||||||
AnimatedContentTransitionScope.SlideDirection.Right,
|
|
||||||
animationSpec = tween(300),
|
|
||||||
)
|
|
||||||
},
|
|
||||||
) {
|
) {
|
||||||
ServiceSettingsScreen(navController = navController)
|
ServiceSettingsScreen(navController = navController)
|
||||||
}
|
}
|
||||||
|
|
||||||
composable(
|
composable(
|
||||||
route = "settings/profile_override",
|
route = "settings/profile_override",
|
||||||
enterTransition = {
|
enterTransition = slideInFromRight,
|
||||||
slideIntoContainer(
|
exitTransition = slideOutToLeft,
|
||||||
AnimatedContentTransitionScope.SlideDirection.Left,
|
popEnterTransition = slideInFromLeft,
|
||||||
animationSpec = tween(300),
|
popExitTransition = slideOutToRight,
|
||||||
)
|
|
||||||
},
|
|
||||||
exitTransition = {
|
|
||||||
slideOutOfContainer(
|
|
||||||
AnimatedContentTransitionScope.SlideDirection.Left,
|
|
||||||
animationSpec = tween(300),
|
|
||||||
)
|
|
||||||
},
|
|
||||||
popEnterTransition = {
|
|
||||||
slideIntoContainer(
|
|
||||||
AnimatedContentTransitionScope.SlideDirection.Right,
|
|
||||||
animationSpec = tween(300),
|
|
||||||
)
|
|
||||||
},
|
|
||||||
popExitTransition = {
|
|
||||||
slideOutOfContainer(
|
|
||||||
AnimatedContentTransitionScope.SlideDirection.Right,
|
|
||||||
animationSpec = tween(300),
|
|
||||||
)
|
|
||||||
},
|
|
||||||
) {
|
) {
|
||||||
ProfileOverrideScreen(navController = navController)
|
ProfileOverrideScreen(navController = navController)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
composable(
|
||||||
|
route = "settings/profile_override/manage",
|
||||||
|
enterTransition = slideInFromRight,
|
||||||
|
exitTransition = slideOutToLeft,
|
||||||
|
popEnterTransition = slideInFromLeft,
|
||||||
|
popExitTransition = slideOutToRight,
|
||||||
|
) {
|
||||||
|
PerAppProxyScreen(onBack = { navController.navigateUp() })
|
||||||
|
}
|
||||||
|
|
||||||
|
composable(
|
||||||
|
route = "settings/privilege",
|
||||||
|
enterTransition = slideInFromRight,
|
||||||
|
exitTransition = slideOutToLeft,
|
||||||
|
popEnterTransition = slideInFromLeft,
|
||||||
|
popExitTransition = slideOutToRight,
|
||||||
|
) {
|
||||||
|
PrivilegeSettingsScreen(navController = navController, serviceStatus = serviceStatus)
|
||||||
|
}
|
||||||
|
|
||||||
|
composable(
|
||||||
|
route = "settings/privilege/manage",
|
||||||
|
enterTransition = slideInFromRight,
|
||||||
|
exitTransition = slideOutToLeft,
|
||||||
|
popEnterTransition = slideInFromLeft,
|
||||||
|
popExitTransition = slideOutToRight,
|
||||||
|
) {
|
||||||
|
PrivilegeSettingsManageScreen(onBack = { navController.navigateUp() })
|
||||||
|
}
|
||||||
|
|
||||||
|
composable(
|
||||||
|
route = "settings/privilege/logs",
|
||||||
|
enterTransition = slideInFromRight,
|
||||||
|
exitTransition = slideOutToLeft,
|
||||||
|
popEnterTransition = slideInFromLeft,
|
||||||
|
popExitTransition = slideOutToRight,
|
||||||
|
) {
|
||||||
|
HookLogScreen(onBack = { navController.navigateUp() })
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ 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
|
||||||
import androidx.compose.foundation.layout.WindowInsets
|
import androidx.compose.foundation.layout.WindowInsets
|
||||||
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.navigationBars
|
import androidx.compose.foundation.layout.navigationBars
|
||||||
@@ -32,7 +33,6 @@ import androidx.compose.material.icons.filled.CloudDownload
|
|||||||
import androidx.compose.material.icons.filled.CreateNewFolder
|
import androidx.compose.material.icons.filled.CreateNewFolder
|
||||||
import androidx.compose.material.icons.filled.FileUpload
|
import androidx.compose.material.icons.filled.FileUpload
|
||||||
import androidx.compose.material.icons.filled.Save
|
import androidx.compose.material.icons.filled.Save
|
||||||
import androidx.compose.material3.AlertDialog
|
|
||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Button
|
||||||
import androidx.compose.material3.ButtonDefaults
|
import androidx.compose.material3.ButtonDefaults
|
||||||
import androidx.compose.material3.Card
|
import androidx.compose.material3.Card
|
||||||
@@ -46,11 +46,9 @@ import androidx.compose.material3.MaterialTheme
|
|||||||
import androidx.compose.material3.OutlinedButton
|
import androidx.compose.material3.OutlinedButton
|
||||||
import androidx.compose.material3.OutlinedCard
|
import androidx.compose.material3.OutlinedCard
|
||||||
import androidx.compose.material3.OutlinedTextField
|
import androidx.compose.material3.OutlinedTextField
|
||||||
import androidx.compose.material3.Scaffold
|
|
||||||
import androidx.compose.material3.Surface
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.material3.Switch
|
import androidx.compose.material3.Switch
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TextButton
|
|
||||||
import androidx.compose.material3.TopAppBar
|
import androidx.compose.material3.TopAppBar
|
||||||
import androidx.compose.material3.TopAppBarDefaults
|
import androidx.compose.material3.TopAppBarDefaults
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
@@ -68,6 +66,8 @@ import androidx.compose.ui.unit.dp
|
|||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
import io.nekohasekai.sfa.R
|
import io.nekohasekai.sfa.R
|
||||||
|
import io.nekohasekai.sfa.compose.base.SelectableMessageDialog
|
||||||
|
import io.nekohasekai.sfa.compose.topbar.OverrideTopBar
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
@@ -114,7 +114,6 @@ fun NewProfileScreen(
|
|||||||
if (uiState.isSuccess) {
|
if (uiState.isSuccess) {
|
||||||
uiState.createdProfile?.let { profile ->
|
uiState.createdProfile?.let { profile ->
|
||||||
onProfileCreated(profile.id)
|
onProfileCreated(profile.id)
|
||||||
onNavigateBack()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -128,28 +127,17 @@ fun NewProfileScreen(
|
|||||||
|
|
||||||
// Error dialog
|
// Error dialog
|
||||||
if (showErrorDialog) {
|
if (showErrorDialog) {
|
||||||
AlertDialog(
|
SelectableMessageDialog(
|
||||||
onDismissRequest = {
|
title = stringResource(R.string.error_title),
|
||||||
|
message = uiState.errorMessage ?: "",
|
||||||
|
onDismiss = {
|
||||||
showErrorDialog = false
|
showErrorDialog = false
|
||||||
viewModel.clearError()
|
viewModel.clearError()
|
||||||
},
|
},
|
||||||
title = { Text(stringResource(R.string.error_title)) },
|
|
||||||
text = { Text(uiState.errorMessage ?: "") },
|
|
||||||
confirmButton = {
|
|
||||||
TextButton(
|
|
||||||
onClick = {
|
|
||||||
showErrorDialog = false
|
|
||||||
viewModel.clearError()
|
|
||||||
},
|
|
||||||
) {
|
|
||||||
Text(stringResource(R.string.ok))
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
Scaffold(
|
OverrideTopBar {
|
||||||
topBar = {
|
|
||||||
TopAppBar(
|
TopAppBar(
|
||||||
title = { Text(stringResource(R.string.title_new_profile)) },
|
title = { Text(stringResource(R.string.title_new_profile)) },
|
||||||
navigationIcon = {
|
navigationIcon = {
|
||||||
@@ -165,52 +153,22 @@ fun NewProfileScreen(
|
|||||||
containerColor = MaterialTheme.colorScheme.surface,
|
containerColor = MaterialTheme.colorScheme.surface,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
},
|
|
||||||
bottomBar = {
|
|
||||||
Surface(
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
color = MaterialTheme.colorScheme.surface,
|
|
||||||
tonalElevation = 3.dp,
|
|
||||||
) {
|
|
||||||
Box(
|
|
||||||
modifier =
|
|
||||||
Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.windowInsetsPadding(WindowInsets.navigationBars)
|
|
||||||
.padding(16.dp),
|
|
||||||
) {
|
|
||||||
Button(
|
|
||||||
onClick = { viewModel.validateAndCreateProfile() },
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
enabled = !uiState.isSaving,
|
|
||||||
) {
|
|
||||||
if (uiState.isSaving) {
|
|
||||||
CircularProgressIndicator(
|
|
||||||
modifier = Modifier.size(18.dp),
|
|
||||||
strokeWidth = 2.dp,
|
|
||||||
color = MaterialTheme.colorScheme.onPrimary,
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
Icon(
|
|
||||||
Icons.Default.Save,
|
|
||||||
contentDescription = null,
|
|
||||||
modifier = Modifier.size(18.dp),
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.width(8.dp))
|
|
||||||
Text(stringResource(R.string.profile_create))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val bottomInset =
|
||||||
|
with(LocalDensity.current) {
|
||||||
|
WindowInsets.navigationBars.getBottom(this).toDp()
|
||||||
}
|
}
|
||||||
}
|
val bottomBarPadding = 88.dp + bottomInset
|
||||||
}
|
|
||||||
},
|
Box(modifier = Modifier.fillMaxSize()) {
|
||||||
) { paddingValues ->
|
|
||||||
Column(
|
Column(
|
||||||
modifier =
|
modifier =
|
||||||
Modifier
|
Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.padding(paddingValues)
|
|
||||||
.verticalScroll(rememberScrollState())
|
.verticalScroll(rememberScrollState())
|
||||||
.padding(16.dp),
|
.padding(16.dp)
|
||||||
|
.padding(bottom = bottomBarPadding),
|
||||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||||
) {
|
) {
|
||||||
// Profile Name
|
// Profile Name
|
||||||
@@ -589,5 +547,44 @@ fun NewProfileScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Surface(
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.align(Alignment.BottomCenter),
|
||||||
|
color = MaterialTheme.colorScheme.surface,
|
||||||
|
tonalElevation = 3.dp,
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.windowInsetsPadding(WindowInsets.navigationBars)
|
||||||
|
.padding(16.dp),
|
||||||
|
) {
|
||||||
|
Button(
|
||||||
|
onClick = { viewModel.validateAndCreateProfile() },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
enabled = !uiState.isSaving,
|
||||||
|
) {
|
||||||
|
if (uiState.isSaving) {
|
||||||
|
CircularProgressIndicator(
|
||||||
|
modifier = Modifier.size(18.dp),
|
||||||
|
strokeWidth = 2.dp,
|
||||||
|
color = MaterialTheme.colorScheme.onPrimary,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Icon(
|
||||||
|
Icons.Default.Save,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(18.dp),
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
|
Text(stringResource(R.string.profile_create))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import androidx.compose.ui.input.nestedscroll.NestedScrollSource
|
|||||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||||
import androidx.compose.ui.unit.Velocity
|
import androidx.compose.ui.unit.Velocity
|
||||||
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.filled.Check
|
import androidx.compose.material.icons.filled.Check
|
||||||
import androidx.compose.material.icons.filled.Clear
|
import androidx.compose.material.icons.filled.Clear
|
||||||
import androidx.compose.material.icons.filled.Close
|
import androidx.compose.material.icons.filled.Close
|
||||||
@@ -33,12 +34,14 @@ import androidx.compose.material.icons.filled.SwapVert
|
|||||||
import androidx.compose.material3.CircularProgressIndicator
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
import androidx.compose.material3.DropdownMenu
|
import androidx.compose.material3.DropdownMenu
|
||||||
import androidx.compose.material3.DropdownMenuItem
|
import androidx.compose.material3.DropdownMenuItem
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.FilterChip
|
import androidx.compose.material3.FilterChip
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.OutlinedTextField
|
import androidx.compose.material3.OutlinedTextField
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TopAppBar
|
||||||
import androidx.compose.runtime.Composable
|
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
|
||||||
@@ -54,16 +57,19 @@ import androidx.compose.ui.res.stringResource
|
|||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
import io.nekohasekai.sfa.R
|
import io.nekohasekai.sfa.R
|
||||||
|
import io.nekohasekai.sfa.compose.topbar.OverrideTopBar
|
||||||
import io.nekohasekai.sfa.compose.model.ConnectionSort
|
import io.nekohasekai.sfa.compose.model.ConnectionSort
|
||||||
import io.nekohasekai.sfa.compose.model.ConnectionStateFilter
|
import io.nekohasekai.sfa.compose.model.ConnectionStateFilter
|
||||||
import io.nekohasekai.sfa.constant.Status
|
import io.nekohasekai.sfa.constant.Status
|
||||||
import io.nekohasekai.sfa.compose.model.Connection
|
import io.nekohasekai.sfa.compose.model.Connection
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun ConnectionsPage(
|
fun ConnectionsPage(
|
||||||
serviceStatus: Status,
|
serviceStatus: Status,
|
||||||
viewModel: ConnectionsViewModel = viewModel(),
|
viewModel: ConnectionsViewModel = viewModel(),
|
||||||
showTitle: Boolean = true,
|
showTitle: Boolean = true,
|
||||||
|
showTopBar: Boolean = false,
|
||||||
onConnectionClick: (String) -> Unit = {},
|
onConnectionClick: (String) -> Unit = {},
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
@@ -72,6 +78,14 @@ fun ConnectionsPage(
|
|||||||
var showSortMenu by remember { mutableStateOf(false) }
|
var showSortMenu by remember { mutableStateOf(false) }
|
||||||
var showConnectionsMenu by remember { mutableStateOf(false) }
|
var showConnectionsMenu by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
if (showTopBar) {
|
||||||
|
OverrideTopBar {
|
||||||
|
TopAppBar(
|
||||||
|
title = { Text(stringResource(R.string.title_connections)) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Column(
|
Column(
|
||||||
modifier = modifier.fillMaxSize(),
|
modifier = modifier.fillMaxSize(),
|
||||||
) {
|
) {
|
||||||
@@ -253,6 +267,7 @@ fun ConnectionsPage(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun ConnectionDetailsRoute(
|
fun ConnectionDetailsRoute(
|
||||||
connectionId: String,
|
connectionId: String,
|
||||||
@@ -266,6 +281,30 @@ fun ConnectionDetailsRoute(
|
|||||||
uiState.allConnections.find { it.id == connectionId }
|
uiState.allConnections.find { it.id == connectionId }
|
||||||
?: uiState.connections.find { it.id == connectionId }
|
?: uiState.connections.find { it.id == connectionId }
|
||||||
|
|
||||||
|
OverrideTopBar {
|
||||||
|
TopAppBar(
|
||||||
|
title = { Text(stringResource(R.string.connection_details)) },
|
||||||
|
navigationIcon = {
|
||||||
|
IconButton(onClick = onBack) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
|
||||||
|
contentDescription = stringResource(R.string.content_description_back),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
actions = {
|
||||||
|
if (connection?.isActive == true) {
|
||||||
|
IconButton(onClick = { viewModel.closeConnection(connectionId) }) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Close,
|
||||||
|
contentDescription = stringResource(R.string.connection_close),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
LaunchedEffect(serviceStatus) {
|
LaunchedEffect(serviceStatus) {
|
||||||
viewModel.updateServiceStatus(serviceStatus)
|
viewModel.updateServiceStatus(serviceStatus)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package io.nekohasekai.sfa.compose.screen.dashboard
|
|||||||
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import io.nekohasekai.sfa.compose.navigation.NewProfileArgs
|
||||||
import io.nekohasekai.sfa.constant.Status
|
import io.nekohasekai.sfa.constant.Status
|
||||||
import io.nekohasekai.sfa.database.Profile
|
import io.nekohasekai.sfa.database.Profile
|
||||||
import io.nekohasekai.sfa.utils.CommandClient
|
import io.nekohasekai.sfa.utils.CommandClient
|
||||||
@@ -33,6 +34,7 @@ fun DashboardCardRenderer(
|
|||||||
onHideAddProfileSheet: () -> Unit = {},
|
onHideAddProfileSheet: () -> Unit = {},
|
||||||
onShowProfilePickerSheet: () -> Unit = {},
|
onShowProfilePickerSheet: () -> Unit = {},
|
||||||
onHideProfilePickerSheet: () -> Unit = {},
|
onHideProfilePickerSheet: () -> Unit = {},
|
||||||
|
onOpenNewProfile: (NewProfileArgs) -> Unit = {},
|
||||||
commandClient: CommandClient? = null,
|
commandClient: CommandClient? = null,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
@@ -121,9 +123,7 @@ fun DashboardCardRenderer(
|
|||||||
onHideAddProfileSheet = onHideAddProfileSheet,
|
onHideAddProfileSheet = onHideAddProfileSheet,
|
||||||
onShowProfilePickerSheet = onShowProfilePickerSheet,
|
onShowProfilePickerSheet = onShowProfilePickerSheet,
|
||||||
onHideProfilePickerSheet = onHideProfilePickerSheet,
|
onHideProfilePickerSheet = onHideProfilePickerSheet,
|
||||||
onImportFromFile = { /* Handled in ProfilesCard */ },
|
onOpenNewProfile = onOpenNewProfile,
|
||||||
onScanQrCode = { /* Handled in ProfilesCard */ },
|
|
||||||
onCreateManually = { /* Handled in ProfilesCard */ },
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,10 +9,15 @@ import androidx.compose.foundation.layout.fillMaxWidth
|
|||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.items
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.MoreVert
|
||||||
import androidx.compose.material3.AlertDialog
|
import androidx.compose.material3.AlertDialog
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TextButton
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.material3.TopAppBar
|
||||||
import androidx.compose.material3.rememberModalBottomSheetState
|
import androidx.compose.material3.rememberModalBottomSheetState
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
@@ -27,6 +32,8 @@ import androidx.compose.ui.unit.dp
|
|||||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
import io.nekohasekai.sfa.R
|
import io.nekohasekai.sfa.R
|
||||||
import io.nekohasekai.sfa.compose.base.UiEvent
|
import io.nekohasekai.sfa.compose.base.UiEvent
|
||||||
|
import io.nekohasekai.sfa.compose.navigation.NewProfileArgs
|
||||||
|
import io.nekohasekai.sfa.compose.topbar.OverrideTopBar
|
||||||
import io.nekohasekai.sfa.constant.Status
|
import io.nekohasekai.sfa.constant.Status
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
@@ -41,10 +48,25 @@ fun DashboardScreen(
|
|||||||
serviceStatus: Status = Status.Stopped,
|
serviceStatus: Status = Status.Stopped,
|
||||||
showStartFab: Boolean = false,
|
showStartFab: Boolean = false,
|
||||||
showStatusBar: Boolean = false,
|
showStatusBar: Boolean = false,
|
||||||
|
onOpenNewProfile: (NewProfileArgs) -> Unit = {},
|
||||||
viewModel: DashboardViewModel = viewModel(),
|
viewModel: DashboardViewModel = viewModel(),
|
||||||
) {
|
) {
|
||||||
val uiState by viewModel.uiState.collectAsState()
|
val uiState by viewModel.uiState.collectAsState()
|
||||||
|
|
||||||
|
OverrideTopBar {
|
||||||
|
TopAppBar(
|
||||||
|
title = { Text(stringResource(R.string.title_dashboard)) },
|
||||||
|
actions = {
|
||||||
|
IconButton(onClick = { viewModel.toggleCardSettingsDialog() }) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.MoreVert,
|
||||||
|
contentDescription = stringResource(R.string.title_others),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// Update service status in ViewModel
|
// Update service status in ViewModel
|
||||||
LaunchedEffect(serviceStatus) {
|
LaunchedEffect(serviceStatus) {
|
||||||
viewModel.updateServiceStatus(serviceStatus)
|
viewModel.updateServiceStatus(serviceStatus)
|
||||||
@@ -174,6 +196,7 @@ fun DashboardScreen(
|
|||||||
onHideAddProfileSheet = viewModel::hideAddProfileSheet,
|
onHideAddProfileSheet = viewModel::hideAddProfileSheet,
|
||||||
onShowProfilePickerSheet = viewModel::showProfilePickerSheet,
|
onShowProfilePickerSheet = viewModel::showProfilePickerSheet,
|
||||||
onHideProfilePickerSheet = viewModel::hideProfilePickerSheet,
|
onHideProfilePickerSheet = viewModel::hideProfilePickerSheet,
|
||||||
|
onOpenNewProfile = onOpenNewProfile,
|
||||||
commandClient = viewModel.commandClient,
|
commandClient = viewModel.commandClient,
|
||||||
modifier =
|
modifier =
|
||||||
Modifier
|
Modifier
|
||||||
@@ -213,6 +236,7 @@ fun DashboardScreen(
|
|||||||
onHideAddProfileSheet = viewModel::hideAddProfileSheet,
|
onHideAddProfileSheet = viewModel::hideAddProfileSheet,
|
||||||
onShowProfilePickerSheet = viewModel::showProfilePickerSheet,
|
onShowProfilePickerSheet = viewModel::showProfilePickerSheet,
|
||||||
onHideProfilePickerSheet = viewModel::hideProfilePickerSheet,
|
onHideProfilePickerSheet = viewModel::hideProfilePickerSheet,
|
||||||
|
onOpenNewProfile = onOpenNewProfile,
|
||||||
commandClient = viewModel.commandClient,
|
commandClient = viewModel.commandClient,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -64,7 +64,6 @@ import androidx.compose.ui.res.stringResource
|
|||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.zIndex
|
import androidx.compose.ui.zIndex
|
||||||
import io.nekohasekai.sfa.BuildConfig
|
|
||||||
import io.nekohasekai.sfa.R
|
import io.nekohasekai.sfa.R
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@@ -78,17 +77,13 @@ fun DashboardSettingsBottomSheet(
|
|||||||
onResetOrder: () -> Unit,
|
onResetOrder: () -> Unit,
|
||||||
onDismiss: () -> Unit,
|
onDismiss: () -> Unit,
|
||||||
) {
|
) {
|
||||||
val filteredCardOrder =
|
var reorderedList by remember(cardOrder) { mutableStateOf(cardOrder) }
|
||||||
if (BuildConfig.DEBUG) cardOrder else cardOrder.filter { it != CardGroup.Debug }
|
var currentVisibleCards by remember(visibleCards) { mutableStateOf(visibleCards) }
|
||||||
val filteredVisibleCards =
|
|
||||||
if (BuildConfig.DEBUG) visibleCards else visibleCards.filter { it != CardGroup.Debug }.toSet()
|
|
||||||
var reorderedList by remember(filteredCardOrder) { mutableStateOf(filteredCardOrder) }
|
|
||||||
var currentVisibleCards by remember(filteredVisibleCards) { mutableStateOf(filteredVisibleCards) }
|
|
||||||
|
|
||||||
// Update local state when props change (e.g., after reset)
|
// Update local state when props change (e.g., after reset)
|
||||||
LaunchedEffect(filteredCardOrder, filteredVisibleCards) {
|
LaunchedEffect(cardOrder, visibleCards) {
|
||||||
reorderedList = filteredCardOrder
|
reorderedList = cardOrder
|
||||||
currentVisibleCards = filteredVisibleCards
|
currentVisibleCards = visibleCards
|
||||||
}
|
}
|
||||||
|
|
||||||
val hapticFeedback = LocalHapticFeedback.current
|
val hapticFeedback = LocalHapticFeedback.current
|
||||||
@@ -166,7 +161,7 @@ fun DashboardSettingsBottomSheet(
|
|||||||
listOfNotNull(
|
listOfNotNull(
|
||||||
CardGroup.UploadTraffic,
|
CardGroup.UploadTraffic,
|
||||||
CardGroup.DownloadTraffic,
|
CardGroup.DownloadTraffic,
|
||||||
if (BuildConfig.DEBUG) CardGroup.Debug else null,
|
CardGroup.Debug,
|
||||||
CardGroup.Connections,
|
CardGroup.Connections,
|
||||||
CardGroup.SystemProxy,
|
CardGroup.SystemProxy,
|
||||||
CardGroup.ClashMode,
|
CardGroup.ClashMode,
|
||||||
@@ -177,7 +172,7 @@ fun DashboardSettingsBottomSheet(
|
|||||||
CardGroup.ClashMode,
|
CardGroup.ClashMode,
|
||||||
CardGroup.UploadTraffic,
|
CardGroup.UploadTraffic,
|
||||||
CardGroup.DownloadTraffic,
|
CardGroup.DownloadTraffic,
|
||||||
if (BuildConfig.DEBUG) CardGroup.Debug else null,
|
CardGroup.Debug,
|
||||||
CardGroup.Connections,
|
CardGroup.Connections,
|
||||||
CardGroup.SystemProxy,
|
CardGroup.SystemProxy,
|
||||||
CardGroup.Profiles,
|
CardGroup.Profiles,
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
package io.nekohasekai.sfa.compose.screen.dashboard
|
package io.nekohasekai.sfa.compose.screen.dashboard
|
||||||
|
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import io.nekohasekai.libbox.Connections
|
||||||
import io.nekohasekai.libbox.Libbox
|
import io.nekohasekai.libbox.Libbox
|
||||||
import io.nekohasekai.libbox.OutboundGroup
|
import io.nekohasekai.libbox.OutboundGroup
|
||||||
import io.nekohasekai.libbox.StatusMessage
|
import io.nekohasekai.libbox.StatusMessage
|
||||||
|
import io.nekohasekai.sfa.ktx.toList
|
||||||
import io.nekohasekai.sfa.bg.BoxService
|
import io.nekohasekai.sfa.bg.BoxService
|
||||||
import io.nekohasekai.sfa.compose.base.BaseViewModel
|
import io.nekohasekai.sfa.compose.base.BaseViewModel
|
||||||
import io.nekohasekai.sfa.compose.base.UiEvent
|
import io.nekohasekai.sfa.compose.base.UiEvent
|
||||||
@@ -50,6 +52,7 @@ data class DashboardUiState(
|
|||||||
val isLoading: Boolean = false,
|
val isLoading: Boolean = false,
|
||||||
val hasGroups: Boolean = false,
|
val hasGroups: Boolean = false,
|
||||||
val groupsCount: Int = 0,
|
val groupsCount: Int = 0,
|
||||||
|
val connectionsCount: Int = 0,
|
||||||
val serviceStartTime: Long? = null,
|
val serviceStartTime: Long? = null,
|
||||||
val deprecatedNotes: List<DeprecatedNote> = emptyList(),
|
val deprecatedNotes: List<DeprecatedNote> = emptyList(),
|
||||||
val showDeprecatedDialog: Boolean = false,
|
val showDeprecatedDialog: Boolean = false,
|
||||||
@@ -630,6 +633,13 @@ class DashboardViewModel : BaseViewModel<DashboardUiState, UiEvent>(), CommandCl
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun updateConnections(connections: Connections) {
|
||||||
|
viewModelScope.launch(Dispatchers.Main) {
|
||||||
|
val count = connections.iterator().toList().count { it.outboundType != "dns" }
|
||||||
|
updateState { copy(connectionsCount = count) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun toggleCardSettingsDialog() {
|
fun toggleCardSettingsDialog() {
|
||||||
updateState {
|
updateState {
|
||||||
copy(showCardSettingsDialog = !showCardSettingsDialog)
|
copy(showCardSettingsDialog = !showCardSettingsDialog)
|
||||||
|
|||||||
@@ -26,6 +26,8 @@ import androidx.compose.foundation.lazy.items
|
|||||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.UnfoldLess
|
||||||
|
import androidx.compose.material.icons.filled.UnfoldMore
|
||||||
import androidx.compose.material.icons.filled.ExpandMore
|
import androidx.compose.material.icons.filled.ExpandMore
|
||||||
import androidx.compose.material.icons.filled.Speed
|
import androidx.compose.material.icons.filled.Speed
|
||||||
import androidx.compose.material3.Card
|
import androidx.compose.material3.Card
|
||||||
@@ -40,6 +42,7 @@ import androidx.compose.material3.MaterialTheme
|
|||||||
import androidx.compose.material3.SnackbarHostState
|
import androidx.compose.material3.SnackbarHostState
|
||||||
import androidx.compose.material3.Surface
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TopAppBar
|
||||||
import androidx.compose.runtime.Composable
|
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
|
||||||
@@ -63,17 +66,20 @@ import androidx.lifecycle.ViewModelProvider
|
|||||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
import io.nekohasekai.libbox.Libbox
|
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.screen.dashboard.groups.GroupsViewModel
|
import io.nekohasekai.sfa.compose.screen.dashboard.groups.GroupsViewModel
|
||||||
import io.nekohasekai.sfa.constant.Status
|
import io.nekohasekai.sfa.constant.Status
|
||||||
import io.nekohasekai.sfa.compose.model.Group
|
import io.nekohasekai.sfa.compose.model.Group
|
||||||
import io.nekohasekai.sfa.compose.model.GroupItem
|
import io.nekohasekai.sfa.compose.model.GroupItem
|
||||||
import io.nekohasekai.sfa.utils.CommandClient
|
import io.nekohasekai.sfa.utils.CommandClient
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun GroupsCard(
|
fun GroupsCard(
|
||||||
serviceStatus: Status,
|
serviceStatus: Status,
|
||||||
commandClient: CommandClient? = null,
|
commandClient: CommandClient? = null,
|
||||||
viewModel: GroupsViewModel? = null,
|
viewModel: GroupsViewModel? = null,
|
||||||
|
showTopBar: Boolean = false,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val actualViewModel: GroupsViewModel = viewModel ?: viewModel(
|
val actualViewModel: GroupsViewModel = viewModel ?: viewModel(
|
||||||
@@ -88,6 +94,35 @@ fun GroupsCard(
|
|||||||
val snackbarHostState = remember { SnackbarHostState() }
|
val snackbarHostState = remember { SnackbarHostState() }
|
||||||
val uiState by actualViewModel.uiState.collectAsState()
|
val uiState by actualViewModel.uiState.collectAsState()
|
||||||
|
|
||||||
|
if (showTopBar) {
|
||||||
|
val allCollapsed = uiState.expandedGroups.isEmpty()
|
||||||
|
OverrideTopBar {
|
||||||
|
TopAppBar(
|
||||||
|
title = { Text(stringResource(R.string.title_groups)) },
|
||||||
|
actions = {
|
||||||
|
if (uiState.groups.isNotEmpty()) {
|
||||||
|
IconButton(onClick = { actualViewModel.toggleAllGroups() }) {
|
||||||
|
Icon(
|
||||||
|
imageVector =
|
||||||
|
if (allCollapsed) {
|
||||||
|
Icons.Default.UnfoldMore
|
||||||
|
} else {
|
||||||
|
Icons.Default.UnfoldLess
|
||||||
|
},
|
||||||
|
contentDescription =
|
||||||
|
if (allCollapsed) {
|
||||||
|
stringResource(R.string.expand_all)
|
||||||
|
} else {
|
||||||
|
stringResource(R.string.collapse_all)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Stable callbacks to prevent recomposition - use remember with viewModel as key
|
// Stable callbacks to prevent recomposition - use remember with viewModel as key
|
||||||
val onToggleExpanded =
|
val onToggleExpanded =
|
||||||
remember(actualViewModel) {
|
remember(actualViewModel) {
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
package io.nekohasekai.sfa.compose.screen.dashboard
|
package io.nekohasekai.sfa.compose.screen.dashboard
|
||||||
|
|
||||||
import android.content.Intent
|
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import android.widget.Toast
|
import android.widget.Toast
|
||||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
@@ -64,10 +63,10 @@ import androidx.compose.ui.unit.dp
|
|||||||
import io.nekohasekai.libbox.Libbox
|
import io.nekohasekai.libbox.Libbox
|
||||||
import io.nekohasekai.libbox.ProfileContent
|
import io.nekohasekai.libbox.ProfileContent
|
||||||
import io.nekohasekai.sfa.R
|
import io.nekohasekai.sfa.R
|
||||||
import io.nekohasekai.sfa.compose.NewProfileActivity
|
|
||||||
import io.nekohasekai.sfa.compose.component.qr.QRCodeDialog
|
import io.nekohasekai.sfa.compose.component.qr.QRCodeDialog
|
||||||
import io.nekohasekai.sfa.compose.component.qr.QRScanSheet
|
import io.nekohasekai.sfa.compose.component.qr.QRScanSheet
|
||||||
import io.nekohasekai.sfa.compose.component.qr.QRSDialog
|
import io.nekohasekai.sfa.compose.component.qr.QRSDialog
|
||||||
|
import io.nekohasekai.sfa.compose.navigation.NewProfileArgs
|
||||||
import io.nekohasekai.sfa.compose.screen.qrscan.QRScanResult
|
import io.nekohasekai.sfa.compose.screen.qrscan.QRScanResult
|
||||||
import io.nekohasekai.sfa.compose.screen.configuration.ProfileImportHandler
|
import io.nekohasekai.sfa.compose.screen.configuration.ProfileImportHandler
|
||||||
import io.nekohasekai.sfa.compose.util.QRCodeGenerator
|
import io.nekohasekai.sfa.compose.util.QRCodeGenerator
|
||||||
@@ -103,9 +102,7 @@ fun ProfilesCard(
|
|||||||
onHideAddProfileSheet: () -> Unit,
|
onHideAddProfileSheet: () -> Unit,
|
||||||
onShowProfilePickerSheet: () -> Unit,
|
onShowProfilePickerSheet: () -> Unit,
|
||||||
onHideProfilePickerSheet: () -> Unit,
|
onHideProfilePickerSheet: () -> Unit,
|
||||||
onImportFromFile: () -> Unit,
|
onOpenNewProfile: (NewProfileArgs) -> Unit,
|
||||||
onScanQrCode: () -> Unit,
|
|
||||||
onCreateManually: () -> Unit,
|
|
||||||
) {
|
) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val coroutineScope = rememberCoroutineScope()
|
val coroutineScope = rememberCoroutineScope()
|
||||||
@@ -126,28 +123,6 @@ fun ProfilesCard(
|
|||||||
|
|
||||||
var showQRScanSheet by remember { mutableStateOf(false) }
|
var showQRScanSheet by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
val newProfileLauncher =
|
|
||||||
rememberLauncherForActivityResult(
|
|
||||||
ActivityResultContracts.StartActivityForResult(),
|
|
||||||
) { result ->
|
|
||||||
if (result.resultCode == android.app.Activity.RESULT_OK) {
|
|
||||||
val profileId = result.data?.getLongExtra(NewProfileActivity.EXTRA_PROFILE_ID, -1L)
|
|
||||||
if (profileId != null && profileId != -1L) {
|
|
||||||
coroutineScope.launch {
|
|
||||||
val profile =
|
|
||||||
withContext(Dispatchers.IO) {
|
|
||||||
io.nekohasekai.sfa.database.ProfileManager.get(profileId)
|
|
||||||
}
|
|
||||||
profile?.let {
|
|
||||||
withContext(Dispatchers.Main) {
|
|
||||||
onProfileEdit(it)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val importFromFileLauncher =
|
val importFromFileLauncher =
|
||||||
rememberLauncherForActivityResult(
|
rememberLauncherForActivityResult(
|
||||||
ActivityResultContracts.GetContent(),
|
ActivityResultContracts.GetContent(),
|
||||||
@@ -238,9 +213,6 @@ fun ProfilesCard(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
LaunchedEffect(onImportFromFile, onScanQrCode) {
|
|
||||||
}
|
|
||||||
|
|
||||||
val selectedProfile = profiles.find { it.id == selectedProfileId }
|
val selectedProfile = profiles.find { it.id == selectedProfileId }
|
||||||
|
|
||||||
Card(
|
Card(
|
||||||
@@ -458,8 +430,7 @@ fun ProfilesCard(
|
|||||||
ListItem(
|
ListItem(
|
||||||
modifier = Modifier.clickable {
|
modifier = Modifier.clickable {
|
||||||
onHideAddProfileSheet()
|
onHideAddProfileSheet()
|
||||||
val intent = Intent(context, NewProfileActivity::class.java)
|
onOpenNewProfile(NewProfileArgs())
|
||||||
newProfileLauncher.launch(intent)
|
|
||||||
},
|
},
|
||||||
leadingContent = {
|
leadingContent = {
|
||||||
Icon(
|
Icon(
|
||||||
@@ -608,12 +579,12 @@ fun ProfilesCard(
|
|||||||
when (val parseResult = importHandler.parseQRCode(result.uri.toString())) {
|
when (val parseResult = importHandler.parseQRCode(result.uri.toString())) {
|
||||||
is ProfileImportHandler.QRCodeParseResult.RemoteProfile -> {
|
is ProfileImportHandler.QRCodeParseResult.RemoteProfile -> {
|
||||||
withContext(Dispatchers.Main) {
|
withContext(Dispatchers.Main) {
|
||||||
val newProfileIntent =
|
onOpenNewProfile(
|
||||||
Intent(context, NewProfileActivity::class.java).apply {
|
NewProfileArgs(
|
||||||
putExtra(NewProfileActivity.EXTRA_IMPORT_NAME, parseResult.name)
|
importName = parseResult.name,
|
||||||
putExtra(NewProfileActivity.EXTRA_IMPORT_URL, parseResult.url)
|
importUrl = parseResult.url,
|
||||||
}
|
),
|
||||||
newProfileLauncher.launch(newProfileIntent)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
is ProfileImportHandler.QRCodeParseResult.LocalProfile -> {
|
is ProfileImportHandler.QRCodeParseResult.LocalProfile -> {
|
||||||
|
|||||||
@@ -0,0 +1,153 @@
|
|||||||
|
package io.nekohasekai.sfa.compose.screen.log
|
||||||
|
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import io.nekohasekai.sfa.compose.util.AnsiColorUtils
|
||||||
|
import kotlinx.coroutines.FlowPreview
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.flow.debounce
|
||||||
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
|
import kotlinx.coroutines.flow.update
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import java.util.LinkedList
|
||||||
|
import java.util.concurrent.atomic.AtomicLong
|
||||||
|
|
||||||
|
@OptIn(FlowPreview::class)
|
||||||
|
abstract class BaseLogViewModel : ViewModel(), LogViewerViewModel {
|
||||||
|
protected val _uiState = MutableStateFlow(LogUiState())
|
||||||
|
override val uiState: StateFlow<LogUiState> = _uiState.asStateFlow()
|
||||||
|
|
||||||
|
protected val _autoScrollEnabled = MutableStateFlow(true)
|
||||||
|
override val isAtBottom: StateFlow<Boolean> = _autoScrollEnabled.asStateFlow()
|
||||||
|
|
||||||
|
protected val _scrollToBottomTrigger = MutableStateFlow(0)
|
||||||
|
override val scrollToBottomTrigger: StateFlow<Int> = _scrollToBottomTrigger.asStateFlow()
|
||||||
|
|
||||||
|
protected val _searchQueryInternal = MutableStateFlow("")
|
||||||
|
protected val logIdGenerator = AtomicLong(0)
|
||||||
|
protected val allLogs = LinkedList<ProcessedLogEntry>()
|
||||||
|
|
||||||
|
init {
|
||||||
|
viewModelScope.launch {
|
||||||
|
_searchQueryInternal
|
||||||
|
.debounce(300)
|
||||||
|
.distinctUntilChanged()
|
||||||
|
.collect {
|
||||||
|
updateDisplayedLogs()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun toggleSearch() {
|
||||||
|
_uiState.update {
|
||||||
|
it.copy(
|
||||||
|
isSearchActive = !it.isSearchActive,
|
||||||
|
searchQuery = if (!it.isSearchActive) it.searchQuery else "",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
updateDisplayedLogs()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun toggleOptionsMenu() {
|
||||||
|
_uiState.update { it.copy(isOptionsMenuOpen = !it.isOptionsMenuOpen) }
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun updateSearchQuery(query: String) {
|
||||||
|
_uiState.update { it.copy(searchQuery = query) }
|
||||||
|
_searchQueryInternal.value = query
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun setLogLevel(level: LogLevel) {
|
||||||
|
_uiState.update { it.copy(filterLogLevel = level) }
|
||||||
|
updateDisplayedLogs()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun setAutoScrollEnabled(enabled: Boolean) {
|
||||||
|
_autoScrollEnabled.value = enabled
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun scrollToBottom() {
|
||||||
|
_autoScrollEnabled.value = true
|
||||||
|
_scrollToBottomTrigger.value++
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun toggleSelectionMode() {
|
||||||
|
_uiState.update {
|
||||||
|
if (it.isSelectionMode) {
|
||||||
|
it.copy(isSelectionMode = false, selectedLogIndices = emptySet(), isPaused = false)
|
||||||
|
} else {
|
||||||
|
it.copy(isSelectionMode = true, isPaused = true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun toggleLogSelection(index: Int) {
|
||||||
|
_uiState.update { state ->
|
||||||
|
val newSelection =
|
||||||
|
if (state.selectedLogIndices.contains(index)) {
|
||||||
|
state.selectedLogIndices - index
|
||||||
|
} else {
|
||||||
|
state.selectedLogIndices + index
|
||||||
|
}
|
||||||
|
if (newSelection.isEmpty()) {
|
||||||
|
state.copy(
|
||||||
|
isSelectionMode = false,
|
||||||
|
selectedLogIndices = emptySet(),
|
||||||
|
isPaused = false,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
state.copy(selectedLogIndices = newSelection)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun clearSelection() {
|
||||||
|
_uiState.update {
|
||||||
|
it.copy(isSelectionMode = false, selectedLogIndices = emptySet(), isPaused = false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getSelectedLogsText(): String {
|
||||||
|
val state = _uiState.value
|
||||||
|
return state.selectedLogIndices
|
||||||
|
.sorted()
|
||||||
|
.mapNotNull { index ->
|
||||||
|
state.logs.getOrNull(index)?.entry?.message?.let { AnsiColorUtils.stripAnsi(it) }
|
||||||
|
}
|
||||||
|
.joinToString("\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getAllLogsText(): String {
|
||||||
|
return _uiState.value.logs.joinToString("\n") { AnsiColorUtils.stripAnsi(it.entry.message) }
|
||||||
|
}
|
||||||
|
|
||||||
|
protected fun updateDisplayedLogs() {
|
||||||
|
val currentState = _uiState.value
|
||||||
|
val levelPriority =
|
||||||
|
if (currentState.filterLogLevel != LogLevel.Default) {
|
||||||
|
currentState.filterLogLevel.priority
|
||||||
|
} else {
|
||||||
|
currentState.defaultLogLevel.priority
|
||||||
|
}
|
||||||
|
val searchQuery = currentState.searchQuery
|
||||||
|
|
||||||
|
val logsToDisplay =
|
||||||
|
allLogs.asSequence()
|
||||||
|
.filter { log -> log.entry.level.priority <= levelPriority }
|
||||||
|
.filter { log ->
|
||||||
|
searchQuery.isEmpty() || log.entry.message.contains(searchQuery, ignoreCase = true)
|
||||||
|
}
|
||||||
|
.toList()
|
||||||
|
|
||||||
|
val selectionCleared =
|
||||||
|
if (_uiState.value.isSelectionMode && _uiState.value.logs != logsToDisplay) {
|
||||||
|
emptySet<Int>()
|
||||||
|
} else {
|
||||||
|
_uiState.value.selectedLogIndices
|
||||||
|
}
|
||||||
|
|
||||||
|
_uiState.update { it.copy(logs = logsToDisplay, selectedLogIndices = selectionCleared) }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
package io.nekohasekai.sfa.compose.screen.log
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
|
import io.nekohasekai.sfa.R
|
||||||
|
import io.nekohasekai.sfa.constant.Status
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun HookLogScreen(onBack: () -> Unit) {
|
||||||
|
val viewModel: HookLogViewModel = viewModel()
|
||||||
|
val context = LocalContext.current
|
||||||
|
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
viewModel.loadLogs(context)
|
||||||
|
}
|
||||||
|
|
||||||
|
LogScreen(
|
||||||
|
serviceStatus = Status.Stopped,
|
||||||
|
showStartFab = false,
|
||||||
|
showStatusBar = false,
|
||||||
|
title = context.getString(R.string.title_log),
|
||||||
|
viewModel = viewModel,
|
||||||
|
showPause = false,
|
||||||
|
showClear = false,
|
||||||
|
showStatusInfo = false,
|
||||||
|
emptyMessage = context.getString(R.string.privilege_settings_hook_logs_empty),
|
||||||
|
saveFilePrefix = "hook_logs",
|
||||||
|
onBack = onBack,
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
package io.nekohasekai.sfa.compose.screen.log
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.text.format.DateFormat
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import io.nekohasekai.sfa.bg.LogEntry
|
||||||
|
import io.nekohasekai.sfa.compose.util.AnsiColorUtils
|
||||||
|
import io.nekohasekai.sfa.constant.Status
|
||||||
|
import io.nekohasekai.sfa.utils.HookErrorClient
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.flow.update
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import java.util.Date
|
||||||
|
|
||||||
|
class HookLogViewModel : BaseLogViewModel() {
|
||||||
|
|
||||||
|
fun loadLogs(context: Context) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
val result = withContext(Dispatchers.IO) {
|
||||||
|
HookErrorClient.query(context)
|
||||||
|
}
|
||||||
|
if (result.failure != null) {
|
||||||
|
val detail = buildErrorMessage(result)
|
||||||
|
allLogs.clear()
|
||||||
|
_uiState.update {
|
||||||
|
it.copy(
|
||||||
|
logs = emptyList(),
|
||||||
|
isConnected = false,
|
||||||
|
errorTitle = "Error",
|
||||||
|
errorMessage = detail,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
val logs = result.logs.map { processLogEntry(it) }
|
||||||
|
allLogs.clear()
|
||||||
|
allLogs.addAll(logs)
|
||||||
|
_uiState.update {
|
||||||
|
it.copy(
|
||||||
|
logs = emptyList(),
|
||||||
|
isConnected = true,
|
||||||
|
errorTitle = null,
|
||||||
|
errorMessage = null,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
updateDisplayedLogs()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
private const val ANSI_RESET = "\u001B[0m"
|
||||||
|
private const val ANSI_RED = "\u001B[31m"
|
||||||
|
private const val ANSI_YELLOW = "\u001B[33m"
|
||||||
|
private const val ANSI_CYAN = "\u001B[36m"
|
||||||
|
private const val ANSI_WHITE = "\u001B[37m"
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun processLogEntry(entry: LogEntry): ProcessedLogEntry {
|
||||||
|
val level = when (entry.level) {
|
||||||
|
LogEntry.LEVEL_DEBUG -> LogLevel.DEBUG
|
||||||
|
LogEntry.LEVEL_INFO -> LogLevel.INFO
|
||||||
|
LogEntry.LEVEL_WARN -> LogLevel.WARNING
|
||||||
|
LogEntry.LEVEL_ERROR -> LogLevel.ERROR
|
||||||
|
else -> LogLevel.Default
|
||||||
|
}
|
||||||
|
val (levelName, levelColor) = when (entry.level) {
|
||||||
|
LogEntry.LEVEL_DEBUG -> "DEBUG" to ANSI_WHITE
|
||||||
|
LogEntry.LEVEL_INFO -> "INFO" to ANSI_CYAN
|
||||||
|
LogEntry.LEVEL_WARN -> "WARN" to ANSI_YELLOW
|
||||||
|
LogEntry.LEVEL_ERROR -> "ERROR" to ANSI_RED
|
||||||
|
else -> "UNKNOWN" to ANSI_WHITE
|
||||||
|
}
|
||||||
|
val timestamp = DateFormat.format("HH:mm:ss", Date(entry.timestamp)).toString()
|
||||||
|
val message = buildString {
|
||||||
|
append(levelColor).append(levelName).append(ANSI_RESET)
|
||||||
|
append("[").append(timestamp).append("] ")
|
||||||
|
append("[").append(entry.source).append("]: ")
|
||||||
|
append(entry.message)
|
||||||
|
if (!entry.stackTrace.isNullOrEmpty()) {
|
||||||
|
append("\n").append(entry.stackTrace)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ProcessedLogEntry(
|
||||||
|
id = logIdGenerator.incrementAndGet(),
|
||||||
|
entry = LogEntryData(level, AnsiColorUtils.stripAnsi(message)),
|
||||||
|
annotatedString = AnsiColorUtils.ansiToAnnotatedString(message),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun buildErrorMessage(result: HookErrorClient.Result): String {
|
||||||
|
val message = when (result.failure) {
|
||||||
|
HookErrorClient.Failure.SERVICE_UNAVAILABLE ->
|
||||||
|
"Connectivity service unavailable. Reboot or activate LSPosed module."
|
||||||
|
HookErrorClient.Failure.TRANSACTION_FAILED ->
|
||||||
|
"Hook transaction rejected. Reboot to load LSPosed module."
|
||||||
|
HookErrorClient.Failure.REMOTE_ERROR ->
|
||||||
|
"Remote error while reading logs."
|
||||||
|
HookErrorClient.Failure.PROTOCOL_ERROR ->
|
||||||
|
"Log protocol mismatch. Reboot to update LSPosed module."
|
||||||
|
null -> "Unknown error."
|
||||||
|
}
|
||||||
|
val detail = result.detail?.takeIf { it.isNotBlank() }
|
||||||
|
return if (detail != null) "$message\n$detail" else message
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun updateServiceStatus(status: Status) {
|
||||||
|
_uiState.update { it.copy(serviceStatus = status) }
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun togglePause() {
|
||||||
|
_uiState.update { it.copy(isPaused = false) }
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun requestClearLogs() {
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
package io.nekohasekai.sfa.compose.screen.log
|
||||||
|
|
||||||
|
import androidx.compose.ui.text.AnnotatedString
|
||||||
|
import io.nekohasekai.sfa.constant.Status
|
||||||
|
|
||||||
|
data class LogEntryData(
|
||||||
|
val level: LogLevel,
|
||||||
|
val message: String,
|
||||||
|
)
|
||||||
|
|
||||||
|
data class ProcessedLogEntry(
|
||||||
|
val id: Long,
|
||||||
|
val entry: LogEntryData,
|
||||||
|
val annotatedString: AnnotatedString,
|
||||||
|
)
|
||||||
|
|
||||||
|
enum class LogLevel(val label: String, val priority: Int) {
|
||||||
|
Default("Default", 7),
|
||||||
|
|
||||||
|
PANIC("Panic", 0),
|
||||||
|
FATAL("Fatal", 1),
|
||||||
|
ERROR("Error", 2),
|
||||||
|
WARNING("Warn", 3),
|
||||||
|
INFO("Info", 4),
|
||||||
|
DEBUG("Debug", 5),
|
||||||
|
TRACE("Trace", 6),
|
||||||
|
}
|
||||||
|
|
||||||
|
data class LogUiState(
|
||||||
|
val logs: List<ProcessedLogEntry> = emptyList(),
|
||||||
|
val isConnected: Boolean = false,
|
||||||
|
val serviceStatus: Status = Status.Stopped,
|
||||||
|
val isPaused: Boolean = false,
|
||||||
|
val searchQuery: String = "",
|
||||||
|
val isSearchActive: Boolean = false,
|
||||||
|
val defaultLogLevel: LogLevel = LogLevel.Default,
|
||||||
|
val filterLogLevel: LogLevel = LogLevel.Default,
|
||||||
|
val isOptionsMenuOpen: Boolean = false,
|
||||||
|
val isSelectionMode: Boolean = false,
|
||||||
|
val selectedLogIndices: Set<Int> = emptySet(),
|
||||||
|
val errorTitle: String? = null,
|
||||||
|
val errorMessage: String? = null,
|
||||||
|
)
|
||||||
@@ -35,6 +35,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape
|
|||||||
import androidx.compose.foundation.text.KeyboardActions
|
import androidx.compose.foundation.text.KeyboardActions
|
||||||
import androidx.compose.foundation.text.KeyboardOptions
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
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.filled.CheckBox
|
import androidx.compose.material.icons.filled.CheckBox
|
||||||
import androidx.compose.material.icons.filled.CheckBoxOutlineBlank
|
import androidx.compose.material.icons.filled.CheckBoxOutlineBlank
|
||||||
import androidx.compose.material.icons.filled.Close
|
import androidx.compose.material.icons.filled.Close
|
||||||
@@ -44,6 +45,9 @@ import androidx.compose.material.icons.filled.ExpandLess
|
|||||||
import androidx.compose.material.icons.filled.ExpandMore
|
import androidx.compose.material.icons.filled.ExpandMore
|
||||||
import androidx.compose.material.icons.filled.FilterList
|
import androidx.compose.material.icons.filled.FilterList
|
||||||
import androidx.compose.material.icons.filled.KeyboardArrowDown
|
import androidx.compose.material.icons.filled.KeyboardArrowDown
|
||||||
|
import androidx.compose.material.icons.filled.MoreVert
|
||||||
|
import androidx.compose.material.icons.filled.Pause
|
||||||
|
import androidx.compose.material.icons.filled.PlayArrow
|
||||||
import androidx.compose.material.icons.filled.RadioButtonChecked
|
import androidx.compose.material.icons.filled.RadioButtonChecked
|
||||||
import androidx.compose.material.icons.filled.RadioButtonUnchecked
|
import androidx.compose.material.icons.filled.RadioButtonUnchecked
|
||||||
import androidx.compose.material.icons.filled.Save
|
import androidx.compose.material.icons.filled.Save
|
||||||
@@ -63,6 +67,7 @@ import androidx.compose.material3.OutlinedTextField
|
|||||||
import androidx.compose.material3.Surface
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TextButton
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.material3.TopAppBar
|
||||||
import androidx.compose.runtime.Composable
|
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
|
||||||
@@ -88,6 +93,7 @@ import androidx.core.content.FileProvider
|
|||||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
import io.nekohasekai.sfa.Application
|
import io.nekohasekai.sfa.Application
|
||||||
import io.nekohasekai.sfa.R
|
import io.nekohasekai.sfa.R
|
||||||
|
import io.nekohasekai.sfa.compose.topbar.OverrideTopBar
|
||||||
import io.nekohasekai.sfa.constant.Status
|
import io.nekohasekai.sfa.constant.Status
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.text.SimpleDateFormat
|
import java.text.SimpleDateFormat
|
||||||
@@ -100,18 +106,97 @@ fun LogScreen(
|
|||||||
serviceStatus: Status = Status.Stopped,
|
serviceStatus: Status = Status.Stopped,
|
||||||
showStartFab: Boolean = false,
|
showStartFab: Boolean = false,
|
||||||
showStatusBar: Boolean = false,
|
showStatusBar: Boolean = false,
|
||||||
viewModel: LogViewModel = viewModel(),
|
title: String? = null,
|
||||||
|
viewModel: LogViewerViewModel? = null,
|
||||||
|
showPause: Boolean = true,
|
||||||
|
showClear: Boolean = true,
|
||||||
|
showStatusInfo: Boolean = true,
|
||||||
|
emptyMessage: String? = null,
|
||||||
|
saveFilePrefix: String = "logs",
|
||||||
|
onBack: (() -> Unit)? = null,
|
||||||
) {
|
) {
|
||||||
val uiState by viewModel.uiState.collectAsState()
|
val resolvedViewModel = viewModel ?: viewModel<LogViewModel>()
|
||||||
|
val uiState by resolvedViewModel.uiState.collectAsState()
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val configuration = LocalConfiguration.current
|
val configuration = LocalConfiguration.current
|
||||||
val isTablet = configuration.isLayoutSizeAtLeast(Configuration.SCREENLAYOUT_SIZE_LARGE)
|
val isTablet = configuration.isLayoutSizeAtLeast(Configuration.SCREENLAYOUT_SIZE_LARGE)
|
||||||
val listState = rememberLazyListState()
|
val listState = rememberLazyListState()
|
||||||
val coroutineScope = rememberCoroutineScope()
|
val coroutineScope = rememberCoroutineScope()
|
||||||
|
val resolvedTitle = title ?: stringResource(R.string.title_log)
|
||||||
|
val emptyStateMessage = emptyMessage ?: stringResource(R.string.privilege_settings_hook_logs_empty)
|
||||||
|
|
||||||
|
OverrideTopBar {
|
||||||
|
TopAppBar(
|
||||||
|
title = { Text(resolvedTitle) },
|
||||||
|
navigationIcon = {
|
||||||
|
if (onBack != null) {
|
||||||
|
IconButton(onClick = onBack) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
|
||||||
|
contentDescription = stringResource(R.string.content_description_back),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
actions = {
|
||||||
|
if (!uiState.isSelectionMode) {
|
||||||
|
if (showPause) {
|
||||||
|
IconButton(onClick = { resolvedViewModel.togglePause() }) {
|
||||||
|
Icon(
|
||||||
|
imageVector =
|
||||||
|
if (uiState.isPaused) {
|
||||||
|
Icons.Default.PlayArrow
|
||||||
|
} else {
|
||||||
|
Icons.Default.Pause
|
||||||
|
},
|
||||||
|
contentDescription =
|
||||||
|
if (uiState.isPaused) {
|
||||||
|
stringResource(R.string.content_description_resume_logs)
|
||||||
|
} else {
|
||||||
|
stringResource(R.string.content_description_pause_logs)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
IconButton(onClick = { resolvedViewModel.toggleSearch() }) {
|
||||||
|
Icon(
|
||||||
|
imageVector =
|
||||||
|
if (uiState.isSearchActive) {
|
||||||
|
Icons.Default.ExpandLess
|
||||||
|
} else {
|
||||||
|
Icons.Default.Search
|
||||||
|
},
|
||||||
|
contentDescription =
|
||||||
|
if (uiState.isSearchActive) {
|
||||||
|
stringResource(R.string.content_description_collapse_search)
|
||||||
|
} else {
|
||||||
|
stringResource(R.string.content_description_search_logs)
|
||||||
|
},
|
||||||
|
tint =
|
||||||
|
if (uiState.isSearchActive) {
|
||||||
|
MaterialTheme.colorScheme.primary
|
||||||
|
} else {
|
||||||
|
MaterialTheme.colorScheme.onSurface
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
IconButton(onClick = { resolvedViewModel.toggleOptionsMenu() }) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.MoreVert,
|
||||||
|
contentDescription = stringResource(R.string.more_options),
|
||||||
|
tint = MaterialTheme.colorScheme.onSurface,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// Handle back press in selection mode
|
// Handle back press in selection mode
|
||||||
androidx.activity.compose.BackHandler(enabled = uiState.isSelectionMode) {
|
androidx.activity.compose.BackHandler(enabled = uiState.isSelectionMode) {
|
||||||
viewModel.clearSelection()
|
resolvedViewModel.clearSelection()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Track if user is at the bottom of the list
|
// Track if user is at the bottom of the list
|
||||||
@@ -126,7 +211,7 @@ fun LogScreen(
|
|||||||
// Re-enable auto-scroll when user reaches bottom
|
// Re-enable auto-scroll when user reaches bottom
|
||||||
LaunchedEffect(isAtBottom) {
|
LaunchedEffect(isAtBottom) {
|
||||||
if (isAtBottom) {
|
if (isAtBottom) {
|
||||||
viewModel.setAutoScrollEnabled(true)
|
resolvedViewModel.setAutoScrollEnabled(true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -154,7 +239,7 @@ fun LogScreen(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (scrolledUp) {
|
if (scrolledUp) {
|
||||||
viewModel.setAutoScrollEnabled(false)
|
resolvedViewModel.setAutoScrollEnabled(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
dragStartIndex = null
|
dragStartIndex = null
|
||||||
@@ -166,7 +251,7 @@ fun LogScreen(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Handle scroll to bottom requests from ViewModel
|
// Handle scroll to bottom requests from ViewModel
|
||||||
val scrollToBottomTrigger by viewModel.scrollToBottomTrigger.collectAsState()
|
val scrollToBottomTrigger by resolvedViewModel.scrollToBottomTrigger.collectAsState()
|
||||||
LaunchedEffect(scrollToBottomTrigger) {
|
LaunchedEffect(scrollToBottomTrigger) {
|
||||||
if (scrollToBottomTrigger > 0 && uiState.logs.isNotEmpty()) {
|
if (scrollToBottomTrigger > 0 && uiState.logs.isNotEmpty()) {
|
||||||
listState.animateScrollToItem(uiState.logs.size - 1)
|
listState.animateScrollToItem(uiState.logs.size - 1)
|
||||||
@@ -175,7 +260,9 @@ fun LogScreen(
|
|||||||
|
|
||||||
// Update service status in ViewModel
|
// Update service status in ViewModel
|
||||||
LaunchedEffect(serviceStatus) {
|
LaunchedEffect(serviceStatus) {
|
||||||
viewModel.updateServiceStatus(serviceStatus)
|
if (showStatusInfo) {
|
||||||
|
resolvedViewModel.updateServiceStatus(serviceStatus)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Box(
|
Box(
|
||||||
@@ -203,7 +290,7 @@ fun LogScreen(
|
|||||||
Row(
|
Row(
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
) {
|
) {
|
||||||
IconButton(onClick = { viewModel.clearSelection() }) {
|
IconButton(onClick = { resolvedViewModel.clearSelection() }) {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = Icons.Default.Close,
|
imageVector = Icons.Default.Close,
|
||||||
contentDescription = stringResource(R.string.content_description_exit_selection_mode),
|
contentDescription = stringResource(R.string.content_description_exit_selection_mode),
|
||||||
@@ -222,9 +309,9 @@ fun LogScreen(
|
|||||||
Row {
|
Row {
|
||||||
IconButton(
|
IconButton(
|
||||||
onClick = {
|
onClick = {
|
||||||
val selectedText = viewModel.getSelectedLogsText()
|
val selectedText = resolvedViewModel.getSelectedLogsText()
|
||||||
if (selectedText.isNotEmpty()) {
|
if (selectedText.isNotEmpty()) {
|
||||||
val clipLabel = context.getString(R.string.title_log)
|
val clipLabel = resolvedTitle
|
||||||
val clip = ClipData.newPlainText(clipLabel, selectedText)
|
val clip = ClipData.newPlainText(clipLabel, selectedText)
|
||||||
Application.clipboard.setPrimaryClip(clip)
|
Application.clipboard.setPrimaryClip(clip)
|
||||||
Toast.makeText(
|
Toast.makeText(
|
||||||
@@ -232,7 +319,7 @@ fun LogScreen(
|
|||||||
context.getString(R.string.copied_to_clipboard),
|
context.getString(R.string.copied_to_clipboard),
|
||||||
Toast.LENGTH_SHORT,
|
Toast.LENGTH_SHORT,
|
||||||
).show()
|
).show()
|
||||||
viewModel.clearSelection()
|
resolvedViewModel.clearSelection()
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
enabled = uiState.selectedLogIndices.isNotEmpty(),
|
enabled = uiState.selectedLogIndices.isNotEmpty(),
|
||||||
@@ -271,7 +358,7 @@ fun LogScreen(
|
|||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
)
|
)
|
||||||
TextButton(
|
TextButton(
|
||||||
onClick = { viewModel.setLogLevel(LogLevel.Default) },
|
onClick = { resolvedViewModel.setLogLevel(LogLevel.Default) },
|
||||||
contentPadding = PaddingValues(horizontal = 8.dp, vertical = 0.dp),
|
contentPadding = PaddingValues(horizontal = 8.dp, vertical = 0.dp),
|
||||||
modifier = Modifier.height(24.dp),
|
modifier = Modifier.height(24.dp),
|
||||||
) {
|
) {
|
||||||
@@ -316,7 +403,7 @@ fun LogScreen(
|
|||||||
|
|
||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
value = uiState.searchQuery,
|
value = uiState.searchQuery,
|
||||||
onValueChange = { viewModel.updateSearchQuery(it) },
|
onValueChange = { resolvedViewModel.updateSearchQuery(it) },
|
||||||
modifier =
|
modifier =
|
||||||
Modifier
|
Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
@@ -331,7 +418,7 @@ fun LogScreen(
|
|||||||
},
|
},
|
||||||
trailingIcon = {
|
trailingIcon = {
|
||||||
if (uiState.searchQuery.isNotEmpty()) {
|
if (uiState.searchQuery.isNotEmpty()) {
|
||||||
IconButton(onClick = { viewModel.updateSearchQuery("") }) {
|
IconButton(onClick = { resolvedViewModel.updateSearchQuery("") }) {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = Icons.Default.Delete,
|
imageVector = Icons.Default.Delete,
|
||||||
contentDescription = stringResource(R.string.content_description_clear_search),
|
contentDescription = stringResource(R.string.content_description_clear_search),
|
||||||
@@ -351,8 +438,7 @@ fun LogScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (uiState.logs.isEmpty()) {
|
if (uiState.errorMessage != null) {
|
||||||
// Empty state
|
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
contentAlignment = Alignment.Center,
|
contentAlignment = Alignment.Center,
|
||||||
@@ -362,12 +448,36 @@ fun LogScreen(
|
|||||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text =
|
text = uiState.errorTitle ?: "Error",
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
color = MaterialTheme.colorScheme.error,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = uiState.errorMessage ?: "",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (uiState.logs.isEmpty()) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = if (showStatusInfo) {
|
||||||
when (serviceStatus) {
|
when (serviceStatus) {
|
||||||
Status.Started -> stringResource(R.string.status_started)
|
Status.Started -> stringResource(R.string.status_started)
|
||||||
Status.Starting -> stringResource(R.string.status_starting)
|
Status.Starting -> stringResource(R.string.status_starting)
|
||||||
Status.Stopping -> stringResource(R.string.status_stopping)
|
Status.Stopping -> stringResource(R.string.status_stopping)
|
||||||
else -> stringResource(R.string.status_default)
|
else -> stringResource(R.string.status_default)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
emptyStateMessage
|
||||||
},
|
},
|
||||||
style = MaterialTheme.typography.bodyLarge,
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
@@ -404,13 +514,13 @@ fun LogScreen(
|
|||||||
isSelectionMode = uiState.isSelectionMode,
|
isSelectionMode = uiState.isSelectionMode,
|
||||||
onLongClick = {
|
onLongClick = {
|
||||||
if (!uiState.isSelectionMode) {
|
if (!uiState.isSelectionMode) {
|
||||||
viewModel.toggleSelectionMode()
|
resolvedViewModel.toggleSelectionMode()
|
||||||
viewModel.toggleLogSelection(index)
|
resolvedViewModel.toggleLogSelection(index)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onClick = {
|
onClick = {
|
||||||
if (uiState.isSelectionMode) {
|
if (uiState.isSelectionMode) {
|
||||||
viewModel.toggleLogSelection(index)
|
resolvedViewModel.toggleLogSelection(index)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -437,7 +547,7 @@ fun LogScreen(
|
|||||||
uri?.let {
|
uri?.let {
|
||||||
try {
|
try {
|
||||||
context.contentResolver.openOutputStream(it)?.use { outputStream ->
|
context.contentResolver.openOutputStream(it)?.use { outputStream ->
|
||||||
val logsText = viewModel.getAllLogsText()
|
val logsText = resolvedViewModel.getAllLogsText()
|
||||||
outputStream.write(logsText.toByteArray())
|
outputStream.write(logsText.toByteArray())
|
||||||
outputStream.flush()
|
outputStream.flush()
|
||||||
Toast.makeText(
|
Toast.makeText(
|
||||||
@@ -460,7 +570,7 @@ fun LogScreen(
|
|||||||
DropdownMenu(
|
DropdownMenu(
|
||||||
expanded = uiState.isOptionsMenuOpen,
|
expanded = uiState.isOptionsMenuOpen,
|
||||||
onDismissRequest = {
|
onDismissRequest = {
|
||||||
viewModel.toggleOptionsMenu()
|
resolvedViewModel.toggleOptionsMenu()
|
||||||
expandedLogLevel = false
|
expandedLogLevel = false
|
||||||
expandedSave = false
|
expandedSave = false
|
||||||
},
|
},
|
||||||
@@ -503,8 +613,8 @@ fun LogScreen(
|
|||||||
Text(text = level.label)
|
Text(text = level.label)
|
||||||
},
|
},
|
||||||
onClick = {
|
onClick = {
|
||||||
viewModel.setLogLevel(level)
|
resolvedViewModel.setLogLevel(level)
|
||||||
viewModel.toggleOptionsMenu()
|
resolvedViewModel.toggleOptionsMenu()
|
||||||
expandedLogLevel = false
|
expandedLogLevel = false
|
||||||
},
|
},
|
||||||
leadingIcon = {
|
leadingIcon = {
|
||||||
@@ -573,13 +683,10 @@ fun LogScreen(
|
|||||||
Text(text = stringResource(R.string.save_to_clipboard))
|
Text(text = stringResource(R.string.save_to_clipboard))
|
||||||
},
|
},
|
||||||
onClick = {
|
onClick = {
|
||||||
val logsText = viewModel.getAllLogsText()
|
val logsText = resolvedViewModel.getAllLogsText()
|
||||||
if (logsText.isNotEmpty()) {
|
if (logsText.isNotEmpty()) {
|
||||||
val clip =
|
val clip =
|
||||||
ClipData.newPlainText(
|
ClipData.newPlainText(resolvedTitle, logsText)
|
||||||
context.getString(R.string.title_log),
|
|
||||||
logsText,
|
|
||||||
)
|
|
||||||
Application.clipboard.setPrimaryClip(clip)
|
Application.clipboard.setPrimaryClip(clip)
|
||||||
Toast.makeText(
|
Toast.makeText(
|
||||||
context,
|
context,
|
||||||
@@ -593,7 +700,7 @@ fun LogScreen(
|
|||||||
Toast.LENGTH_SHORT,
|
Toast.LENGTH_SHORT,
|
||||||
).show()
|
).show()
|
||||||
}
|
}
|
||||||
viewModel.toggleOptionsMenu()
|
resolvedViewModel.toggleOptionsMenu()
|
||||||
expandedSave = false
|
expandedSave = false
|
||||||
},
|
},
|
||||||
leadingIcon = {
|
leadingIcon = {
|
||||||
@@ -617,8 +724,8 @@ fun LogScreen(
|
|||||||
"yyyyMMdd_HHmmss",
|
"yyyyMMdd_HHmmss",
|
||||||
Locale.getDefault(),
|
Locale.getDefault(),
|
||||||
).format(Date())
|
).format(Date())
|
||||||
saveFileLauncher.launch("logs_$timestamp.txt")
|
saveFileLauncher.launch("${saveFilePrefix}_$timestamp.txt")
|
||||||
viewModel.toggleOptionsMenu()
|
resolvedViewModel.toggleOptionsMenu()
|
||||||
expandedSave = false
|
expandedSave = false
|
||||||
},
|
},
|
||||||
leadingIcon = {
|
leadingIcon = {
|
||||||
@@ -637,7 +744,7 @@ fun LogScreen(
|
|||||||
Text(text = stringResource(R.string.menu_share))
|
Text(text = stringResource(R.string.menu_share))
|
||||||
},
|
},
|
||||||
onClick = {
|
onClick = {
|
||||||
val logsText = viewModel.getAllLogsText()
|
val logsText = resolvedViewModel.getAllLogsText()
|
||||||
if (logsText.isNotEmpty()) {
|
if (logsText.isNotEmpty()) {
|
||||||
try {
|
try {
|
||||||
val logsDir =
|
val logsDir =
|
||||||
@@ -647,7 +754,7 @@ fun LogScreen(
|
|||||||
"yyyyMMdd_HHmmss",
|
"yyyyMMdd_HHmmss",
|
||||||
Locale.getDefault(),
|
Locale.getDefault(),
|
||||||
).format(Date())
|
).format(Date())
|
||||||
val logFile = File(logsDir, "logs_$timestamp.txt")
|
val logFile = File(logsDir, "${saveFilePrefix}_$timestamp.txt")
|
||||||
logFile.writeText(logsText)
|
logFile.writeText(logsText)
|
||||||
|
|
||||||
val uri =
|
val uri =
|
||||||
@@ -682,7 +789,7 @@ fun LogScreen(
|
|||||||
Toast.LENGTH_SHORT,
|
Toast.LENGTH_SHORT,
|
||||||
).show()
|
).show()
|
||||||
}
|
}
|
||||||
viewModel.toggleOptionsMenu()
|
resolvedViewModel.toggleOptionsMenu()
|
||||||
expandedSave = false
|
expandedSave = false
|
||||||
},
|
},
|
||||||
leadingIcon = {
|
leadingIcon = {
|
||||||
@@ -698,7 +805,7 @@ fun LogScreen(
|
|||||||
HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp))
|
HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clear logs option
|
if (showClear) {
|
||||||
DropdownMenuItem(
|
DropdownMenuItem(
|
||||||
text = {
|
text = {
|
||||||
Text(
|
Text(
|
||||||
@@ -708,8 +815,8 @@ fun LogScreen(
|
|||||||
)
|
)
|
||||||
},
|
},
|
||||||
onClick = {
|
onClick = {
|
||||||
viewModel.requestClearLogs()
|
resolvedViewModel.requestClearLogs()
|
||||||
viewModel.toggleOptionsMenu()
|
resolvedViewModel.toggleOptionsMenu()
|
||||||
},
|
},
|
||||||
leadingIcon = {
|
leadingIcon = {
|
||||||
Icon(
|
Icon(
|
||||||
@@ -721,6 +828,7 @@ fun LogScreen(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// FABs - Hide during selection mode
|
// FABs - Hide during selection mode
|
||||||
val padFabVisible = isTablet && (showStartFab || showStatusBar)
|
val padFabVisible = isTablet && (showStartFab || showStatusBar)
|
||||||
@@ -746,7 +854,7 @@ fun LogScreen(
|
|||||||
exit = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) scaleOut() else fadeOut(),
|
exit = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) scaleOut() else fadeOut(),
|
||||||
) {
|
) {
|
||||||
FloatingActionButton(
|
FloatingActionButton(
|
||||||
onClick = { viewModel.scrollToBottom() },
|
onClick = { resolvedViewModel.scrollToBottom() },
|
||||||
containerColor = MaterialTheme.colorScheme.primaryContainer,
|
containerColor = MaterialTheme.colorScheme.primaryContainer,
|
||||||
contentColor = MaterialTheme.colorScheme.onPrimaryContainer,
|
contentColor = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||||
) {
|
) {
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
package io.nekohasekai.sfa.compose.screen.log
|
package io.nekohasekai.sfa.compose.screen.log
|
||||||
|
|
||||||
import androidx.compose.ui.text.AnnotatedString
|
|
||||||
import androidx.lifecycle.ViewModel
|
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import io.nekohasekai.libbox.Libbox
|
import io.nekohasekai.libbox.Libbox
|
||||||
import io.nekohasekai.libbox.LogEntry
|
import io.nekohasekai.libbox.LogEntry
|
||||||
@@ -9,67 +7,16 @@ import io.nekohasekai.sfa.compose.util.AnsiColorUtils
|
|||||||
import io.nekohasekai.sfa.constant.Status
|
import io.nekohasekai.sfa.constant.Status
|
||||||
import io.nekohasekai.sfa.utils.CommandClient
|
import io.nekohasekai.sfa.utils.CommandClient
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
|
||||||
import kotlinx.coroutines.flow.debounce
|
|
||||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
|
||||||
import kotlinx.coroutines.flow.update
|
import kotlinx.coroutines.flow.update
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import java.util.LinkedList
|
import java.util.LinkedList
|
||||||
import java.util.concurrent.atomic.AtomicLong
|
|
||||||
|
|
||||||
data class ProcessedLogEntry(
|
class LogViewModel : BaseLogViewModel(), CommandClient.Handler {
|
||||||
val id: Long,
|
|
||||||
val originalEntry: LogEntry,
|
|
||||||
val annotatedString: AnnotatedString,
|
|
||||||
)
|
|
||||||
|
|
||||||
enum class LogLevel(val label: String, val priority: Int) {
|
|
||||||
Default("Default", 7),
|
|
||||||
|
|
||||||
PANIC("Panic", 0),
|
|
||||||
FATAL("Fatal", 1),
|
|
||||||
ERROR("Error", 2),
|
|
||||||
WARNING("Warn", 3),
|
|
||||||
INFO("Info", 4),
|
|
||||||
DEBUG("Debug", 5),
|
|
||||||
TRACE("Trace", 6),
|
|
||||||
}
|
|
||||||
|
|
||||||
data class LogUiState(
|
|
||||||
val logs: List<ProcessedLogEntry> = emptyList(),
|
|
||||||
val isConnected: Boolean = false,
|
|
||||||
val serviceStatus: Status = Status.Stopped,
|
|
||||||
val isPaused: Boolean = false,
|
|
||||||
val searchQuery: String = "",
|
|
||||||
val isSearchActive: Boolean = false,
|
|
||||||
val defaultLogLevel: LogLevel = LogLevel.Default,
|
|
||||||
val filterLogLevel: LogLevel = LogLevel.Default,
|
|
||||||
val isOptionsMenuOpen: Boolean = false,
|
|
||||||
val isSelectionMode: Boolean = false,
|
|
||||||
val selectedLogIndices: Set<Int> = emptySet(),
|
|
||||||
)
|
|
||||||
|
|
||||||
class LogViewModel : ViewModel(), CommandClient.Handler {
|
|
||||||
companion object {
|
companion object {
|
||||||
private val maxLines = 3000
|
private val maxLines = 3000
|
||||||
}
|
}
|
||||||
|
|
||||||
private val _uiState = MutableStateFlow(LogUiState())
|
|
||||||
val uiState: StateFlow<LogUiState> = _uiState.asStateFlow()
|
|
||||||
|
|
||||||
private val _autoScrollEnabled = MutableStateFlow(true)
|
|
||||||
val isAtBottom: StateFlow<Boolean> = _autoScrollEnabled.asStateFlow()
|
|
||||||
|
|
||||||
private val _scrollToBottomTrigger = MutableStateFlow(0)
|
|
||||||
val scrollToBottomTrigger: StateFlow<Int> = _scrollToBottomTrigger.asStateFlow()
|
|
||||||
|
|
||||||
private val _searchQueryInternal = MutableStateFlow("")
|
|
||||||
private val logIdGenerator = AtomicLong(0)
|
|
||||||
|
|
||||||
private val allLogs = LinkedList<ProcessedLogEntry>()
|
|
||||||
private val bufferedLogs = LinkedList<ProcessedLogEntry>()
|
private val bufferedLogs = LinkedList<ProcessedLogEntry>()
|
||||||
private val commandClient =
|
private val commandClient =
|
||||||
CommandClient(
|
CommandClient(
|
||||||
@@ -78,26 +25,16 @@ class LogViewModel : ViewModel(), CommandClient.Handler {
|
|||||||
handler = this,
|
handler = this,
|
||||||
)
|
)
|
||||||
|
|
||||||
init {
|
|
||||||
viewModelScope.launch {
|
|
||||||
_searchQueryInternal
|
|
||||||
.debounce(300)
|
|
||||||
.distinctUntilChanged()
|
|
||||||
.collect { _ ->
|
|
||||||
updateDisplayedLogs()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun processLogEntry(entry: LogEntry): ProcessedLogEntry {
|
private fun processLogEntry(entry: LogEntry): ProcessedLogEntry {
|
||||||
|
val level = LogLevel.entries.find { it.priority == entry.level } ?: LogLevel.Default
|
||||||
return ProcessedLogEntry(
|
return ProcessedLogEntry(
|
||||||
id = logIdGenerator.incrementAndGet(),
|
id = logIdGenerator.incrementAndGet(),
|
||||||
originalEntry = entry,
|
entry = LogEntryData(level = level, message = entry.message),
|
||||||
annotatedString = AnsiColorUtils.ansiToAnnotatedString(entry.message),
|
annotatedString = AnsiColorUtils.ansiToAnnotatedString(entry.message),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun updateServiceStatus(status: Status) {
|
override fun updateServiceStatus(status: Status) {
|
||||||
_uiState.update { it.copy(serviceStatus = status) }
|
_uiState.update { it.copy(serviceStatus = status) }
|
||||||
|
|
||||||
when (status) {
|
when (status) {
|
||||||
@@ -135,7 +72,7 @@ class LogViewModel : ViewModel(), CommandClient.Handler {
|
|||||||
updateDisplayedLogs()
|
updateDisplayedLogs()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun requestClearLogs() {
|
override fun requestClearLogs() {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
runCatching {
|
runCatching {
|
||||||
@@ -168,10 +105,9 @@ class LogViewModel : ViewModel(), CommandClient.Handler {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun togglePause() {
|
override fun togglePause() {
|
||||||
val currentState = _uiState.value
|
val currentState = _uiState.value
|
||||||
if (currentState.isPaused && bufferedLogs.isNotEmpty()) {
|
if (currentState.isPaused && bufferedLogs.isNotEmpty()) {
|
||||||
// When resuming, add buffered logs
|
|
||||||
val totalSize = allLogs.size + bufferedLogs.size
|
val totalSize = allLogs.size + bufferedLogs.size
|
||||||
val removeCount = (totalSize - maxLines).coerceAtLeast(0)
|
val removeCount = (totalSize - maxLines).coerceAtLeast(0)
|
||||||
|
|
||||||
@@ -189,121 +125,6 @@ class LogViewModel : ViewModel(), CommandClient.Handler {
|
|||||||
updateDisplayedLogs()
|
updateDisplayedLogs()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun toggleSearch() {
|
|
||||||
_uiState.update {
|
|
||||||
it.copy(
|
|
||||||
isSearchActive = !it.isSearchActive,
|
|
||||||
searchQuery = if (!it.isSearchActive) it.searchQuery else "",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
updateDisplayedLogs()
|
|
||||||
}
|
|
||||||
|
|
||||||
fun updateSearchQuery(query: String) {
|
|
||||||
_uiState.update { it.copy(searchQuery = query) }
|
|
||||||
_searchQueryInternal.value = query
|
|
||||||
}
|
|
||||||
|
|
||||||
fun setLogLevel(level: LogLevel) {
|
|
||||||
_uiState.update { it.copy(filterLogLevel = level) }
|
|
||||||
updateDisplayedLogs()
|
|
||||||
}
|
|
||||||
|
|
||||||
fun toggleOptionsMenu() {
|
|
||||||
_uiState.update { it.copy(isOptionsMenuOpen = !it.isOptionsMenuOpen) }
|
|
||||||
}
|
|
||||||
|
|
||||||
fun setAutoScrollEnabled(enabled: Boolean) {
|
|
||||||
_autoScrollEnabled.value = enabled
|
|
||||||
}
|
|
||||||
|
|
||||||
fun scrollToBottom() {
|
|
||||||
_autoScrollEnabled.value = true
|
|
||||||
_scrollToBottomTrigger.value++
|
|
||||||
}
|
|
||||||
|
|
||||||
fun toggleSelectionMode() {
|
|
||||||
_uiState.update {
|
|
||||||
if (it.isSelectionMode) {
|
|
||||||
// Exit selection mode, clear selections, and resume if it was paused by selection mode
|
|
||||||
it.copy(isSelectionMode = false, selectedLogIndices = emptySet(), isPaused = false)
|
|
||||||
} else {
|
|
||||||
// Enter selection mode and pause log updates
|
|
||||||
it.copy(isSelectionMode = true, isPaused = true)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun toggleLogSelection(index: Int) {
|
|
||||||
_uiState.update { state ->
|
|
||||||
val newSelection =
|
|
||||||
if (state.selectedLogIndices.contains(index)) {
|
|
||||||
state.selectedLogIndices - index
|
|
||||||
} else {
|
|
||||||
state.selectedLogIndices + index
|
|
||||||
}
|
|
||||||
|
|
||||||
// Exit selection mode and unpause if no items are selected
|
|
||||||
if (newSelection.isEmpty()) {
|
|
||||||
state.copy(
|
|
||||||
isSelectionMode = false,
|
|
||||||
selectedLogIndices = emptySet(),
|
|
||||||
isPaused = false,
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
state.copy(selectedLogIndices = newSelection)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun clearSelection() {
|
|
||||||
_uiState.update {
|
|
||||||
it.copy(isSelectionMode = false, selectedLogIndices = emptySet(), isPaused = false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun getSelectedLogsText(): String {
|
|
||||||
val state = _uiState.value
|
|
||||||
return state.selectedLogIndices
|
|
||||||
.sorted()
|
|
||||||
.mapNotNull { index ->
|
|
||||||
state.logs.getOrNull(index)?.originalEntry?.message
|
|
||||||
}
|
|
||||||
.joinToString("\n")
|
|
||||||
}
|
|
||||||
|
|
||||||
fun getAllLogsText(): String {
|
|
||||||
return _uiState.value.logs.joinToString("\n") { it.originalEntry.message }
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun updateDisplayedLogs() {
|
|
||||||
val currentState = _uiState.value
|
|
||||||
val levelPriority =
|
|
||||||
if (currentState.filterLogLevel != LogLevel.Default) {
|
|
||||||
currentState.filterLogLevel.priority
|
|
||||||
} else {
|
|
||||||
currentState.defaultLogLevel.priority
|
|
||||||
}
|
|
||||||
val searchQuery = currentState.searchQuery
|
|
||||||
|
|
||||||
val logsToDisplay =
|
|
||||||
allLogs.asSequence()
|
|
||||||
.filter { log -> log.originalEntry.level <= levelPriority }
|
|
||||||
.filter { log ->
|
|
||||||
searchQuery.isEmpty() || log.originalEntry.message.contains(searchQuery, ignoreCase = true)
|
|
||||||
}
|
|
||||||
.toList()
|
|
||||||
|
|
||||||
val selectionCleared =
|
|
||||||
if (_uiState.value.isSelectionMode && _uiState.value.logs != logsToDisplay) {
|
|
||||||
emptySet<Int>()
|
|
||||||
} else {
|
|
||||||
_uiState.value.selectedLogIndices
|
|
||||||
}
|
|
||||||
|
|
||||||
_uiState.update { it.copy(logs = logsToDisplay, selectedLogIndices = selectionCleared) }
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onCleared() {
|
override fun onCleared() {
|
||||||
super.onCleared()
|
super.onCleared()
|
||||||
commandClient.disconnect()
|
commandClient.disconnect()
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
package io.nekohasekai.sfa.compose.screen.log
|
||||||
|
|
||||||
|
import io.nekohasekai.sfa.constant.Status
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
|
||||||
|
interface LogViewerViewModel {
|
||||||
|
val uiState: StateFlow<LogUiState>
|
||||||
|
val scrollToBottomTrigger: StateFlow<Int>
|
||||||
|
val isAtBottom: StateFlow<Boolean>
|
||||||
|
|
||||||
|
fun updateServiceStatus(status: Status)
|
||||||
|
fun togglePause()
|
||||||
|
fun toggleSearch()
|
||||||
|
fun toggleOptionsMenu()
|
||||||
|
fun updateSearchQuery(query: String)
|
||||||
|
fun setLogLevel(level: LogLevel)
|
||||||
|
fun setAutoScrollEnabled(enabled: Boolean)
|
||||||
|
fun scrollToBottom()
|
||||||
|
fun toggleSelectionMode()
|
||||||
|
fun toggleLogSelection(index: Int)
|
||||||
|
fun clearSelection()
|
||||||
|
fun getSelectedLogsText(): String
|
||||||
|
fun getAllLogsText(): String
|
||||||
|
fun requestClearLogs()
|
||||||
|
}
|
||||||
@@ -0,0 +1,934 @@
|
|||||||
|
package io.nekohasekai.sfa.compose.screen.privilegesettings
|
||||||
|
|
||||||
|
import android.content.pm.PackageManager
|
||||||
|
import android.os.Build
|
||||||
|
import android.widget.Toast
|
||||||
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
|
import androidx.compose.animation.fadeIn
|
||||||
|
import androidx.compose.animation.fadeOut
|
||||||
|
import androidx.compose.animation.expandVertically
|
||||||
|
import androidx.compose.animation.shrinkVertically
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||||
|
import androidx.compose.material.icons.filled.Clear
|
||||||
|
import androidx.compose.material.icons.filled.Close
|
||||||
|
import androidx.compose.material.icons.filled.ContentPaste
|
||||||
|
import androidx.compose.material.icons.filled.ExpandLess
|
||||||
|
import androidx.compose.material.icons.filled.ExpandMore
|
||||||
|
import androidx.compose.material.icons.filled.FilterList
|
||||||
|
import androidx.compose.material.icons.filled.ManageSearch
|
||||||
|
import androidx.compose.material.icons.filled.MoreVert
|
||||||
|
import androidx.compose.material.icons.filled.Search
|
||||||
|
import androidx.compose.material.icons.filled.SelectAll
|
||||||
|
import androidx.compose.material.icons.filled.Sort
|
||||||
|
import androidx.compose.material3.DropdownMenu
|
||||||
|
import androidx.compose.material3.DropdownMenuItem
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.HorizontalDivider
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.LinearProgressIndicator
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedTextField
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TopAppBar
|
||||||
|
import androidx.compose.material3.TopAppBarDefaults
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.focus.FocusRequester
|
||||||
|
import androidx.compose.ui.focus.focusRequester
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.platform.LocalFocusManager
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import io.nekohasekai.sfa.R
|
||||||
|
import io.nekohasekai.sfa.database.Settings
|
||||||
|
import io.nekohasekai.sfa.ktx.clipboardText
|
||||||
|
import io.nekohasekai.sfa.compose.topbar.OverrideTopBar
|
||||||
|
import io.nekohasekai.sfa.compose.shared.AppSelectionCard
|
||||||
|
import io.nekohasekai.sfa.compose.shared.PackageCache
|
||||||
|
import io.nekohasekai.sfa.compose.shared.SortMode
|
||||||
|
import io.nekohasekai.sfa.compose.shared.buildDisplayPackages
|
||||||
|
import io.nekohasekai.sfa.compose.screen.profileoverride.PerAppProxyScanner
|
||||||
|
import io.nekohasekai.sfa.utils.PrivilegeSettingsClient
|
||||||
|
import io.nekohasekai.sfa.vendor.PackageQueryManager
|
||||||
|
import io.nekohasekai.sfa.vendor.PrivilegedAccessRequiredException
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import java.util.Locale
|
||||||
|
|
||||||
|
|
||||||
|
private data class LoadResult(
|
||||||
|
val packages: List<PackageCache>,
|
||||||
|
val selectedUids: Set<Int>,
|
||||||
|
)
|
||||||
|
|
||||||
|
private const val VPN_SERVICE_PERMISSION = "android.permission.BIND_VPN_SERVICE"
|
||||||
|
|
||||||
|
private val managementPermissions =
|
||||||
|
setOf(
|
||||||
|
"android.permission.CONTROL_VPN",
|
||||||
|
"android.permission.CONTROL_ALWAYS_ON_VPN",
|
||||||
|
"android.permission.MANAGE_VPN",
|
||||||
|
"android.permission.NETWORK_SETTINGS",
|
||||||
|
"android.permission.NETWORK_STACK",
|
||||||
|
"android.permission.MAINLINE_NETWORK_STACK",
|
||||||
|
"android.permission.CONNECTIVITY_INTERNAL",
|
||||||
|
"android.permission.NETWORK_MANAGEMENT",
|
||||||
|
"android.permission.TETHER_PRIVILEGED",
|
||||||
|
"android.permission.MANAGE_NETWORK_POLICY",
|
||||||
|
)
|
||||||
|
|
||||||
|
private enum class RiskCategory {
|
||||||
|
NONE,
|
||||||
|
VPN_APP,
|
||||||
|
MANAGEMENT_APP,
|
||||||
|
BOTH,
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun PrivilegeSettingsManageScreen(onBack: () -> Unit) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
val focusManager = LocalFocusManager.current
|
||||||
|
val coroutineScope = rememberCoroutineScope()
|
||||||
|
|
||||||
|
var sortMode by remember { mutableStateOf(SortMode.NAME) }
|
||||||
|
var sortReverse by remember { mutableStateOf(false) }
|
||||||
|
var hideSystemApps by remember { mutableStateOf(false) }
|
||||||
|
var hideOfflineApps by remember { mutableStateOf(true) }
|
||||||
|
var hideDisabledApps by remember { mutableStateOf(true) }
|
||||||
|
|
||||||
|
var packages by remember { mutableStateOf<List<PackageCache>>(emptyList()) }
|
||||||
|
var displayPackages by remember { mutableStateOf<List<PackageCache>>(emptyList()) }
|
||||||
|
var currentPackages by remember { mutableStateOf<List<PackageCache>>(emptyList()) }
|
||||||
|
var selectedUids by remember { mutableStateOf<Set<Int>>(emptySet()) }
|
||||||
|
var isLoading by remember { mutableStateOf(true) }
|
||||||
|
|
||||||
|
var isSearchActive by remember { mutableStateOf(false) }
|
||||||
|
var searchQuery by remember { mutableStateOf("") }
|
||||||
|
var riskyWarningMessage by remember { mutableStateOf<String?>(null) }
|
||||||
|
var syncErrorMessage by remember { mutableStateOf<String?>(null) }
|
||||||
|
|
||||||
|
fun getRiskCategory(packageCache: PackageCache): RiskCategory {
|
||||||
|
val permissions = packageCache.info.requestedPermissions ?: emptyArray()
|
||||||
|
val hasManagement = permissions.any { it in managementPermissions }
|
||||||
|
val isSelf = packageCache.packageName == context.packageName
|
||||||
|
val hasVpnService =
|
||||||
|
!isSelf && (
|
||||||
|
permissions.any { it == VPN_SERVICE_PERMISSION } ||
|
||||||
|
packageCache.info.services?.any { it.permission == VPN_SERVICE_PERMISSION } == true
|
||||||
|
)
|
||||||
|
return when {
|
||||||
|
hasManagement && hasVpnService -> RiskCategory.BOTH
|
||||||
|
hasManagement -> RiskCategory.MANAGEMENT_APP
|
||||||
|
hasVpnService -> RiskCategory.VPN_APP
|
||||||
|
else -> RiskCategory.NONE
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun buildPackageList(newUids: Set<Int>): Set<String> {
|
||||||
|
return newUids.mapNotNull { uid ->
|
||||||
|
packages.find { it.uid == uid }?.packageName
|
||||||
|
}.toSet()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun updateCurrentPackages(filterQuery: String) {
|
||||||
|
currentPackages =
|
||||||
|
if (filterQuery.isEmpty()) {
|
||||||
|
displayPackages
|
||||||
|
} else {
|
||||||
|
displayPackages.filter {
|
||||||
|
it.applicationLabel.contains(filterQuery, ignoreCase = true) ||
|
||||||
|
it.packageName.contains(filterQuery, ignoreCase = true) ||
|
||||||
|
it.uid.toString().contains(filterQuery)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun applyFilter() {
|
||||||
|
displayPackages =
|
||||||
|
buildDisplayPackages(
|
||||||
|
packages = packages,
|
||||||
|
selectedUids = selectedUids,
|
||||||
|
selectedFirst = true,
|
||||||
|
hideSystemApps = hideSystemApps,
|
||||||
|
hideOfflineApps = hideOfflineApps,
|
||||||
|
hideDisabledApps = hideDisabledApps,
|
||||||
|
sortMode = sortMode,
|
||||||
|
sortReverse = sortReverse,
|
||||||
|
)
|
||||||
|
currentPackages = displayPackages
|
||||||
|
}
|
||||||
|
|
||||||
|
fun saveSelectedApplications(newUids: Set<Int>) {
|
||||||
|
coroutineScope.launch {
|
||||||
|
val failure =
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
Settings.privilegeSettingsList = buildPackageList(newUids)
|
||||||
|
PrivilegeSettingsClient.sync()
|
||||||
|
}
|
||||||
|
if (failure != null) {
|
||||||
|
syncErrorMessage = failure.message ?: failure.toString()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun warnIfRiskySelected(newUids: Set<Int>) {
|
||||||
|
val addedUids = newUids - selectedUids
|
||||||
|
if (addedUids.isEmpty()) return
|
||||||
|
val addedApps = packages.filter { it.uid in addedUids }
|
||||||
|
val vpnUids =
|
||||||
|
addedApps
|
||||||
|
.filter { getRiskCategory(it) == RiskCategory.VPN_APP || getRiskCategory(it) == RiskCategory.BOTH }
|
||||||
|
.map { it.uid }
|
||||||
|
.toSet()
|
||||||
|
val managementUids =
|
||||||
|
addedApps
|
||||||
|
.filter { getRiskCategory(it) == RiskCategory.MANAGEMENT_APP || getRiskCategory(it) == RiskCategory.BOTH }
|
||||||
|
.map { it.uid }
|
||||||
|
.toSet()
|
||||||
|
val vpnApps = packages.filter { it.uid in vpnUids }.distinctBy { it.packageName }
|
||||||
|
val managementApps = packages.filter { it.uid in managementUids }.distinctBy { it.packageName }
|
||||||
|
if (vpnApps.isEmpty() && managementApps.isEmpty()) return
|
||||||
|
|
||||||
|
val listSeparator = if (Locale.getDefault().language == "zh") "、" else ", "
|
||||||
|
val messages = ArrayList<String>(2)
|
||||||
|
if (vpnApps.isNotEmpty()) {
|
||||||
|
val labelList = vpnApps.map { it.applicationLabel }.distinct().sorted()
|
||||||
|
val labels = labelList.joinToString(listSeparator)
|
||||||
|
messages +=
|
||||||
|
if (labelList.size == 1) {
|
||||||
|
context.getString(
|
||||||
|
R.string.privilege_settings_risky_vpn_message_single,
|
||||||
|
labels,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
context.getString(
|
||||||
|
R.string.privilege_settings_risky_vpn_message_multi,
|
||||||
|
labels,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (managementApps.isNotEmpty()) {
|
||||||
|
val labelList = managementApps.map { it.applicationLabel }.distinct().sorted()
|
||||||
|
val labels = labelList.joinToString(listSeparator)
|
||||||
|
messages +=
|
||||||
|
if (labelList.size == 1) {
|
||||||
|
context.getString(
|
||||||
|
R.string.privilege_settings_risky_management_message_single,
|
||||||
|
labels,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
context.getString(
|
||||||
|
R.string.privilege_settings_risky_management_message_multi,
|
||||||
|
labels,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
riskyWarningMessage = messages.joinToString("\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
fun postSaveSelectedApplications(newUids: Set<Int>, warnRisky: Boolean = true) {
|
||||||
|
if (warnRisky) {
|
||||||
|
warnIfRiskySelected(newUids)
|
||||||
|
}
|
||||||
|
selectedUids = newUids
|
||||||
|
saveSelectedApplications(newUids)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun toggleSelection(packageCache: PackageCache, selected: Boolean) {
|
||||||
|
val newSelected =
|
||||||
|
if (selected) {
|
||||||
|
selectedUids + packageCache.uid
|
||||||
|
} else {
|
||||||
|
selectedUids - packageCache.uid
|
||||||
|
}
|
||||||
|
if (newSelected == selectedUids) return
|
||||||
|
postSaveSelectedApplications(newSelected)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun startScanChinaApps() {
|
||||||
|
val scanPackages = currentPackages.toList()
|
||||||
|
if (scanPackages.isEmpty() || isLoading) return
|
||||||
|
isLoading = true
|
||||||
|
coroutineScope.launch {
|
||||||
|
val foundUids =
|
||||||
|
withContext(Dispatchers.Default) {
|
||||||
|
scanPackages.mapNotNull { packageCache ->
|
||||||
|
if (PerAppProxyScanner.scanChinaPackage(packageCache.info)) {
|
||||||
|
if (getRiskCategory(packageCache) != RiskCategory.NONE) {
|
||||||
|
null
|
||||||
|
} else {
|
||||||
|
packageCache.uid
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}.toSet()
|
||||||
|
}
|
||||||
|
if (foundUids.isNotEmpty()) {
|
||||||
|
postSaveSelectedApplications(selectedUids + foundUids)
|
||||||
|
}
|
||||||
|
isLoading = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
isLoading = true
|
||||||
|
val packageManagerFlags =
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
|
||||||
|
PackageManager.GET_PERMISSIONS or PackageManager.MATCH_UNINSTALLED_PACKAGES or
|
||||||
|
PackageManager.GET_ACTIVITIES or PackageManager.GET_SERVICES or
|
||||||
|
PackageManager.GET_RECEIVERS or PackageManager.GET_PROVIDERS
|
||||||
|
} else {
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
PackageManager.GET_PERMISSIONS or PackageManager.GET_UNINSTALLED_PACKAGES or
|
||||||
|
PackageManager.GET_ACTIVITIES or PackageManager.GET_SERVICES or
|
||||||
|
PackageManager.GET_RECEIVERS or PackageManager.GET_PROVIDERS
|
||||||
|
}
|
||||||
|
val loadResult =
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
try {
|
||||||
|
val installedPackages = PackageQueryManager.getInstalledPackages(packageManagerFlags)
|
||||||
|
val packageManager = context.packageManager
|
||||||
|
val packageCaches =
|
||||||
|
installedPackages.mapNotNull { packageInfo ->
|
||||||
|
val appInfo = packageInfo.applicationInfo ?: return@mapNotNull null
|
||||||
|
PackageCache(packageInfo, appInfo, packageManager)
|
||||||
|
}
|
||||||
|
val selectedPackageNames = Settings.privilegeSettingsList.toMutableSet()
|
||||||
|
val selectedUidSet =
|
||||||
|
packageCaches.mapNotNull { packageCache ->
|
||||||
|
if (selectedPackageNames.contains(packageCache.packageName)) {
|
||||||
|
packageCache.uid
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}.toSet()
|
||||||
|
LoadResult(packageCaches, selectedUidSet)
|
||||||
|
} catch (_: PrivilegedAccessRequiredException) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (loadResult == null) {
|
||||||
|
Toast.makeText(
|
||||||
|
context,
|
||||||
|
R.string.privileged_access_required,
|
||||||
|
Toast.LENGTH_LONG,
|
||||||
|
).show()
|
||||||
|
onBack()
|
||||||
|
return@LaunchedEffect
|
||||||
|
}
|
||||||
|
packages = loadResult.packages
|
||||||
|
selectedUids = loadResult.selectedUids
|
||||||
|
applyFilter()
|
||||||
|
updateCurrentPackages(searchQuery)
|
||||||
|
isLoading = false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (riskyWarningMessage != null) {
|
||||||
|
androidx.compose.material3.AlertDialog(
|
||||||
|
onDismissRequest = { riskyWarningMessage = null },
|
||||||
|
title = { Text(stringResource(R.string.privilege_settings_risky_app_title)) },
|
||||||
|
text = { Text(riskyWarningMessage ?: "") },
|
||||||
|
confirmButton = {
|
||||||
|
androidx.compose.material3.TextButton(
|
||||||
|
onClick = { riskyWarningMessage = null },
|
||||||
|
) {
|
||||||
|
Text(stringResource(R.string.ok))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (syncErrorMessage != null) {
|
||||||
|
androidx.compose.material3.AlertDialog(
|
||||||
|
onDismissRequest = { syncErrorMessage = null },
|
||||||
|
title = { Text(stringResource(R.string.error_title)) },
|
||||||
|
text = { Text(syncErrorMessage ?: "") },
|
||||||
|
confirmButton = {
|
||||||
|
androidx.compose.material3.TextButton(
|
||||||
|
onClick = { syncErrorMessage = null },
|
||||||
|
) {
|
||||||
|
Text(stringResource(R.string.ok))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
OverrideTopBar {
|
||||||
|
TopAppBar(
|
||||||
|
title = { Text(stringResource(R.string.privilege_settings_hide_title)) },
|
||||||
|
navigationIcon = {
|
||||||
|
IconButton(onClick = onBack) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
|
||||||
|
contentDescription = stringResource(R.string.content_description_back),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
actions = {
|
||||||
|
IconButton(
|
||||||
|
onClick = {
|
||||||
|
isSearchActive = !isSearchActive
|
||||||
|
if (!isSearchActive) {
|
||||||
|
searchQuery = ""
|
||||||
|
updateCurrentPackages("")
|
||||||
|
focusManager.clearFocus()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = if (isSearchActive) Icons.Default.Close else Icons.Default.Search,
|
||||||
|
contentDescription = stringResource(R.string.search),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
PrivilegeSettingsMenus(
|
||||||
|
sortMode = sortMode,
|
||||||
|
sortReverse = sortReverse,
|
||||||
|
hideSystemApps = hideSystemApps,
|
||||||
|
hideOfflineApps = hideOfflineApps,
|
||||||
|
hideDisabledApps = hideDisabledApps,
|
||||||
|
onSortModeChange = { mode ->
|
||||||
|
sortMode = mode
|
||||||
|
applyFilter()
|
||||||
|
},
|
||||||
|
onSortReverseToggle = {
|
||||||
|
sortReverse = !sortReverse
|
||||||
|
applyFilter()
|
||||||
|
},
|
||||||
|
onHideSystemAppsToggle = {
|
||||||
|
hideSystemApps = !hideSystemApps
|
||||||
|
applyFilter()
|
||||||
|
},
|
||||||
|
onHideOfflineAppsToggle = {
|
||||||
|
hideOfflineApps = !hideOfflineApps
|
||||||
|
applyFilter()
|
||||||
|
},
|
||||||
|
onHideDisabledAppsToggle = {
|
||||||
|
hideDisabledApps = !hideDisabledApps
|
||||||
|
applyFilter()
|
||||||
|
},
|
||||||
|
onScanChinaApps = {
|
||||||
|
startScanChinaApps()
|
||||||
|
},
|
||||||
|
onSelectAll = {
|
||||||
|
val newSelected = currentPackages.map { it.uid }.toSet()
|
||||||
|
postSaveSelectedApplications(newSelected)
|
||||||
|
},
|
||||||
|
onDeselectAll = {
|
||||||
|
postSaveSelectedApplications(emptySet())
|
||||||
|
},
|
||||||
|
onImport = {
|
||||||
|
val packageNames =
|
||||||
|
clipboardText?.split("\n")?.distinct()
|
||||||
|
?.takeIf { it.isNotEmpty() && it[0].isNotEmpty() }
|
||||||
|
if (packageNames.isNullOrEmpty()) {
|
||||||
|
Toast.makeText(
|
||||||
|
context,
|
||||||
|
R.string.toast_clipboard_empty,
|
||||||
|
Toast.LENGTH_SHORT,
|
||||||
|
).show()
|
||||||
|
} else {
|
||||||
|
val newSelected =
|
||||||
|
packages.mapNotNull { packageCache ->
|
||||||
|
if (packageNames.contains(packageCache.packageName)) {
|
||||||
|
packageCache.uid
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}.toSet()
|
||||||
|
postSaveSelectedApplications(newSelected)
|
||||||
|
Toast.makeText(
|
||||||
|
context,
|
||||||
|
R.string.toast_imported_from_clipboard,
|
||||||
|
Toast.LENGTH_SHORT,
|
||||||
|
).show()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onExport = {
|
||||||
|
val packageList =
|
||||||
|
packages.mapNotNull { packageCache ->
|
||||||
|
if (selectedUids.contains(packageCache.uid)) {
|
||||||
|
packageCache.packageName
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
clipboardText = packageList.joinToString("\n")
|
||||||
|
Toast.makeText(
|
||||||
|
context,
|
||||||
|
R.string.toast_copied_to_clipboard,
|
||||||
|
Toast.LENGTH_SHORT,
|
||||||
|
).show()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
},
|
||||||
|
colors =
|
||||||
|
TopAppBarDefaults.topAppBarColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.surface,
|
||||||
|
titleContentColor = MaterialTheme.colorScheme.onSurface,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
) {
|
||||||
|
AnimatedVisibility(
|
||||||
|
visible = isLoading,
|
||||||
|
enter = fadeIn(),
|
||||||
|
exit = fadeOut(),
|
||||||
|
) {
|
||||||
|
LinearProgressIndicator(modifier = Modifier.fillMaxWidth())
|
||||||
|
}
|
||||||
|
|
||||||
|
Surface(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
color = MaterialTheme.colorScheme.surfaceContainerLow,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.privilege_settings_hide_description),
|
||||||
|
modifier = Modifier.padding(horizontal = 16.dp, vertical = 10.dp),
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
AnimatedVisibility(
|
||||||
|
visible = isSearchActive,
|
||||||
|
enter = expandVertically() + fadeIn(),
|
||||||
|
exit = shrinkVertically() + fadeOut(),
|
||||||
|
) {
|
||||||
|
val focusRequester = remember { FocusRequester() }
|
||||||
|
|
||||||
|
LaunchedEffect(isSearchActive) {
|
||||||
|
if (isSearchActive) {
|
||||||
|
focusRequester.requestFocus()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
OutlinedTextField(
|
||||||
|
value = searchQuery,
|
||||||
|
onValueChange = {
|
||||||
|
searchQuery = it
|
||||||
|
updateCurrentPackages(it)
|
||||||
|
},
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 16.dp, vertical = 8.dp)
|
||||||
|
.focusRequester(focusRequester),
|
||||||
|
placeholder = { Text(stringResource(R.string.search)) },
|
||||||
|
leadingIcon = {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Search,
|
||||||
|
contentDescription = stringResource(R.string.search),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
trailingIcon = {
|
||||||
|
if (searchQuery.isNotEmpty()) {
|
||||||
|
IconButton(onClick = {
|
||||||
|
searchQuery = ""
|
||||||
|
updateCurrentPackages("")
|
||||||
|
focusManager.clearFocus()
|
||||||
|
}) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Clear,
|
||||||
|
contentDescription = stringResource(R.string.content_description_clear_search),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
singleLine = true,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
LazyColumn(
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
contentPadding =
|
||||||
|
androidx.compose.foundation.layout.PaddingValues(
|
||||||
|
horizontal = 16.dp,
|
||||||
|
vertical = 12.dp,
|
||||||
|
),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
items(currentPackages, key = { it.packageName }) { packageCache ->
|
||||||
|
AppSelectionCard(
|
||||||
|
packageCache = packageCache,
|
||||||
|
selected = selectedUids.contains(packageCache.uid),
|
||||||
|
onToggle = { selected -> toggleSelection(packageCache, selected) },
|
||||||
|
onCopyLabel = { clipboardText = packageCache.applicationLabel },
|
||||||
|
onCopyPackage = { clipboardText = packageCache.packageName },
|
||||||
|
onCopyUid = { clipboardText = packageCache.uid.toString() },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
private fun PrivilegeSettingsMenus(
|
||||||
|
sortMode: SortMode,
|
||||||
|
sortReverse: Boolean,
|
||||||
|
hideSystemApps: Boolean,
|
||||||
|
hideOfflineApps: Boolean,
|
||||||
|
hideDisabledApps: Boolean,
|
||||||
|
onSortModeChange: (SortMode) -> Unit,
|
||||||
|
onSortReverseToggle: () -> Unit,
|
||||||
|
onHideSystemAppsToggle: () -> Unit,
|
||||||
|
onHideOfflineAppsToggle: () -> Unit,
|
||||||
|
onHideDisabledAppsToggle: () -> Unit,
|
||||||
|
onScanChinaApps: () -> Unit,
|
||||||
|
onSelectAll: () -> Unit,
|
||||||
|
onDeselectAll: () -> Unit,
|
||||||
|
onImport: () -> Unit,
|
||||||
|
onExport: () -> Unit,
|
||||||
|
) {
|
||||||
|
var showMainMenu by remember { mutableStateOf(false) }
|
||||||
|
var showSortMenu by remember { mutableStateOf(false) }
|
||||||
|
var showFilterMenu by remember { mutableStateOf(false) }
|
||||||
|
var showScanMenu by remember { mutableStateOf(false) }
|
||||||
|
var showSelectMenu by remember { mutableStateOf(false) }
|
||||||
|
var showBackupMenu by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
IconButton(onClick = { showMainMenu = true }) {
|
||||||
|
Icon(Icons.Default.MoreVert, contentDescription = null)
|
||||||
|
}
|
||||||
|
|
||||||
|
DropdownMenu(
|
||||||
|
expanded = showMainMenu,
|
||||||
|
onDismissRequest = {
|
||||||
|
showMainMenu = false
|
||||||
|
showSortMenu = false
|
||||||
|
showFilterMenu = false
|
||||||
|
showScanMenu = false
|
||||||
|
showSelectMenu = false
|
||||||
|
showBackupMenu = false
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text(stringResource(R.string.per_app_proxy_sort_mode)) },
|
||||||
|
onClick = { showSortMenu = !showSortMenu },
|
||||||
|
leadingIcon = {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Sort,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.primary,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if (showSortMenu) {
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text(stringResource(R.string.per_app_proxy_sort_mode_name)) },
|
||||||
|
onClick = {
|
||||||
|
onSortModeChange(SortMode.NAME)
|
||||||
|
showMainMenu = false
|
||||||
|
showSortMenu = false
|
||||||
|
},
|
||||||
|
leadingIcon = {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Sort,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.primary,
|
||||||
|
modifier = Modifier.padding(start = 24.dp),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text(stringResource(R.string.per_app_proxy_sort_mode_package_name)) },
|
||||||
|
onClick = {
|
||||||
|
onSortModeChange(SortMode.PACKAGE_NAME)
|
||||||
|
showMainMenu = false
|
||||||
|
showSortMenu = false
|
||||||
|
},
|
||||||
|
leadingIcon = {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Sort,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.primary,
|
||||||
|
modifier = Modifier.padding(start = 24.dp),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text(stringResource(R.string.per_app_proxy_sort_mode_uid)) },
|
||||||
|
onClick = {
|
||||||
|
onSortModeChange(SortMode.UID)
|
||||||
|
showMainMenu = false
|
||||||
|
showSortMenu = false
|
||||||
|
},
|
||||||
|
leadingIcon = {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Sort,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.primary,
|
||||||
|
modifier = Modifier.padding(start = 24.dp),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text(stringResource(R.string.per_app_proxy_sort_mode_install_time)) },
|
||||||
|
onClick = {
|
||||||
|
onSortModeChange(SortMode.INSTALL_TIME)
|
||||||
|
showMainMenu = false
|
||||||
|
showSortMenu = false
|
||||||
|
},
|
||||||
|
leadingIcon = {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Sort,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.primary,
|
||||||
|
modifier = Modifier.padding(start = 24.dp),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text(stringResource(R.string.per_app_proxy_sort_mode_update_time)) },
|
||||||
|
onClick = {
|
||||||
|
onSortModeChange(SortMode.UPDATE_TIME)
|
||||||
|
showMainMenu = false
|
||||||
|
showSortMenu = false
|
||||||
|
},
|
||||||
|
leadingIcon = {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Sort,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.primary,
|
||||||
|
modifier = Modifier.padding(start = 24.dp),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp))
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text(stringResource(R.string.per_app_proxy_sort_mode_reverse)) },
|
||||||
|
onClick = {
|
||||||
|
onSortReverseToggle()
|
||||||
|
showMainMenu = false
|
||||||
|
showSortMenu = false
|
||||||
|
},
|
||||||
|
leadingIcon = {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Sort,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.primary,
|
||||||
|
modifier = Modifier.padding(start = 24.dp),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text(stringResource(R.string.per_app_proxy_scan)) },
|
||||||
|
onClick = { showScanMenu = !showScanMenu },
|
||||||
|
leadingIcon = {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.ManageSearch,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.primary,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
trailingIcon = {
|
||||||
|
Icon(
|
||||||
|
imageVector =
|
||||||
|
if (showScanMenu) {
|
||||||
|
Icons.Default.ExpandLess
|
||||||
|
} else {
|
||||||
|
Icons.Default.ExpandMore
|
||||||
|
},
|
||||||
|
contentDescription = null,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if (showScanMenu) {
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text(stringResource(R.string.per_app_proxy_scan_china_apps)) },
|
||||||
|
onClick = {
|
||||||
|
onScanChinaApps()
|
||||||
|
showMainMenu = false
|
||||||
|
showScanMenu = false
|
||||||
|
},
|
||||||
|
leadingIcon = {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.ManageSearch,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.primary,
|
||||||
|
modifier = Modifier.padding(start = 24.dp),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp))
|
||||||
|
}
|
||||||
|
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text(stringResource(R.string.per_app_proxy_filter)) },
|
||||||
|
onClick = { showFilterMenu = !showFilterMenu },
|
||||||
|
leadingIcon = {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.FilterList,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.primary,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if (showFilterMenu) {
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text(stringResource(R.string.per_app_proxy_hide_system_apps)) },
|
||||||
|
onClick = {
|
||||||
|
onHideSystemAppsToggle()
|
||||||
|
showMainMenu = false
|
||||||
|
showFilterMenu = false
|
||||||
|
},
|
||||||
|
leadingIcon = {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.FilterList,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.primary,
|
||||||
|
modifier = Modifier.padding(start = 24.dp),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text(stringResource(R.string.per_app_proxy_hide_offline_apps)) },
|
||||||
|
onClick = {
|
||||||
|
onHideOfflineAppsToggle()
|
||||||
|
showMainMenu = false
|
||||||
|
showFilterMenu = false
|
||||||
|
},
|
||||||
|
leadingIcon = {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.FilterList,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.primary,
|
||||||
|
modifier = Modifier.padding(start = 24.dp),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text(stringResource(R.string.per_app_proxy_hide_disabled_apps)) },
|
||||||
|
onClick = {
|
||||||
|
onHideDisabledAppsToggle()
|
||||||
|
showMainMenu = false
|
||||||
|
showFilterMenu = false
|
||||||
|
},
|
||||||
|
leadingIcon = {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.FilterList,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.primary,
|
||||||
|
modifier = Modifier.padding(start = 24.dp),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp))
|
||||||
|
}
|
||||||
|
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text(stringResource(R.string.per_app_proxy_select)) },
|
||||||
|
onClick = { showSelectMenu = !showSelectMenu },
|
||||||
|
leadingIcon = {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.SelectAll,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.primary,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if (showSelectMenu) {
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text(stringResource(R.string.per_app_proxy_select_all)) },
|
||||||
|
onClick = {
|
||||||
|
onSelectAll()
|
||||||
|
showMainMenu = false
|
||||||
|
showSelectMenu = false
|
||||||
|
},
|
||||||
|
leadingIcon = {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.SelectAll,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.primary,
|
||||||
|
modifier = Modifier.padding(start = 24.dp),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text(stringResource(R.string.action_deselect)) },
|
||||||
|
onClick = {
|
||||||
|
onDeselectAll()
|
||||||
|
showMainMenu = false
|
||||||
|
showSelectMenu = false
|
||||||
|
},
|
||||||
|
leadingIcon = {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.SelectAll,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.primary,
|
||||||
|
modifier = Modifier.padding(start = 24.dp),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp))
|
||||||
|
}
|
||||||
|
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text(stringResource(R.string.per_app_proxy_backup)) },
|
||||||
|
onClick = { showBackupMenu = !showBackupMenu },
|
||||||
|
leadingIcon = {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.ContentPaste,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.primary,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if (showBackupMenu) {
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text(stringResource(R.string.per_app_proxy_import)) },
|
||||||
|
onClick = {
|
||||||
|
onImport()
|
||||||
|
showMainMenu = false
|
||||||
|
showBackupMenu = false
|
||||||
|
},
|
||||||
|
leadingIcon = {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.ContentPaste,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.primary,
|
||||||
|
modifier = Modifier.padding(start = 24.dp),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text(stringResource(R.string.per_app_proxy_export)) },
|
||||||
|
onClick = {
|
||||||
|
onExport()
|
||||||
|
showMainMenu = false
|
||||||
|
showBackupMenu = false
|
||||||
|
},
|
||||||
|
leadingIcon = {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.ContentPaste,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.primary,
|
||||||
|
modifier = Modifier.padding(start = 24.dp),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -44,7 +44,6 @@ import androidx.compose.material3.IconButton
|
|||||||
import androidx.compose.material3.LinearProgressIndicator
|
import androidx.compose.material3.LinearProgressIndicator
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.OutlinedTextField
|
import androidx.compose.material3.OutlinedTextField
|
||||||
import androidx.compose.material3.Scaffold
|
|
||||||
import androidx.compose.material3.Surface
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TextButton
|
import androidx.compose.material3.TextButton
|
||||||
@@ -62,6 +61,7 @@ import androidx.compose.runtime.setValue
|
|||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.ExperimentalComposeUiApi
|
import androidx.compose.ui.ExperimentalComposeUiApi
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clipToBounds
|
||||||
import androidx.compose.ui.focus.FocusRequester
|
import androidx.compose.ui.focus.FocusRequester
|
||||||
import androidx.compose.ui.focus.focusRequester
|
import androidx.compose.ui.focus.focusRequester
|
||||||
import androidx.compose.ui.input.key.Key
|
import androidx.compose.ui.input.key.Key
|
||||||
@@ -82,6 +82,7 @@ import androidx.compose.ui.viewinterop.AndroidView
|
|||||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
import com.blacksquircle.ui.language.json.JsonLanguage
|
import com.blacksquircle.ui.language.json.JsonLanguage
|
||||||
import io.nekohasekai.sfa.R
|
import io.nekohasekai.sfa.R
|
||||||
|
import io.nekohasekai.sfa.compose.topbar.OverrideTopBar
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
@@ -137,7 +138,91 @@ fun EditProfileContentScreen(
|
|||||||
showUnsavedChangesDialog = true
|
showUnsavedChangesDialog = true
|
||||||
}
|
}
|
||||||
|
|
||||||
Scaffold(
|
OverrideTopBar {
|
||||||
|
TopAppBar(
|
||||||
|
title = {
|
||||||
|
Column {
|
||||||
|
Text(
|
||||||
|
if (uiState.isReadOnly) {
|
||||||
|
stringResource(R.string.view_configuration)
|
||||||
|
} else {
|
||||||
|
stringResource(R.string.title_edit_configuration)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if (uiState.profileName.isNotEmpty()) {
|
||||||
|
Text(
|
||||||
|
text = uiState.profileName,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
navigationIcon = {
|
||||||
|
IconButton(
|
||||||
|
onClick = {
|
||||||
|
if (uiState.hasUnsavedChanges && !uiState.isReadOnly) {
|
||||||
|
showUnsavedChangesDialog = true
|
||||||
|
} else {
|
||||||
|
onNavigateBack()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
|
||||||
|
contentDescription = stringResource(R.string.content_description_back),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
actions = {
|
||||||
|
// Search/Collapse button (Ctrl/Cmd+F)
|
||||||
|
IconButton(
|
||||||
|
onClick = { viewModel.toggleSearchBar() },
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = if (uiState.showSearchBar) Icons.Default.ExpandLess else Icons.Default.Search,
|
||||||
|
contentDescription =
|
||||||
|
if (uiState.showSearchBar) {
|
||||||
|
stringResource(R.string.content_description_collapse_search)
|
||||||
|
} else {
|
||||||
|
stringResource(R.string.search)
|
||||||
|
},
|
||||||
|
tint =
|
||||||
|
if (uiState.showSearchBar) {
|
||||||
|
MaterialTheme.colorScheme.primary
|
||||||
|
} else {
|
||||||
|
MaterialTheme.colorScheme.onSurface
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save button (only show if not read-only) (Ctrl/Cmd+S)
|
||||||
|
if (!uiState.isReadOnly) {
|
||||||
|
IconButton(
|
||||||
|
onClick = { viewModel.saveConfiguration() },
|
||||||
|
enabled = uiState.hasUnsavedChanges && !uiState.isLoading,
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Save,
|
||||||
|
contentDescription = stringResource(R.string.save),
|
||||||
|
tint =
|
||||||
|
if (uiState.hasUnsavedChanges) {
|
||||||
|
MaterialTheme.colorScheme.primary
|
||||||
|
} else {
|
||||||
|
MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
colors =
|
||||||
|
TopAppBarDefaults.topAppBarColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.surface,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Column(
|
||||||
modifier =
|
modifier =
|
||||||
modifier
|
modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
@@ -221,106 +306,17 @@ fun EditProfileContentScreen(
|
|||||||
false
|
false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
topBar = {
|
|
||||||
TopAppBar(
|
|
||||||
title = {
|
|
||||||
Column {
|
|
||||||
Text(
|
|
||||||
if (uiState.isReadOnly) {
|
|
||||||
stringResource(R.string.view_configuration)
|
|
||||||
} else {
|
|
||||||
stringResource(R.string.title_edit_configuration)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
if (uiState.profileName.isNotEmpty()) {
|
|
||||||
Text(
|
|
||||||
text = uiState.profileName,
|
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
navigationIcon = {
|
|
||||||
IconButton(
|
|
||||||
onClick = {
|
|
||||||
if (uiState.hasUnsavedChanges && !uiState.isReadOnly) {
|
|
||||||
showUnsavedChangesDialog = true
|
|
||||||
} else {
|
|
||||||
onNavigateBack()
|
|
||||||
}
|
|
||||||
},
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
|
|
||||||
contentDescription = stringResource(R.string.content_description_back),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
actions = {
|
|
||||||
// Search/Collapse button (Ctrl/Cmd+F)
|
|
||||||
IconButton(
|
|
||||||
onClick = { viewModel.toggleSearchBar() },
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
imageVector = if (uiState.showSearchBar) Icons.Default.ExpandLess else Icons.Default.Search,
|
|
||||||
contentDescription =
|
|
||||||
if (uiState.showSearchBar) {
|
|
||||||
stringResource(R.string.content_description_collapse_search)
|
|
||||||
} else {
|
|
||||||
stringResource(R.string.search)
|
|
||||||
},
|
|
||||||
tint =
|
|
||||||
if (uiState.showSearchBar) {
|
|
||||||
MaterialTheme.colorScheme.primary
|
|
||||||
} else {
|
|
||||||
MaterialTheme.colorScheme.onSurface
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Save button (only show if not read-only) (Ctrl/Cmd+S)
|
|
||||||
if (!uiState.isReadOnly) {
|
|
||||||
IconButton(
|
|
||||||
onClick = { viewModel.saveConfiguration() },
|
|
||||||
enabled = uiState.hasUnsavedChanges && !uiState.isLoading,
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
imageVector = Icons.Default.Save,
|
|
||||||
contentDescription = stringResource(R.string.save),
|
|
||||||
tint =
|
|
||||||
if (uiState.hasUnsavedChanges) {
|
|
||||||
MaterialTheme.colorScheme.primary
|
|
||||||
} else {
|
|
||||||
MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
colors =
|
|
||||||
TopAppBarDefaults.topAppBarColors(
|
|
||||||
containerColor = MaterialTheme.colorScheme.surfaceContainer,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
},
|
|
||||||
) { paddingValues ->
|
|
||||||
Column(
|
|
||||||
modifier =
|
|
||||||
Modifier
|
|
||||||
.fillMaxSize()
|
|
||||||
.padding(paddingValues),
|
|
||||||
) {
|
) {
|
||||||
// Search bar (appears at top when activated)
|
// Search bar (appears at top when activated)
|
||||||
AnimatedVisibility(
|
AnimatedVisibility(
|
||||||
visible = uiState.showSearchBar,
|
visible = uiState.showSearchBar,
|
||||||
enter = slideInVertically(initialOffsetY = { -it }) + fadeIn() + expandVertically(),
|
enter = expandVertically(expandFrom = Alignment.Top) + fadeIn(),
|
||||||
exit = slideOutVertically(targetOffsetY = { -it }) + fadeOut() + shrinkVertically(),
|
exit = shrinkVertically(shrinkTowards = Alignment.Top) + fadeOut(),
|
||||||
) {
|
) {
|
||||||
Surface(
|
Surface(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
color = MaterialTheme.colorScheme.surfaceContainer,
|
color = MaterialTheme.colorScheme.surfaceContainer,
|
||||||
shadowElevation = 4.dp,
|
tonalElevation = 2.dp,
|
||||||
) {
|
) {
|
||||||
Row(
|
Row(
|
||||||
modifier =
|
modifier =
|
||||||
@@ -436,6 +432,7 @@ fun EditProfileContentScreen(
|
|||||||
modifier =
|
modifier =
|
||||||
Modifier
|
Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
|
.clipToBounds()
|
||||||
.weight(1f),
|
.weight(1f),
|
||||||
) {
|
) {
|
||||||
// Editor
|
// Editor
|
||||||
@@ -829,8 +826,6 @@ fun EditProfileContentScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Unsaved changes dialog
|
// Unsaved changes dialog
|
||||||
if (showUnsavedChangesDialog) {
|
if (showUnsavedChangesDialog) {
|
||||||
AlertDialog(
|
AlertDialog(
|
||||||
|
|||||||
@@ -0,0 +1,185 @@
|
|||||||
|
package io.nekohasekai.sfa.compose.screen.profile
|
||||||
|
|
||||||
|
import androidx.compose.animation.AnimatedContentTransitionScope
|
||||||
|
import androidx.compose.animation.core.tween
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
|
import androidx.navigation.NavType
|
||||||
|
import androidx.navigation.compose.NavHost
|
||||||
|
import androidx.navigation.compose.composable
|
||||||
|
import androidx.navigation.compose.rememberNavController
|
||||||
|
import androidx.navigation.navArgument
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun EditProfileRoute(
|
||||||
|
profileId: Long,
|
||||||
|
onNavigateBack: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
if (profileId == -1L) {
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
onNavigateBack()
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val navController = rememberNavController()
|
||||||
|
val sharedViewModel: EditProfileViewModel = viewModel()
|
||||||
|
|
||||||
|
LaunchedEffect(profileId) {
|
||||||
|
sharedViewModel.loadProfile(profileId)
|
||||||
|
}
|
||||||
|
|
||||||
|
NavHost(
|
||||||
|
navController = navController,
|
||||||
|
startDestination = "edit_profile",
|
||||||
|
modifier = modifier,
|
||||||
|
) {
|
||||||
|
composable(
|
||||||
|
route = "edit_profile",
|
||||||
|
enterTransition = {
|
||||||
|
slideIntoContainer(
|
||||||
|
AnimatedContentTransitionScope.SlideDirection.Left,
|
||||||
|
animationSpec = tween(300),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
exitTransition = {
|
||||||
|
slideOutOfContainer(
|
||||||
|
AnimatedContentTransitionScope.SlideDirection.Left,
|
||||||
|
animationSpec = tween(300),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
popEnterTransition = {
|
||||||
|
slideIntoContainer(
|
||||||
|
AnimatedContentTransitionScope.SlideDirection.Right,
|
||||||
|
animationSpec = tween(300),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
popExitTransition = {
|
||||||
|
slideOutOfContainer(
|
||||||
|
AnimatedContentTransitionScope.SlideDirection.Right,
|
||||||
|
animationSpec = tween(300),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
EditProfileScreen(
|
||||||
|
profileId = profileId,
|
||||||
|
onNavigateBack = onNavigateBack,
|
||||||
|
onNavigateToIconSelection = { currentIconId ->
|
||||||
|
navController.navigate("icon_selection/${currentIconId ?: "null"}") {
|
||||||
|
launchSingleTop = true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onNavigateToEditContent = { profileName, isReadOnly ->
|
||||||
|
navController.navigate("edit_content/$profileName/$isReadOnly") {
|
||||||
|
launchSingleTop = true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
viewModel = sharedViewModel,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
composable(
|
||||||
|
route = "icon_selection/{currentIconId}",
|
||||||
|
arguments =
|
||||||
|
listOf(
|
||||||
|
navArgument("currentIconId") {
|
||||||
|
type = NavType.StringType
|
||||||
|
nullable = true
|
||||||
|
},
|
||||||
|
),
|
||||||
|
enterTransition = {
|
||||||
|
slideIntoContainer(
|
||||||
|
AnimatedContentTransitionScope.SlideDirection.Left,
|
||||||
|
animationSpec = tween(300),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
exitTransition = {
|
||||||
|
slideOutOfContainer(
|
||||||
|
AnimatedContentTransitionScope.SlideDirection.Left,
|
||||||
|
animationSpec = tween(300),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
popEnterTransition = {
|
||||||
|
slideIntoContainer(
|
||||||
|
AnimatedContentTransitionScope.SlideDirection.Right,
|
||||||
|
animationSpec = tween(300),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
popExitTransition = {
|
||||||
|
slideOutOfContainer(
|
||||||
|
AnimatedContentTransitionScope.SlideDirection.Right,
|
||||||
|
animationSpec = tween(300),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
) { backStackEntry ->
|
||||||
|
val currentIconId =
|
||||||
|
backStackEntry.arguments?.getString("currentIconId")
|
||||||
|
?.takeIf { it != "null" }
|
||||||
|
|
||||||
|
IconSelectionScreen(
|
||||||
|
currentIconId = currentIconId,
|
||||||
|
onIconSelected = { iconId ->
|
||||||
|
sharedViewModel.updateIcon(iconId)
|
||||||
|
navController.popBackStack("edit_profile", inclusive = false)
|
||||||
|
},
|
||||||
|
onNavigateBack = {
|
||||||
|
navController.popBackStack("edit_profile", inclusive = false)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
composable(
|
||||||
|
route = "edit_content/{profileName}/{isReadOnly}",
|
||||||
|
arguments =
|
||||||
|
listOf(
|
||||||
|
navArgument("profileName") {
|
||||||
|
type = NavType.StringType
|
||||||
|
defaultValue = ""
|
||||||
|
},
|
||||||
|
navArgument("isReadOnly") {
|
||||||
|
type = NavType.BoolType
|
||||||
|
defaultValue = false
|
||||||
|
},
|
||||||
|
),
|
||||||
|
enterTransition = {
|
||||||
|
slideIntoContainer(
|
||||||
|
AnimatedContentTransitionScope.SlideDirection.Left,
|
||||||
|
animationSpec = tween(300),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
exitTransition = {
|
||||||
|
slideOutOfContainer(
|
||||||
|
AnimatedContentTransitionScope.SlideDirection.Left,
|
||||||
|
animationSpec = tween(300),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
popEnterTransition = {
|
||||||
|
slideIntoContainer(
|
||||||
|
AnimatedContentTransitionScope.SlideDirection.Right,
|
||||||
|
animationSpec = tween(300),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
popExitTransition = {
|
||||||
|
slideOutOfContainer(
|
||||||
|
AnimatedContentTransitionScope.SlideDirection.Right,
|
||||||
|
animationSpec = tween(300),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
) { backStackEntry ->
|
||||||
|
val profileName = backStackEntry.arguments?.getString("profileName") ?: ""
|
||||||
|
val isReadOnly = backStackEntry.arguments?.getBoolean("isReadOnly") ?: false
|
||||||
|
|
||||||
|
EditProfileContentScreen(
|
||||||
|
profileId = profileId,
|
||||||
|
onNavigateBack = {
|
||||||
|
navController.popBackStack("edit_profile", inclusive = false)
|
||||||
|
},
|
||||||
|
profileName = profileName,
|
||||||
|
isReadOnly = isReadOnly,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,6 +13,7 @@ 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
|
||||||
import androidx.compose.foundation.layout.WindowInsets
|
import androidx.compose.foundation.layout.WindowInsets
|
||||||
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.navigationBars
|
import androidx.compose.foundation.layout.navigationBars
|
||||||
@@ -44,7 +45,6 @@ import androidx.compose.material3.IconButton
|
|||||||
import androidx.compose.material3.LinearProgressIndicator
|
import androidx.compose.material3.LinearProgressIndicator
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.OutlinedTextField
|
import androidx.compose.material3.OutlinedTextField
|
||||||
import androidx.compose.material3.Scaffold
|
|
||||||
import androidx.compose.material3.Surface
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.material3.Switch
|
import androidx.compose.material3.Switch
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
@@ -66,6 +66,8 @@ import androidx.compose.ui.unit.dp
|
|||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
import io.nekohasekai.sfa.R
|
import io.nekohasekai.sfa.R
|
||||||
|
import io.nekohasekai.sfa.compose.base.SelectableMessageDialog
|
||||||
|
import io.nekohasekai.sfa.compose.topbar.OverrideTopBar
|
||||||
import io.nekohasekai.sfa.compose.util.ProfileIcons
|
import io.nekohasekai.sfa.compose.util.ProfileIcons
|
||||||
import io.nekohasekai.sfa.compose.util.RelativeTimeFormatter
|
import io.nekohasekai.sfa.compose.util.RelativeTimeFormatter
|
||||||
import io.nekohasekai.sfa.compose.util.icons.MaterialIconsLibrary
|
import io.nekohasekai.sfa.compose.util.icons.MaterialIconsLibrary
|
||||||
@@ -112,23 +114,13 @@ fun EditProfileScreen(
|
|||||||
|
|
||||||
// Error dialog
|
// Error dialog
|
||||||
if (showErrorDialog) {
|
if (showErrorDialog) {
|
||||||
AlertDialog(
|
SelectableMessageDialog(
|
||||||
onDismissRequest = {
|
title = stringResource(R.string.error_title),
|
||||||
|
message = uiState.errorMessage ?: "",
|
||||||
|
onDismiss = {
|
||||||
showErrorDialog = false
|
showErrorDialog = false
|
||||||
viewModel.clearError()
|
viewModel.clearError()
|
||||||
},
|
},
|
||||||
title = { Text(stringResource(R.string.error_title)) },
|
|
||||||
text = { Text(uiState.errorMessage ?: "") },
|
|
||||||
confirmButton = {
|
|
||||||
TextButton(
|
|
||||||
onClick = {
|
|
||||||
showErrorDialog = false
|
|
||||||
viewModel.clearError()
|
|
||||||
},
|
|
||||||
) {
|
|
||||||
Text(stringResource(R.string.ok))
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -175,8 +167,7 @@ fun EditProfileScreen(
|
|||||||
showUnsavedChangesDialog = true
|
showUnsavedChangesDialog = true
|
||||||
}
|
}
|
||||||
|
|
||||||
Scaffold(
|
OverrideTopBar {
|
||||||
topBar = {
|
|
||||||
TopAppBar(
|
TopAppBar(
|
||||||
title = { Text(stringResource(R.string.title_edit_profile)) },
|
title = { Text(stringResource(R.string.title_edit_profile)) },
|
||||||
navigationIcon = {
|
navigationIcon = {
|
||||||
@@ -192,56 +183,21 @@ fun EditProfileScreen(
|
|||||||
containerColor = MaterialTheme.colorScheme.surface,
|
containerColor = MaterialTheme.colorScheme.surface,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
},
|
}
|
||||||
bottomBar = {
|
|
||||||
AnimatedVisibility(
|
val bottomInset =
|
||||||
visible = uiState.hasChanges,
|
with(LocalDensity.current) {
|
||||||
enter = fadeIn() + expandVertically(),
|
WindowInsets.navigationBars.getBottom(this).toDp()
|
||||||
exit = fadeOut() + shrinkVertically(),
|
}
|
||||||
) {
|
val bottomBarPadding =
|
||||||
Surface(
|
if (uiState.hasChanges) {
|
||||||
modifier = Modifier.fillMaxWidth(),
|
88.dp + bottomInset
|
||||||
color = MaterialTheme.colorScheme.surface,
|
|
||||||
tonalElevation = 3.dp,
|
|
||||||
) {
|
|
||||||
Box(
|
|
||||||
modifier =
|
|
||||||
Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.windowInsetsPadding(WindowInsets.navigationBars)
|
|
||||||
.padding(16.dp),
|
|
||||||
) {
|
|
||||||
Button(
|
|
||||||
onClick = { viewModel.saveChanges() },
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
enabled = !uiState.isSaving && uiState.autoUpdateIntervalError == null,
|
|
||||||
) {
|
|
||||||
if (uiState.isSaving) {
|
|
||||||
CircularProgressIndicator(
|
|
||||||
modifier = Modifier.size(18.dp),
|
|
||||||
strokeWidth = 2.dp,
|
|
||||||
color = MaterialTheme.colorScheme.onPrimary,
|
|
||||||
)
|
|
||||||
} else {
|
} else {
|
||||||
Icon(
|
0.dp
|
||||||
Icons.Default.Save,
|
|
||||||
contentDescription = null,
|
|
||||||
modifier = Modifier.size(18.dp),
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.width(8.dp))
|
|
||||||
Text(stringResource(R.string.save))
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
) { paddingValues ->
|
|
||||||
Box(
|
Box(
|
||||||
modifier =
|
modifier = Modifier.fillMaxSize(),
|
||||||
Modifier
|
|
||||||
.fillMaxSize()
|
|
||||||
.padding(paddingValues),
|
|
||||||
) {
|
) {
|
||||||
// Progress indicator at top (only for initial loading)
|
// Progress indicator at top (only for initial loading)
|
||||||
if (uiState.isLoading) {
|
if (uiState.isLoading) {
|
||||||
@@ -256,7 +212,8 @@ fun EditProfileScreen(
|
|||||||
Modifier
|
Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.verticalScroll(rememberScrollState())
|
.verticalScroll(rememberScrollState())
|
||||||
.padding(16.dp),
|
.padding(16.dp)
|
||||||
|
.padding(bottom = bottomBarPadding),
|
||||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||||
) {
|
) {
|
||||||
// Basic Information Card
|
// Basic Information Card
|
||||||
@@ -560,6 +517,47 @@ fun EditProfileScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
AnimatedVisibility(
|
||||||
|
visible = uiState.hasChanges,
|
||||||
|
enter = fadeIn() + expandVertically(),
|
||||||
|
exit = fadeOut() + shrinkVertically(),
|
||||||
|
modifier = Modifier.align(Alignment.BottomCenter),
|
||||||
|
) {
|
||||||
|
Surface(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
color = MaterialTheme.colorScheme.surface,
|
||||||
|
tonalElevation = 3.dp,
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.windowInsetsPadding(WindowInsets.navigationBars)
|
||||||
|
.padding(16.dp),
|
||||||
|
) {
|
||||||
|
Button(
|
||||||
|
onClick = { viewModel.saveChanges() },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
enabled = !uiState.isSaving && uiState.autoUpdateIntervalError == null,
|
||||||
|
) {
|
||||||
|
if (uiState.isSaving) {
|
||||||
|
CircularProgressIndicator(
|
||||||
|
modifier = Modifier.size(18.dp),
|
||||||
|
strokeWidth = 2.dp,
|
||||||
|
color = MaterialTheme.colorScheme.onPrimary,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Icon(
|
||||||
|
Icons.Default.Save,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(18.dp),
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
|
Text(stringResource(R.string.save))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ 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
|
||||||
import androidx.compose.foundation.layout.WindowInsets
|
import androidx.compose.foundation.layout.WindowInsets
|
||||||
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
import androidx.compose.foundation.layout.aspectRatio
|
import androidx.compose.foundation.layout.aspectRatio
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
@@ -44,7 +45,6 @@ import androidx.compose.material3.Icon
|
|||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.OutlinedTextField
|
import androidx.compose.material3.OutlinedTextField
|
||||||
import androidx.compose.material3.Scaffold
|
|
||||||
import androidx.compose.material3.Surface
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TextButton
|
import androidx.compose.material3.TextButton
|
||||||
@@ -67,6 +67,7 @@ import androidx.compose.ui.text.style.TextAlign
|
|||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import io.nekohasekai.sfa.R
|
import io.nekohasekai.sfa.R
|
||||||
|
import io.nekohasekai.sfa.compose.topbar.OverrideTopBar
|
||||||
import io.nekohasekai.sfa.compose.util.ProfileIcon
|
import io.nekohasekai.sfa.compose.util.ProfileIcon
|
||||||
import io.nekohasekai.sfa.compose.util.icons.IconCategory
|
import io.nekohasekai.sfa.compose.util.icons.IconCategory
|
||||||
import io.nekohasekai.sfa.compose.util.icons.MaterialIconsLibrary
|
import io.nekohasekai.sfa.compose.util.icons.MaterialIconsLibrary
|
||||||
@@ -99,8 +100,7 @@ fun IconSelectionScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Scaffold(
|
OverrideTopBar {
|
||||||
topBar = {
|
|
||||||
TopAppBar(
|
TopAppBar(
|
||||||
title = { Text(stringResource(R.string.select_icon)) },
|
title = { Text(stringResource(R.string.select_icon)) },
|
||||||
navigationIcon = {
|
navigationIcon = {
|
||||||
@@ -147,65 +147,29 @@ fun IconSelectionScreen(
|
|||||||
containerColor = MaterialTheme.colorScheme.surface,
|
containerColor = MaterialTheme.colorScheme.surface,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
},
|
}
|
||||||
bottomBar = {
|
|
||||||
// Footer with current selection info
|
val currentIcon =
|
||||||
currentIconId?.let { id ->
|
currentIconId?.let { id ->
|
||||||
MaterialIconsLibrary.getIconById(id)?.let { icon ->
|
MaterialIconsLibrary.getIconById(id)?.let { icon -> id to icon }
|
||||||
Card(
|
|
||||||
modifier =
|
|
||||||
Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.windowInsetsPadding(WindowInsets.navigationBars)
|
|
||||||
.padding(horizontal = 16.dp, vertical = 8.dp),
|
|
||||||
colors =
|
|
||||||
CardDefaults.cardColors(
|
|
||||||
containerColor = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.3f),
|
|
||||||
),
|
|
||||||
) {
|
|
||||||
Row(
|
|
||||||
modifier =
|
|
||||||
Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(12.dp),
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
imageVector = icon,
|
|
||||||
contentDescription = null,
|
|
||||||
modifier = Modifier.size(24.dp),
|
|
||||||
tint = MaterialTheme.colorScheme.primary,
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.width(12.dp))
|
|
||||||
Column {
|
|
||||||
val iconInfo = MaterialIconsLibrary.getAllIcons().find { it.id == id }
|
|
||||||
Text(
|
|
||||||
text =
|
|
||||||
stringResource(
|
|
||||||
R.string.current_icon_format,
|
|
||||||
iconInfo?.label ?: id,
|
|
||||||
),
|
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
|
||||||
)
|
|
||||||
MaterialIconsLibrary.getCategoryForIcon(id)?.let { category ->
|
|
||||||
Text(
|
|
||||||
text = category,
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
val bottomInset =
|
||||||
|
with(LocalDensity.current) {
|
||||||
|
WindowInsets.navigationBars.getBottom(this).toDp()
|
||||||
}
|
}
|
||||||
|
val bottomBarPadding =
|
||||||
|
if (currentIcon != null) {
|
||||||
|
88.dp + bottomInset
|
||||||
|
} else {
|
||||||
|
0.dp
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
Box(modifier = Modifier.fillMaxSize()) {
|
||||||
}
|
|
||||||
},
|
|
||||||
) { paddingValues ->
|
|
||||||
Column(
|
Column(
|
||||||
modifier =
|
modifier =
|
||||||
Modifier
|
Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.padding(paddingValues),
|
.padding(bottom = bottomBarPadding),
|
||||||
) {
|
) {
|
||||||
// Show search bar with animation
|
// Show search bar with animation
|
||||||
AnimatedVisibility(
|
AnimatedVisibility(
|
||||||
@@ -419,6 +383,55 @@ fun IconSelectionScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
currentIcon?.let { (id, icon) ->
|
||||||
|
Card(
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.align(Alignment.BottomCenter)
|
||||||
|
.windowInsetsPadding(WindowInsets.navigationBars)
|
||||||
|
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||||
|
colors =
|
||||||
|
CardDefaults.cardColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.3f),
|
||||||
|
),
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(12.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = icon,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(24.dp),
|
||||||
|
tint = MaterialTheme.colorScheme.primary,
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(12.dp))
|
||||||
|
Column {
|
||||||
|
val iconInfo = MaterialIconsLibrary.getAllIcons().find { it.id == id }
|
||||||
|
Text(
|
||||||
|
text =
|
||||||
|
stringResource(
|
||||||
|
R.string.current_icon_format,
|
||||||
|
iconInfo?.label ?: id,
|
||||||
|
),
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
)
|
||||||
|
MaterialIconsLibrary.getCategoryForIcon(id)?.let { category ->
|
||||||
|
Text(
|
||||||
|
text = category,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,4 @@
|
|||||||
package io.nekohasekai.sfa.ui.profile
|
package io.nekohasekai.sfa.compose.screen.qrscan
|
||||||
|
|
||||||
import kotlin.math.max
|
import kotlin.math.max
|
||||||
import kotlin.math.min
|
import kotlin.math.min
|
||||||
@@ -16,8 +16,6 @@ import androidx.lifecycle.LifecycleOwner
|
|||||||
import io.nekohasekai.libbox.Libbox
|
import io.nekohasekai.libbox.Libbox
|
||||||
import io.nekohasekai.sfa.qrs.QRSDecoder
|
import io.nekohasekai.sfa.qrs.QRSDecoder
|
||||||
import io.nekohasekai.sfa.qrs.readIntLE
|
import io.nekohasekai.sfa.qrs.readIntLE
|
||||||
import io.nekohasekai.sfa.ui.profile.QRCodeCropArea
|
|
||||||
import io.nekohasekai.sfa.ui.profile.ZxingQRCodeAnalyzer
|
|
||||||
import io.nekohasekai.sfa.vendor.Vendor
|
import io.nekohasekai.sfa.vendor.Vendor
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
package io.nekohasekai.sfa.ui.profile
|
package io.nekohasekai.sfa.compose.screen.qrscan
|
||||||
|
|
||||||
import androidx.camera.core.ImageAnalysis
|
import androidx.camera.core.ImageAnalysis
|
||||||
import androidx.camera.core.ImageProxy
|
import androidx.camera.core.ImageProxy
|
||||||
@@ -20,6 +20,7 @@ import androidx.compose.foundation.rememberScrollState
|
|||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
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.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.Download
|
import androidx.compose.material.icons.outlined.Download
|
||||||
@@ -36,14 +37,17 @@ import androidx.compose.material3.CardDefaults
|
|||||||
import androidx.compose.material3.CircularProgressIndicator
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
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.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
|
||||||
import androidx.compose.material3.RadioButton
|
import androidx.compose.material3.RadioButton
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TextButton
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.material3.TopAppBar
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
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
|
||||||
@@ -62,11 +66,14 @@ import androidx.navigation.NavController
|
|||||||
import io.nekohasekai.sfa.BuildConfig
|
import io.nekohasekai.sfa.BuildConfig
|
||||||
import io.nekohasekai.sfa.R
|
import io.nekohasekai.sfa.R
|
||||||
import io.nekohasekai.sfa.database.Settings
|
import io.nekohasekai.sfa.database.Settings
|
||||||
|
import io.nekohasekai.sfa.compose.topbar.OverrideTopBar
|
||||||
import io.nekohasekai.sfa.compose.component.UpdateAvailableDialog
|
import io.nekohasekai.sfa.compose.component.UpdateAvailableDialog
|
||||||
import io.nekohasekai.sfa.update.UpdateCheckException
|
import io.nekohasekai.sfa.update.UpdateCheckException
|
||||||
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.vendor.Vendor
|
import io.nekohasekai.sfa.vendor.Vendor
|
||||||
|
import io.nekohasekai.sfa.utils.HookStatusClient
|
||||||
|
import io.nekohasekai.sfa.xposed.XposedActivation
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
@@ -75,6 +82,20 @@ import kotlinx.coroutines.withContext
|
|||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun AppSettingsScreen(navController: NavController) {
|
fun AppSettingsScreen(navController: NavController) {
|
||||||
|
OverrideTopBar {
|
||||||
|
TopAppBar(
|
||||||
|
title = { Text(stringResource(R.string.title_app_settings)) },
|
||||||
|
navigationIcon = {
|
||||||
|
IconButton(onClick = { navController.navigateUp() }) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
|
||||||
|
contentDescription = stringResource(R.string.content_description_back),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
val hasUpdate by UpdateState.hasUpdate
|
val hasUpdate by UpdateState.hasUpdate
|
||||||
@@ -87,6 +108,8 @@ fun AppSettingsScreen(navController: NavController) {
|
|||||||
|
|
||||||
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) }
|
||||||
|
val systemHookStatus by HookStatusClient.status.collectAsState()
|
||||||
|
val xposedActivated = systemHookStatus?.active == true || XposedActivation.isActivated(context)
|
||||||
var isMethodAvailable by remember { mutableStateOf(true) }
|
var isMethodAvailable by remember { mutableStateOf(true) }
|
||||||
var autoUpdateEnabled by remember { mutableStateOf(Settings.autoUpdateEnabled) }
|
var autoUpdateEnabled by remember { mutableStateOf(Settings.autoUpdateEnabled) }
|
||||||
var showInstallMethodMenu by remember { mutableStateOf(false) }
|
var showInstallMethodMenu by remember { mutableStateOf(false) }
|
||||||
@@ -98,8 +121,13 @@ fun AppSettingsScreen(navController: NavController) {
|
|||||||
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) }
|
||||||
|
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
HookStatusClient.refresh()
|
||||||
|
}
|
||||||
|
|
||||||
// Re-check method availability when returning from background (e.g., after granting permission)
|
// Re-check method availability when returning from background (e.g., after granting permission)
|
||||||
LifecycleEventEffect(Lifecycle.Event.ON_RESUME) {
|
LifecycleEventEffect(Lifecycle.Event.ON_RESUME) {
|
||||||
|
HookStatusClient.refresh()
|
||||||
if (silentInstallEnabled) {
|
if (silentInstallEnabled) {
|
||||||
scope.launch {
|
scope.launch {
|
||||||
val success = withContext(Dispatchers.IO) {
|
val success = withContext(Dispatchers.IO) {
|
||||||
@@ -216,14 +244,10 @@ fun AppSettingsScreen(navController: NavController) {
|
|||||||
downloadError = null
|
downloadError = null
|
||||||
downloadJob = scope.launch {
|
downloadJob = scope.launch {
|
||||||
try {
|
try {
|
||||||
val result = withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
Vendor.downloadAndInstall(context, updateInfo!!.downloadUrl)
|
Vendor.downloadAndInstall(context, updateInfo!!.downloadUrl)
|
||||||
}
|
}
|
||||||
if (result.isFailure) {
|
|
||||||
downloadError = result.exceptionOrNull()?.message
|
|
||||||
} else {
|
|
||||||
showDownloadDialog = false
|
showDownloadDialog = false
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
downloadError = e.message
|
downloadError = e.message
|
||||||
}
|
}
|
||||||
@@ -473,7 +497,7 @@ fun AppSettingsScreen(navController: NavController) {
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
if (silentInstallEnabled) {
|
if (silentInstallEnabled && !xposedActivated) {
|
||||||
ListItem(
|
ListItem(
|
||||||
headlineContent = {
|
headlineContent = {
|
||||||
Text(
|
Text(
|
||||||
@@ -707,6 +731,76 @@ fun AppSettingsScreen(navController: NavController) {
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if (BuildConfig.DEBUG && Vendor.supportsTrackSelection()) {
|
||||||
|
var isForceDownloading by remember { mutableStateOf(false) }
|
||||||
|
ListItem(
|
||||||
|
headlineContent = {
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.force_download_install),
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
leadingContent = {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Outlined.SystemUpdateAlt,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.error,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
trailingContent = {
|
||||||
|
if (isForceDownloading) {
|
||||||
|
CircularProgressIndicator(
|
||||||
|
modifier = Modifier.size(24.dp),
|
||||||
|
strokeWidth = 2.dp,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.clip(
|
||||||
|
if (hasUpdate) {
|
||||||
|
RoundedCornerShape(0.dp)
|
||||||
|
} else {
|
||||||
|
RoundedCornerShape(bottomStart = 12.dp, bottomEnd = 12.dp)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.clickable(enabled = !isForceDownloading) {
|
||||||
|
isForceDownloading = true
|
||||||
|
scope.launch {
|
||||||
|
try {
|
||||||
|
val latestUpdate = withContext(Dispatchers.IO) {
|
||||||
|
Vendor.forceGetLatestUpdate()
|
||||||
|
}
|
||||||
|
if (latestUpdate != null) {
|
||||||
|
showDownloadDialog = true
|
||||||
|
downloadError = null
|
||||||
|
downloadJob = scope.launch {
|
||||||
|
try {
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
Vendor.downloadAndInstall(context, latestUpdate.downloadUrl)
|
||||||
|
}
|
||||||
|
showDownloadDialog = false
|
||||||
|
} catch (e: Exception) {
|
||||||
|
downloadError = e.message
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
showErrorDialog = R.string.no_updates_available
|
||||||
|
}
|
||||||
|
} catch (_: UpdateCheckException.TrackNotSupported) {
|
||||||
|
showErrorDialog = R.string.update_track_not_supported
|
||||||
|
} catch (_: Exception) {
|
||||||
|
}
|
||||||
|
isForceDownloading = false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
colors =
|
||||||
|
ListItemDefaults.colors(
|
||||||
|
containerColor = Color.Transparent,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
if (hasUpdate && updateInfo != null) {
|
if (hasUpdate && updateInfo != null) {
|
||||||
ListItem(
|
ListItem(
|
||||||
headlineContent = {
|
headlineContent = {
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import android.content.Intent
|
|||||||
import android.provider.DocumentsContract
|
import android.provider.DocumentsContract
|
||||||
import android.widget.Toast
|
import android.widget.Toast
|
||||||
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.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
|
||||||
@@ -24,12 +25,15 @@ 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.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
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
|
||||||
import androidx.compose.material3.Switch
|
import androidx.compose.material3.Switch
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TopAppBar
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
@@ -46,13 +50,29 @@ import androidx.compose.ui.unit.dp
|
|||||||
import androidx.navigation.NavController
|
import androidx.navigation.NavController
|
||||||
import io.nekohasekai.libbox.Libbox
|
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.database.Settings
|
import io.nekohasekai.sfa.database.Settings
|
||||||
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)
|
||||||
@Composable
|
@Composable
|
||||||
fun CoreSettingsScreen(navController: NavController) {
|
fun CoreSettingsScreen(navController: NavController) {
|
||||||
|
OverrideTopBar {
|
||||||
|
TopAppBar(
|
||||||
|
title = { Text(stringResource(R.string.core)) },
|
||||||
|
navigationIcon = {
|
||||||
|
IconButton(onClick = { navController.navigateUp() }) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
|
||||||
|
contentDescription = stringResource(R.string.content_description_back),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
var dataSize by remember { mutableStateOf("") }
|
var dataSize by remember { mutableStateOf("") }
|
||||||
|
|||||||
@@ -0,0 +1,963 @@
|
|||||||
|
package io.nekohasekai.sfa.compose.screen.settings
|
||||||
|
|
||||||
|
import android.content.Intent
|
||||||
|
import android.net.Uri
|
||||||
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
|
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.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.automirrored.outlined.KeyboardArrowRight
|
||||||
|
import androidx.compose.material.icons.filled.RestartAlt
|
||||||
|
import androidx.compose.material.icons.outlined.AppShortcut
|
||||||
|
import androidx.compose.material.icons.outlined.BugReport
|
||||||
|
import androidx.compose.material.icons.outlined.CheckBox
|
||||||
|
import androidx.compose.material.icons.outlined.Code
|
||||||
|
import androidx.compose.material.icons.outlined.FilterAlt
|
||||||
|
import androidx.compose.material.icons.outlined.ViewModule
|
||||||
|
import androidx.compose.material.icons.outlined.WarningAmber
|
||||||
|
import androidx.compose.material3.AlertDialog
|
||||||
|
import androidx.compose.material3.Card
|
||||||
|
import androidx.compose.material3.CardDefaults
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
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.Switch
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.material3.TopAppBar
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
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.alpha
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.core.content.FileProvider
|
||||||
|
import androidx.navigation.NavController
|
||||||
|
import io.nekohasekai.sfa.R
|
||||||
|
import io.nekohasekai.libbox.Libbox
|
||||||
|
import io.nekohasekai.sfa.compose.base.GlobalEventBus
|
||||||
|
import io.nekohasekai.sfa.compose.base.SelectableMessageDialog
|
||||||
|
import io.nekohasekai.sfa.compose.base.UiEvent
|
||||||
|
import io.nekohasekai.sfa.compose.topbar.OverrideTopBar
|
||||||
|
import io.nekohasekai.sfa.database.Settings
|
||||||
|
import io.nekohasekai.sfa.constant.Status
|
||||||
|
import io.nekohasekai.sfa.utils.DetectionResult
|
||||||
|
import io.nekohasekai.sfa.utils.HookModuleUpdateNotifier
|
||||||
|
import io.nekohasekai.sfa.utils.PrivilegeSettingsClient
|
||||||
|
import io.nekohasekai.sfa.utils.HookStatusClient
|
||||||
|
import io.nekohasekai.sfa.utils.VpnDetectionTest
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import java.io.File
|
||||||
|
import java.io.FileInputStream
|
||||||
|
import java.text.SimpleDateFormat
|
||||||
|
import java.util.Date
|
||||||
|
import java.util.Locale
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun PrivilegeSettingsScreen(navController: NavController, serviceStatus: Status = Status.Stopped) {
|
||||||
|
OverrideTopBar {
|
||||||
|
TopAppBar(
|
||||||
|
title = { Text(stringResource(R.string.privilege_settings)) },
|
||||||
|
navigationIcon = {
|
||||||
|
IconButton(onClick = { navController.navigateUp() }) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
|
||||||
|
contentDescription = stringResource(R.string.content_description_back),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
val context = LocalContext.current
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
val systemHookStatus by HookStatusClient.status.collectAsState()
|
||||||
|
var privilegeSettingsEnabled by remember { mutableStateOf(Settings.privilegeSettingsEnabled) }
|
||||||
|
|
||||||
|
var showTestDialog by remember { mutableStateOf(false) }
|
||||||
|
var testResult by remember { mutableStateOf<DetectionResult?>(null) }
|
||||||
|
var isTestRunning by remember { mutableStateOf(false) }
|
||||||
|
var interfaceRenameEnabled by remember { mutableStateOf(Settings.privilegeSettingsInterfaceRenameEnabled) }
|
||||||
|
var interfacePrefix by remember { mutableStateOf(Settings.privilegeSettingsInterfacePrefix) }
|
||||||
|
var showInterfacePrefixDialog by remember { mutableStateOf(false) }
|
||||||
|
var interfacePrefixInput by remember { mutableStateOf(interfacePrefix) }
|
||||||
|
var showExportProgressDialog by remember { mutableStateOf(false) }
|
||||||
|
var exportCancelled by remember { mutableStateOf(false) }
|
||||||
|
var exportError by remember { mutableStateOf<String?>(null) }
|
||||||
|
var showExportSuccessDialog by remember { mutableStateOf(false) }
|
||||||
|
var exportedFile by remember { mutableStateOf<File?>(null) }
|
||||||
|
var showMessageDialog by remember { mutableStateOf(false) }
|
||||||
|
var messageDialogTitle by remember { mutableStateOf("") }
|
||||||
|
var messageDialogMessage by remember { mutableStateOf("") }
|
||||||
|
|
||||||
|
val saveFileLauncher = rememberLauncherForActivityResult(
|
||||||
|
contract = ActivityResultContracts.CreateDocument("application/zip")
|
||||||
|
) { uri ->
|
||||||
|
val file = exportedFile
|
||||||
|
if (uri != null && file != null) {
|
||||||
|
scope.launch(Dispatchers.IO) {
|
||||||
|
try {
|
||||||
|
context.contentResolver.openOutputStream(uri)?.use { output ->
|
||||||
|
FileInputStream(file).use { input ->
|
||||||
|
input.copyTo(output)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e: Throwable) {
|
||||||
|
android.util.Log.e("PrivilegeSettings", "Failed to save file", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
showExportSuccessDialog = false
|
||||||
|
exportedFile = null
|
||||||
|
}
|
||||||
|
|
||||||
|
androidx.compose.runtime.LaunchedEffect(Unit) {
|
||||||
|
HookStatusClient.refresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
val hasPendingDowngrade = HookModuleUpdateNotifier.isDowngrade(systemHookStatus)
|
||||||
|
val hasPendingUpdate = HookModuleUpdateNotifier.isUpgrade(systemHookStatus)
|
||||||
|
val hasPendingChange = hasPendingDowngrade || hasPendingUpdate
|
||||||
|
androidx.compose.runtime.LaunchedEffect(systemHookStatus) {
|
||||||
|
HookModuleUpdateNotifier.maybeNotify(context, systemHookStatus)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showTestDialog) {
|
||||||
|
SelfTestDialog(
|
||||||
|
isRunning = isTestRunning,
|
||||||
|
result = testResult,
|
||||||
|
onDismiss = {
|
||||||
|
showTestDialog = false
|
||||||
|
testResult = null
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (showInterfacePrefixDialog) {
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = { showInterfacePrefixDialog = false },
|
||||||
|
title = { Text(stringResource(R.string.privilege_settings_interface_rename_title)) },
|
||||||
|
text = {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = interfacePrefixInput,
|
||||||
|
onValueChange = { interfacePrefixInput = it },
|
||||||
|
singleLine = true,
|
||||||
|
label = { Text(stringResource(R.string.privilege_settings_interface_prefix)) },
|
||||||
|
)
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(
|
||||||
|
onClick = {
|
||||||
|
val trimmed = interfacePrefixInput.trim()
|
||||||
|
val filtered = buildString(trimmed.length) {
|
||||||
|
for (ch in trimmed) {
|
||||||
|
if (ch.isLetterOrDigit() || ch == '_') {
|
||||||
|
append(ch)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val normalized = if (filtered.isEmpty()) "en" else filtered
|
||||||
|
interfacePrefix = normalized
|
||||||
|
Settings.privilegeSettingsInterfacePrefix = normalized
|
||||||
|
showInterfacePrefixDialog = false
|
||||||
|
scope.launch {
|
||||||
|
val failure =
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
PrivilegeSettingsClient.sync()
|
||||||
|
}
|
||||||
|
if (failure != null) {
|
||||||
|
messageDialogTitle = context.getString(R.string.error_title)
|
||||||
|
messageDialogMessage = failure.message ?: failure.toString()
|
||||||
|
showMessageDialog = true
|
||||||
|
} else if (serviceStatus == Status.Started) {
|
||||||
|
GlobalEventBus.tryEmit(UiEvent.RestartToTakeEffect)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
Text(stringResource(R.string.save))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = { showInterfacePrefixDialog = false }) {
|
||||||
|
Text(stringResource(R.string.cancel))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (showMessageDialog) {
|
||||||
|
SelectableMessageDialog(
|
||||||
|
title = messageDialogTitle,
|
||||||
|
message = messageDialogMessage,
|
||||||
|
onDismiss = { showMessageDialog = false },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (showExportProgressDialog) {
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = {},
|
||||||
|
title = { Text(stringResource(R.string.privilege_settings_export_debug)) },
|
||||||
|
text = {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
CircularProgressIndicator(modifier = Modifier.size(24.dp))
|
||||||
|
Spacer(modifier = Modifier.width(12.dp))
|
||||||
|
Text(
|
||||||
|
if (exportError != null) exportError!!
|
||||||
|
else stringResource(R.string.exporting)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(
|
||||||
|
onClick = {
|
||||||
|
if (exportError != null) {
|
||||||
|
showExportProgressDialog = false
|
||||||
|
exportError = null
|
||||||
|
} else {
|
||||||
|
exportCancelled = true
|
||||||
|
showExportProgressDialog = false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
Text(stringResource(if (exportError != null) R.string.ok else android.R.string.cancel))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (showExportSuccessDialog && exportedFile != null) {
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = {
|
||||||
|
showExportSuccessDialog = false
|
||||||
|
exportedFile = null
|
||||||
|
},
|
||||||
|
title = { Text(stringResource(R.string.privilege_settings_export_debug_complete)) },
|
||||||
|
text = {
|
||||||
|
val file = exportedFile
|
||||||
|
if (file != null) {
|
||||||
|
Text(stringResource(R.string.privilege_settings_export_debug_message, Libbox.formatBytes(file.length())))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(
|
||||||
|
onClick = {
|
||||||
|
val file = exportedFile ?: return@TextButton
|
||||||
|
val uri = FileProvider.getUriForFile(
|
||||||
|
context,
|
||||||
|
"${context.packageName}.cache",
|
||||||
|
file
|
||||||
|
)
|
||||||
|
val intent = Intent(Intent.ACTION_SEND).apply {
|
||||||
|
type = "application/zip"
|
||||||
|
putExtra(Intent.EXTRA_STREAM, uri)
|
||||||
|
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||||
|
}
|
||||||
|
context.startActivity(Intent.createChooser(intent, null))
|
||||||
|
showExportSuccessDialog = false
|
||||||
|
exportedFile = null
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
Text(stringResource(R.string.menu_share))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(
|
||||||
|
onClick = {
|
||||||
|
val file = exportedFile ?: return@TextButton
|
||||||
|
saveFileLauncher.launch(file.name)
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
Text(stringResource(R.string.save))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.background(MaterialTheme.colorScheme.surface)
|
||||||
|
.verticalScroll(rememberScrollState())
|
||||||
|
.padding(vertical = 8.dp),
|
||||||
|
) {
|
||||||
|
val isLsposedActivated = systemHookStatus?.active == true
|
||||||
|
val showLogs = isLsposedActivated && !hasPendingChange
|
||||||
|
val showExportDebug = showLogs
|
||||||
|
val statusShape =
|
||||||
|
if (showLogs || hasPendingChange) {
|
||||||
|
RoundedCornerShape(topStart = 12.dp, topEnd = 12.dp)
|
||||||
|
} else {
|
||||||
|
RoundedCornerShape(12.dp)
|
||||||
|
}
|
||||||
|
val logItemShape =
|
||||||
|
if (showExportDebug) {
|
||||||
|
RoundedCornerShape(0.dp)
|
||||||
|
} else {
|
||||||
|
RoundedCornerShape(bottomStart = 12.dp, bottomEnd = 12.dp)
|
||||||
|
}
|
||||||
|
val statusLabel =
|
||||||
|
when {
|
||||||
|
hasPendingDowngrade -> stringResource(R.string.lsposed_module_pending_downgrade)
|
||||||
|
hasPendingUpdate -> stringResource(R.string.lsposed_module_pending_update)
|
||||||
|
isLsposedActivated -> stringResource(R.string.lsposed_module_activated)
|
||||||
|
else -> stringResource(R.string.lsposed_module_not_activated)
|
||||||
|
}
|
||||||
|
val statusIcon =
|
||||||
|
when {
|
||||||
|
hasPendingDowngrade -> Icons.Outlined.WarningAmber
|
||||||
|
hasPendingUpdate -> Icons.Outlined.WarningAmber
|
||||||
|
isLsposedActivated -> Icons.Outlined.CheckBox
|
||||||
|
else -> Icons.Outlined.WarningAmber
|
||||||
|
}
|
||||||
|
val statusIconTint =
|
||||||
|
when {
|
||||||
|
hasPendingDowngrade -> MaterialTheme.colorScheme.error
|
||||||
|
hasPendingUpdate -> Color(0xFFFFC107)
|
||||||
|
isLsposedActivated -> MaterialTheme.colorScheme.primary
|
||||||
|
else -> MaterialTheme.colorScheme.error
|
||||||
|
}
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.privilege_module_title),
|
||||||
|
style = MaterialTheme.typography.labelLarge,
|
||||||
|
color = MaterialTheme.colorScheme.primary,
|
||||||
|
modifier = Modifier.padding(start = 32.dp, top = 16.dp, bottom = 8.dp),
|
||||||
|
)
|
||||||
|
|
||||||
|
Card(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 16.dp),
|
||||||
|
colors = CardDefaults.cardColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.surfaceContainer,
|
||||||
|
),
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
) {
|
||||||
|
ListItem(
|
||||||
|
headlineContent = {
|
||||||
|
Text(
|
||||||
|
statusLabel,
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
supportingContent = null,
|
||||||
|
leadingContent = {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Outlined.Code,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.primary,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
trailingContent = {
|
||||||
|
Icon(
|
||||||
|
imageVector = statusIcon,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = statusIconTint,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
modifier = Modifier.clip(statusShape),
|
||||||
|
colors = ListItemDefaults.colors(
|
||||||
|
containerColor = Color.Transparent,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
if (showLogs) {
|
||||||
|
ListItem(
|
||||||
|
headlineContent = {
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.privilege_settings_view_logs),
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
leadingContent = {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Outlined.ViewModule,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.primary,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
trailingContent = {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.AutoMirrored.Outlined.KeyboardArrowRight,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.clip(logItemShape)
|
||||||
|
.clickable {
|
||||||
|
navController.navigate("settings/privilege/logs")
|
||||||
|
},
|
||||||
|
colors = ListItemDefaults.colors(
|
||||||
|
containerColor = Color.Transparent,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (showExportDebug) {
|
||||||
|
ListItem(
|
||||||
|
headlineContent = {
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.privilege_settings_export_debug),
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
leadingContent = {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Outlined.BugReport,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.primary,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.clip(RoundedCornerShape(bottomStart = 12.dp, bottomEnd = 12.dp))
|
||||||
|
.clickable {
|
||||||
|
val exportBase = File(context.cacheDir, "debug")
|
||||||
|
if (!exportBase.exists()) {
|
||||||
|
exportBase.mkdirs()
|
||||||
|
}
|
||||||
|
val timestamp =
|
||||||
|
SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format(Date())
|
||||||
|
val outZip = File(exportBase, "sing-box-lsposed-debug-${timestamp}.zip")
|
||||||
|
exportCancelled = false
|
||||||
|
exportError = null
|
||||||
|
showExportProgressDialog = true
|
||||||
|
scope.launch {
|
||||||
|
val result = withContext(Dispatchers.IO) {
|
||||||
|
PrivilegeSettingsClient.exportDebugInfo(outZip.absolutePath)
|
||||||
|
}
|
||||||
|
if (exportCancelled) {
|
||||||
|
outZip.delete()
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
showExportProgressDialog = false
|
||||||
|
val failure = result.error
|
||||||
|
if (failure == null) {
|
||||||
|
exportedFile = outZip
|
||||||
|
showExportSuccessDialog = true
|
||||||
|
} else {
|
||||||
|
messageDialogTitle = context.getString(R.string.error_title)
|
||||||
|
messageDialogMessage = context.getString(
|
||||||
|
R.string.privilege_settings_export_debug_failed,
|
||||||
|
failure
|
||||||
|
)
|
||||||
|
showMessageDialog = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
colors = ListItemDefaults.colors(
|
||||||
|
containerColor = Color.Transparent,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (hasPendingChange) {
|
||||||
|
ListItem(
|
||||||
|
headlineContent = {
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.privilege_module_restart_action),
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
leadingContent = {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.RestartAlt,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.primary,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.clip(RoundedCornerShape(bottomStart = 12.dp, bottomEnd = 12.dp))
|
||||||
|
.clickable {
|
||||||
|
scope.launch {
|
||||||
|
val failure = withContext(Dispatchers.IO) {
|
||||||
|
runCatching {
|
||||||
|
val process = Runtime.getRuntime().exec(
|
||||||
|
arrayOf(
|
||||||
|
"su",
|
||||||
|
"-c",
|
||||||
|
"/system/bin/svc power reboot || /system/bin/reboot",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
val error = process.errorStream.bufferedReader().use { it.readText().trim() }
|
||||||
|
process.inputStream.close()
|
||||||
|
process.outputStream.close()
|
||||||
|
process.errorStream.close()
|
||||||
|
val code = process.waitFor()
|
||||||
|
if (code == 0) {
|
||||||
|
null
|
||||||
|
} else {
|
||||||
|
error.ifBlank { "exit=$code" }
|
||||||
|
}
|
||||||
|
}.getOrElse { it.message ?: "unknown" }
|
||||||
|
}
|
||||||
|
if (failure != null) {
|
||||||
|
val message =
|
||||||
|
if (failure == "unknown" || failure.startsWith("exit=")) {
|
||||||
|
context.getString(R.string.root_access_required)
|
||||||
|
} else {
|
||||||
|
context.getString(R.string.privilege_module_restart_failed, failure)
|
||||||
|
}
|
||||||
|
messageDialogTitle = context.getString(R.string.error_title)
|
||||||
|
messageDialogMessage = message
|
||||||
|
showMessageDialog = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
colors = ListItemDefaults.colors(
|
||||||
|
containerColor = Color.Transparent,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.privilege_settings_hide_title),
|
||||||
|
style = MaterialTheme.typography.labelLarge,
|
||||||
|
color = MaterialTheme.colorScheme.primary,
|
||||||
|
modifier = Modifier.padding(start = 32.dp, top = 24.dp, bottom = 8.dp),
|
||||||
|
)
|
||||||
|
|
||||||
|
val privilegeControlsEnabled = isLsposedActivated && !hasPendingChange
|
||||||
|
val hasManageItem = privilegeSettingsEnabled
|
||||||
|
|
||||||
|
Card(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 16.dp),
|
||||||
|
colors = CardDefaults.cardColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.surfaceContainer,
|
||||||
|
),
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
) {
|
||||||
|
val disabledAlpha = 0.38f
|
||||||
|
ListItem(
|
||||||
|
headlineContent = {
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.enabled),
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
supportingContent = {
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.privilege_settings_hide_description),
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
leadingContent = {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Outlined.FilterAlt,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.primary,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
trailingContent = {
|
||||||
|
Switch(
|
||||||
|
checked = privilegeSettingsEnabled,
|
||||||
|
onCheckedChange = { checked ->
|
||||||
|
privilegeSettingsEnabled = checked
|
||||||
|
scope.launch {
|
||||||
|
val failure =
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
Settings.privilegeSettingsEnabled = checked
|
||||||
|
PrivilegeSettingsClient.sync()
|
||||||
|
}
|
||||||
|
if (failure != null) {
|
||||||
|
messageDialogTitle = context.getString(R.string.error_title)
|
||||||
|
messageDialogMessage = failure.message ?: failure.toString()
|
||||||
|
showMessageDialog = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
enabled = privilegeControlsEnabled,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
modifier = Modifier
|
||||||
|
.alpha(if (privilegeControlsEnabled) 1f else disabledAlpha)
|
||||||
|
.clip(
|
||||||
|
if (hasManageItem) {
|
||||||
|
RoundedCornerShape(topStart = 12.dp, topEnd = 12.dp)
|
||||||
|
} else {
|
||||||
|
RoundedCornerShape(12.dp)
|
||||||
|
}
|
||||||
|
),
|
||||||
|
colors = ListItemDefaults.colors(
|
||||||
|
containerColor = Color.Transparent,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
val manageEnabled = privilegeControlsEnabled && privilegeSettingsEnabled
|
||||||
|
if (hasManageItem) {
|
||||||
|
ListItem(
|
||||||
|
headlineContent = {
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.privilege_settings_hide_manage),
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
leadingContent = {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Outlined.AppShortcut,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.primary,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
trailingContent = {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.AutoMirrored.Outlined.KeyboardArrowRight,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
modifier = Modifier
|
||||||
|
.alpha(if (manageEnabled) 1f else disabledAlpha)
|
||||||
|
.clip(RoundedCornerShape(bottomStart = 12.dp, bottomEnd = 12.dp))
|
||||||
|
.clickable(enabled = manageEnabled) {
|
||||||
|
navController.navigate("settings/privilege/manage")
|
||||||
|
},
|
||||||
|
colors = ListItemDefaults.colors(
|
||||||
|
containerColor = Color.Transparent,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.privilege_settings_interface_rename_title),
|
||||||
|
style = MaterialTheme.typography.labelLarge,
|
||||||
|
color = MaterialTheme.colorScheme.primary,
|
||||||
|
modifier = Modifier.padding(start = 32.dp, top = 24.dp, bottom = 8.dp),
|
||||||
|
)
|
||||||
|
|
||||||
|
Card(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 16.dp),
|
||||||
|
colors = CardDefaults.cardColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.surfaceContainer,
|
||||||
|
),
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
) {
|
||||||
|
val renameControlsEnabled = isLsposedActivated && !hasPendingChange
|
||||||
|
val disabledAlpha = 0.38f
|
||||||
|
ListItem(
|
||||||
|
headlineContent = {
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.enabled),
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
leadingContent = {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Outlined.FilterAlt,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.primary,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
trailingContent = {
|
||||||
|
Switch(
|
||||||
|
checked = interfaceRenameEnabled,
|
||||||
|
onCheckedChange = { checked ->
|
||||||
|
interfaceRenameEnabled = checked
|
||||||
|
scope.launch {
|
||||||
|
val failure =
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
Settings.privilegeSettingsInterfaceRenameEnabled = checked
|
||||||
|
PrivilegeSettingsClient.sync()
|
||||||
|
}
|
||||||
|
if (failure != null) {
|
||||||
|
messageDialogTitle = context.getString(R.string.error_title)
|
||||||
|
messageDialogMessage = failure.message ?: failure.toString()
|
||||||
|
showMessageDialog = true
|
||||||
|
} else if (serviceStatus == Status.Started) {
|
||||||
|
GlobalEventBus.tryEmit(UiEvent.RestartToTakeEffect)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
enabled = renameControlsEnabled,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
modifier = Modifier
|
||||||
|
.alpha(if (renameControlsEnabled) 1f else disabledAlpha)
|
||||||
|
.clip(
|
||||||
|
if (interfaceRenameEnabled) {
|
||||||
|
RoundedCornerShape(topStart = 12.dp, topEnd = 12.dp)
|
||||||
|
} else {
|
||||||
|
RoundedCornerShape(12.dp)
|
||||||
|
}
|
||||||
|
),
|
||||||
|
colors = ListItemDefaults.colors(
|
||||||
|
containerColor = Color.Transparent,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
if (interfaceRenameEnabled) {
|
||||||
|
val prefixEnabled = renameControlsEnabled
|
||||||
|
ListItem(
|
||||||
|
headlineContent = {
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.privilege_settings_interface_prefix),
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
supportingContent = {
|
||||||
|
Text(
|
||||||
|
interfacePrefix,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
leadingContent = {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Outlined.Code,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.primary,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
trailingContent = {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.AutoMirrored.Outlined.KeyboardArrowRight,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
modifier = Modifier
|
||||||
|
.alpha(if (prefixEnabled) 1f else disabledAlpha)
|
||||||
|
.clip(RoundedCornerShape(bottomStart = 12.dp, bottomEnd = 12.dp))
|
||||||
|
.clickable(enabled = prefixEnabled) {
|
||||||
|
interfacePrefixInput = interfacePrefix
|
||||||
|
showInterfacePrefixDialog = true
|
||||||
|
},
|
||||||
|
colors = ListItemDefaults.colors(
|
||||||
|
containerColor = Color.Transparent,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.privilege_settings_vpn_detection_title),
|
||||||
|
style = MaterialTheme.typography.labelLarge,
|
||||||
|
color = MaterialTheme.colorScheme.primary,
|
||||||
|
modifier = Modifier.padding(start = 32.dp, top = 24.dp, bottom = 8.dp),
|
||||||
|
)
|
||||||
|
|
||||||
|
Card(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 16.dp),
|
||||||
|
colors = CardDefaults.cardColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.surfaceContainer,
|
||||||
|
),
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
) {
|
||||||
|
val testEnabled = !hasPendingChange
|
||||||
|
ListItem(
|
||||||
|
headlineContent = {
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.privilege_settings_hide_test),
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
leadingContent = {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Outlined.BugReport,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.primary,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
trailingContent = {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.AutoMirrored.Outlined.KeyboardArrowRight,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
modifier = Modifier
|
||||||
|
.alpha(if (testEnabled) 1f else 0.38f)
|
||||||
|
.clip(RoundedCornerShape(12.dp))
|
||||||
|
.clickable(enabled = testEnabled) {
|
||||||
|
showTestDialog = true
|
||||||
|
isTestRunning = true
|
||||||
|
testResult = null
|
||||||
|
scope.launch {
|
||||||
|
val result = withContext(Dispatchers.IO) {
|
||||||
|
VpnDetectionTest.runDetection(context)
|
||||||
|
}
|
||||||
|
testResult = result
|
||||||
|
isTestRunning = false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
colors = ListItemDefaults.colors(
|
||||||
|
containerColor = Color.Transparent,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun SelfTestDialog(
|
||||||
|
isRunning: Boolean,
|
||||||
|
result: DetectionResult?,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
) {
|
||||||
|
val notDetectedText = stringResource(R.string.privilege_settings_hide_test_not_detected)
|
||||||
|
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
title = {
|
||||||
|
Text(stringResource(R.string.privilege_settings_hide_test_result))
|
||||||
|
},
|
||||||
|
text = {
|
||||||
|
if (isRunning) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(vertical = 24.dp),
|
||||||
|
horizontalArrangement = Arrangement.Center,
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
CircularProgressIndicator()
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.privilege_settings_hide_test_running),
|
||||||
|
modifier = Modifier.padding(start = 16.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else if (result != null) {
|
||||||
|
val frameworkInterfacesText = result.frameworkInterfaces
|
||||||
|
.takeIf { it.isNotEmpty() }
|
||||||
|
?.joinToString(", ")
|
||||||
|
val frameworkProxyText = result.httpProxy?.takeIf { it.isNotBlank() }
|
||||||
|
val frameworkExtraLines = listOfNotNull(frameworkInterfacesText, frameworkProxyText)
|
||||||
|
val nativeInterfacesText = result.nativeInterfaces
|
||||||
|
.takeIf { it.isNotEmpty() }
|
||||||
|
?.joinToString(", ")
|
||||||
|
val nativeExtraLines = listOfNotNull(nativeInterfacesText)
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
) {
|
||||||
|
Column {
|
||||||
|
Text(
|
||||||
|
text = "Framework",
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
|
)
|
||||||
|
if (result.frameworkDetected.isEmpty()) {
|
||||||
|
Text(
|
||||||
|
text = notDetectedText,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = Color(0xFF4CAF50),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Text(
|
||||||
|
text = result.frameworkDetected.joinToString(", "),
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.error,
|
||||||
|
modifier = Modifier.padding(top = 4.dp),
|
||||||
|
)
|
||||||
|
if (frameworkExtraLines.isNotEmpty()) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.padding(top = 4.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
|
) {
|
||||||
|
frameworkExtraLines.forEach { line ->
|
||||||
|
Text(
|
||||||
|
text = line,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.error,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Column {
|
||||||
|
Text(
|
||||||
|
text = "Native",
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
|
)
|
||||||
|
if (!result.nativeDetected) {
|
||||||
|
Text(
|
||||||
|
text = notDetectedText,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = Color(0xFF4CAF50),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Text(
|
||||||
|
text = "getifaddrs()",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.error,
|
||||||
|
modifier = Modifier.padding(top = 4.dp),
|
||||||
|
)
|
||||||
|
if (nativeExtraLines.isNotEmpty()) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.padding(top = 4.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
|
) {
|
||||||
|
nativeExtraLines.forEach { line ->
|
||||||
|
Text(
|
||||||
|
text = line,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.error,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(onClick = onDismiss) {
|
||||||
|
Text(stringResource(R.string.close))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -16,6 +16,7 @@ import androidx.compose.foundation.rememberScrollState
|
|||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
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.outlined.KeyboardArrowRight
|
import androidx.compose.material.icons.automirrored.outlined.KeyboardArrowRight
|
||||||
import androidx.compose.material.icons.outlined.AppShortcut
|
import androidx.compose.material.icons.outlined.AppShortcut
|
||||||
import androidx.compose.material.icons.outlined.FilterList
|
import androidx.compose.material.icons.outlined.FilterList
|
||||||
@@ -26,7 +27,9 @@ import androidx.compose.material3.AlertDialog
|
|||||||
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.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
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
|
||||||
@@ -34,6 +37,7 @@ import androidx.compose.material3.RadioButton
|
|||||||
import androidx.compose.material3.Switch
|
import androidx.compose.material3.Switch
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TextButton
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.material3.TopAppBar
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.DisposableEffect
|
import androidx.compose.runtime.DisposableEffect
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
@@ -47,12 +51,16 @@ 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
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.platform.LocalLifecycleOwner
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.navigation.NavController
|
import androidx.navigation.NavController
|
||||||
|
import androidx.lifecycle.Lifecycle
|
||||||
|
import androidx.lifecycle.LifecycleEventObserver
|
||||||
import io.nekohasekai.sfa.R
|
import io.nekohasekai.sfa.R
|
||||||
|
import io.nekohasekai.sfa.compose.topbar.OverrideTopBar
|
||||||
import io.nekohasekai.sfa.database.Settings
|
import io.nekohasekai.sfa.database.Settings
|
||||||
import io.nekohasekai.sfa.ui.profileoverride.PerAppProxyActivity
|
import io.nekohasekai.sfa.compose.screen.profileoverride.PerAppProxyScanner
|
||||||
import io.nekohasekai.sfa.vendor.PackageQueryManager
|
import io.nekohasekai.sfa.vendor.PackageQueryManager
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.async
|
import kotlinx.coroutines.async
|
||||||
@@ -60,8 +68,23 @@ import kotlinx.coroutines.awaitAll
|
|||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun ProfileOverrideScreen(navController: NavController) {
|
fun ProfileOverrideScreen(navController: NavController) {
|
||||||
|
OverrideTopBar {
|
||||||
|
TopAppBar(
|
||||||
|
title = { Text(stringResource(R.string.profile_override)) },
|
||||||
|
navigationIcon = {
|
||||||
|
IconButton(onClick = { navController.navigateUp() }) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
|
||||||
|
contentDescription = stringResource(R.string.content_description_back),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
|
|
||||||
@@ -73,7 +96,7 @@ fun ProfileOverrideScreen(navController: NavController) {
|
|||||||
var showRootDialog by remember { mutableStateOf(false) }
|
var showRootDialog by remember { mutableStateOf(false) }
|
||||||
var showModeDialog by remember { mutableStateOf(false) }
|
var showModeDialog by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
val needsPrivilegedQuery = PackageQueryManager.needsPrivilegedQuery
|
val showModeSelector = PackageQueryManager.showModeSelector
|
||||||
var packageQueryMode by remember { mutableStateOf(Settings.perAppProxyPackageQueryMode) }
|
var packageQueryMode by remember { mutableStateOf(Settings.perAppProxyPackageQueryMode) }
|
||||||
val useRootMode = packageQueryMode == Settings.PACKAGE_QUERY_MODE_ROOT
|
val useRootMode = packageQueryMode == Settings.PACKAGE_QUERY_MODE_ROOT
|
||||||
|
|
||||||
@@ -82,20 +105,34 @@ fun ProfileOverrideScreen(navController: NavController) {
|
|||||||
val isShizukuPermissionGranted by PackageQueryManager.shizukuPermissionGranted.collectAsState()
|
val isShizukuPermissionGranted by PackageQueryManager.shizukuPermissionGranted.collectAsState()
|
||||||
val isShizukuAvailable = isShizukuBinderReady && isShizukuPermissionGranted
|
val isShizukuAvailable = isShizukuBinderReady && isShizukuPermissionGranted
|
||||||
|
|
||||||
DisposableEffect(needsPrivilegedQuery) {
|
DisposableEffect(showModeSelector) {
|
||||||
if (needsPrivilegedQuery) {
|
if (showModeSelector) {
|
||||||
PackageQueryManager.registerListeners()
|
PackageQueryManager.registerListeners()
|
||||||
}
|
}
|
||||||
onDispose {
|
onDispose {
|
||||||
if (needsPrivilegedQuery) {
|
if (showModeSelector) {
|
||||||
PackageQueryManager.unregisterListeners()
|
PackageQueryManager.unregisterListeners()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val lifecycleOwner = LocalLifecycleOwner.current
|
||||||
|
DisposableEffect(lifecycleOwner, showModeSelector) {
|
||||||
|
if (!showModeSelector) return@DisposableEffect onDispose { }
|
||||||
|
val observer = LifecycleEventObserver { _, event ->
|
||||||
|
if (event == Lifecycle.Event.ON_RESUME) {
|
||||||
|
PackageQueryManager.refreshShizukuState()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
lifecycleOwner.lifecycle.addObserver(observer)
|
||||||
|
onDispose {
|
||||||
|
lifecycleOwner.lifecycle.removeObserver(observer)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Auto-disable per-app proxy if Shizuku authorization is revoked (only when using Shizuku mode)
|
// Auto-disable per-app proxy if Shizuku authorization is revoked (only when using Shizuku mode)
|
||||||
LaunchedEffect(isShizukuAvailable, useRootMode) {
|
LaunchedEffect(isShizukuAvailable, useRootMode) {
|
||||||
if (needsPrivilegedQuery && !useRootMode && !isShizukuAvailable && perAppProxyEnabled) {
|
if (showModeSelector && !useRootMode && !isShizukuAvailable && perAppProxyEnabled) {
|
||||||
perAppProxyEnabled = false
|
perAppProxyEnabled = false
|
||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
Settings.perAppProxyEnabled = false
|
Settings.perAppProxyEnabled = false
|
||||||
@@ -105,7 +142,7 @@ fun ProfileOverrideScreen(navController: NavController) {
|
|||||||
|
|
||||||
// Auto-close dialog and enable feature when Shizuku becomes available
|
// Auto-close dialog and enable feature when Shizuku becomes available
|
||||||
LaunchedEffect(isShizukuAvailable) {
|
LaunchedEffect(isShizukuAvailable) {
|
||||||
if (needsPrivilegedQuery && isShizukuAvailable && showShizukuDialog) {
|
if (showModeSelector && isShizukuAvailable && showShizukuDialog) {
|
||||||
showShizukuDialog = false
|
showShizukuDialog = false
|
||||||
perAppProxyEnabled = true
|
perAppProxyEnabled = true
|
||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
@@ -212,7 +249,7 @@ fun ProfileOverrideScreen(navController: NavController) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Section: Per-App Proxy
|
// Section: Per-App Proxy
|
||||||
val canUsePerAppProxy = if (needsPrivilegedQuery) {
|
val canUsePerAppProxy = if (showModeSelector) {
|
||||||
if (useRootMode) true else isShizukuAvailable
|
if (useRootMode) true else isShizukuAvailable
|
||||||
} else {
|
} else {
|
||||||
true
|
true
|
||||||
@@ -237,7 +274,7 @@ fun ProfileOverrideScreen(navController: NavController) {
|
|||||||
) {
|
) {
|
||||||
Column {
|
Column {
|
||||||
// Mode selector (only when privileged query is needed)
|
// Mode selector (only when privileged query is needed)
|
||||||
if (needsPrivilegedQuery) {
|
if (showModeSelector) {
|
||||||
val modeEnabled = !perAppProxyEnabled
|
val modeEnabled = !perAppProxyEnabled
|
||||||
val disabledAlpha = 0.38f
|
val disabledAlpha = 0.38f
|
||||||
ListItem(
|
ListItem(
|
||||||
@@ -301,7 +338,7 @@ fun ProfileOverrideScreen(navController: NavController) {
|
|||||||
Switch(
|
Switch(
|
||||||
checked = perAppProxyEnabled,
|
checked = perAppProxyEnabled,
|
||||||
onCheckedChange = { checked ->
|
onCheckedChange = { checked ->
|
||||||
if (checked && needsPrivilegedQuery) {
|
if (checked && showModeSelector) {
|
||||||
if (useRootMode) {
|
if (useRootMode) {
|
||||||
showRootDialog = true
|
showRootDialog = true
|
||||||
} else {
|
} else {
|
||||||
@@ -329,7 +366,7 @@ fun ProfileOverrideScreen(navController: NavController) {
|
|||||||
},
|
},
|
||||||
modifier =
|
modifier =
|
||||||
Modifier.clip(
|
Modifier.clip(
|
||||||
if (needsPrivilegedQuery) {
|
if (showModeSelector) {
|
||||||
RoundedCornerShape(0.dp)
|
RoundedCornerShape(0.dp)
|
||||||
} else if (perAppProxyEnabled && canUsePerAppProxy) {
|
} else if (perAppProxyEnabled && canUsePerAppProxy) {
|
||||||
RoundedCornerShape(topStart = 12.dp, topEnd = 12.dp)
|
RoundedCornerShape(topStart = 12.dp, topEnd = 12.dp)
|
||||||
@@ -383,8 +420,7 @@ fun ProfileOverrideScreen(navController: NavController) {
|
|||||||
},
|
},
|
||||||
modifier =
|
modifier =
|
||||||
Modifier.clickable(enabled = manageEnabled) {
|
Modifier.clickable(enabled = manageEnabled) {
|
||||||
val intent = Intent(context, PerAppProxyActivity::class.java)
|
navController.navigate("settings/profile_override/manage")
|
||||||
context.startActivity(intent)
|
|
||||||
},
|
},
|
||||||
colors =
|
colors =
|
||||||
ListItemDefaults.colors(
|
ListItemDefaults.colors(
|
||||||
@@ -674,7 +710,7 @@ private suspend fun scanAllChinaApps(): Set<String> = withContext(Dispatchers.De
|
|||||||
val chinaApps = mutableSetOf<String>()
|
val chinaApps = mutableSetOf<String>()
|
||||||
installedPackages.map { packageInfo ->
|
installedPackages.map { packageInfo ->
|
||||||
async {
|
async {
|
||||||
if (PerAppProxyActivity.scanChinaPackage(packageInfo)) {
|
if (PerAppProxyScanner.scanChinaPackage(packageInfo)) {
|
||||||
synchronized(chinaApps) {
|
synchronized(chinaApps) {
|
||||||
chinaApps.add(packageInfo.packageName)
|
chinaApps.add(packageInfo.packageName)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,18 +19,22 @@ import androidx.compose.foundation.rememberScrollState
|
|||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
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.outlined.BatteryChargingFull
|
import androidx.compose.material.icons.outlined.BatteryChargingFull
|
||||||
import androidx.compose.material.icons.outlined.Memory
|
import androidx.compose.material.icons.outlined.Memory
|
||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Button
|
||||||
import androidx.compose.material3.Card
|
import androidx.compose.material3.Card
|
||||||
import androidx.compose.material3.CardDefaults
|
import androidx.compose.material3.CardDefaults
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
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
|
||||||
import androidx.compose.material3.OutlinedButton
|
import androidx.compose.material3.OutlinedButton
|
||||||
import androidx.compose.material3.Switch
|
import androidx.compose.material3.Switch
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TopAppBar
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
@@ -51,16 +55,32 @@ import io.nekohasekai.sfa.R
|
|||||||
import io.nekohasekai.sfa.bg.ServiceConnection
|
import io.nekohasekai.sfa.bg.ServiceConnection
|
||||||
import io.nekohasekai.sfa.compose.base.GlobalEventBus
|
import io.nekohasekai.sfa.compose.base.GlobalEventBus
|
||||||
import io.nekohasekai.sfa.compose.base.UiEvent
|
import io.nekohasekai.sfa.compose.base.UiEvent
|
||||||
|
import io.nekohasekai.sfa.compose.topbar.OverrideTopBar
|
||||||
import io.nekohasekai.sfa.database.Settings
|
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.Dispatchers
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun ServiceSettingsScreen(
|
fun ServiceSettingsScreen(
|
||||||
navController: NavController,
|
navController: NavController,
|
||||||
serviceConnection: ServiceConnection? = null,
|
serviceConnection: ServiceConnection? = null,
|
||||||
) {
|
) {
|
||||||
|
OverrideTopBar {
|
||||||
|
TopAppBar(
|
||||||
|
title = { Text(stringResource(R.string.service)) },
|
||||||
|
navigationIcon = {
|
||||||
|
IconButton(onClick = { navController.navigateUp() }) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
|
||||||
|
contentDescription = stringResource(R.string.content_description_back),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
// Check battery optimization status
|
// Check battery optimization status
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import androidx.compose.material.icons.outlined.Info
|
|||||||
import androidx.compose.material.icons.outlined.Settings
|
import androidx.compose.material.icons.outlined.Settings
|
||||||
import androidx.compose.material.icons.outlined.SwapHoriz
|
import androidx.compose.material.icons.outlined.SwapHoriz
|
||||||
import androidx.compose.material.icons.outlined.Tune
|
import androidx.compose.material.icons.outlined.Tune
|
||||||
|
import androidx.compose.material.icons.outlined.AdminPanelSettings
|
||||||
import androidx.compose.material3.Badge
|
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
|
||||||
@@ -32,8 +33,10 @@ 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
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TopAppBar
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
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
|
||||||
@@ -48,20 +51,33 @@ import androidx.compose.ui.unit.dp
|
|||||||
import androidx.navigation.NavController
|
import androidx.navigation.NavController
|
||||||
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.topbar.OverrideTopBar
|
||||||
import io.nekohasekai.sfa.database.Settings
|
import io.nekohasekai.sfa.database.Settings
|
||||||
import io.nekohasekai.sfa.update.UpdateState
|
import io.nekohasekai.sfa.update.UpdateState
|
||||||
|
import io.nekohasekai.sfa.utils.HookModuleUpdateNotifier
|
||||||
|
import io.nekohasekai.sfa.utils.HookStatusClient
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun SettingsScreen(navController: NavController) {
|
fun SettingsScreen(navController: NavController) {
|
||||||
|
OverrideTopBar {
|
||||||
|
TopAppBar(
|
||||||
|
title = { Text(stringResource(R.string.title_settings)) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
val hasUpdate by UpdateState.hasUpdate
|
val hasUpdate by UpdateState.hasUpdate
|
||||||
|
val hookStatus by HookStatusClient.status.collectAsState()
|
||||||
|
val hasPendingPrivilegeDowngrade = HookModuleUpdateNotifier.isDowngrade(hookStatus)
|
||||||
|
val hasPendingPrivilegeUpdate = HookModuleUpdateNotifier.isUpgrade(hookStatus)
|
||||||
var isBatteryOptimizationIgnored by remember { mutableStateOf(true) }
|
var isBatteryOptimizationIgnored by remember { mutableStateOf(true) }
|
||||||
|
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(Unit) {
|
||||||
|
HookStatusClient.refresh()
|
||||||
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 =
|
||||||
@@ -183,13 +199,43 @@ fun SettingsScreen(navController: NavController) {
|
|||||||
},
|
},
|
||||||
modifier =
|
modifier =
|
||||||
Modifier
|
Modifier
|
||||||
.clip(RoundedCornerShape(bottomStart = 12.dp, bottomEnd = 12.dp))
|
|
||||||
.clickable { navController.navigate("settings/profile_override") },
|
.clickable { navController.navigate("settings/profile_override") },
|
||||||
colors =
|
colors =
|
||||||
ListItemDefaults.colors(
|
ListItemDefaults.colors(
|
||||||
containerColor = Color.Transparent,
|
containerColor = Color.Transparent,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
ListItem(
|
||||||
|
headlineContent = {
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.privilege_settings),
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
leadingContent = {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Outlined.AdminPanelSettings,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.primary,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
trailingContent = {
|
||||||
|
if (hasPendingPrivilegeDowngrade) {
|
||||||
|
Badge(containerColor = MaterialTheme.colorScheme.error)
|
||||||
|
} else if (hasPendingPrivilegeUpdate) {
|
||||||
|
Badge(containerColor = Color(0xFFFFC107))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.clip(RoundedCornerShape(bottomStart = 12.dp, bottomEnd = 12.dp))
|
||||||
|
.clickable { navController.navigate("settings/privilege") },
|
||||||
|
colors =
|
||||||
|
ListItemDefaults.colors(
|
||||||
|
containerColor = Color.Transparent,
|
||||||
|
),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,301 @@
|
|||||||
|
package io.nekohasekai.sfa.compose.shared
|
||||||
|
|
||||||
|
import android.Manifest
|
||||||
|
import android.content.pm.ApplicationInfo
|
||||||
|
import android.content.pm.PackageInfo
|
||||||
|
import android.content.pm.PackageManager
|
||||||
|
import android.graphics.Bitmap
|
||||||
|
import android.graphics.Canvas
|
||||||
|
import android.graphics.drawable.BitmapDrawable
|
||||||
|
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||||
|
import androidx.compose.foundation.Image
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.combinedClickable
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.ContentCopy
|
||||||
|
import androidx.compose.material.icons.filled.ExpandLess
|
||||||
|
import androidx.compose.material.icons.filled.ExpandMore
|
||||||
|
import androidx.compose.material3.Card
|
||||||
|
import androidx.compose.material3.CardDefaults
|
||||||
|
import androidx.compose.material3.DropdownMenu
|
||||||
|
import androidx.compose.material3.DropdownMenuItem
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Switch
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
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.asImageBitmap
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import io.nekohasekai.sfa.R
|
||||||
|
|
||||||
|
enum class SortMode {
|
||||||
|
NAME,
|
||||||
|
PACKAGE_NAME,
|
||||||
|
UID,
|
||||||
|
INSTALL_TIME,
|
||||||
|
UPDATE_TIME,
|
||||||
|
}
|
||||||
|
|
||||||
|
class PackageCache(
|
||||||
|
private val packageInfo: PackageInfo,
|
||||||
|
private val appInfo: ApplicationInfo,
|
||||||
|
private val packageManager: PackageManager,
|
||||||
|
) {
|
||||||
|
val packageName: String get() = packageInfo.packageName
|
||||||
|
|
||||||
|
val uid: Int get() = packageInfo.applicationInfo!!.uid
|
||||||
|
|
||||||
|
val installTime: Long get() = packageInfo.firstInstallTime
|
||||||
|
val updateTime: Long get() = packageInfo.lastUpdateTime
|
||||||
|
val isSystem: Boolean get() = appInfo.flags and ApplicationInfo.FLAG_SYSTEM == 1
|
||||||
|
val isOffline: Boolean
|
||||||
|
get() = packageInfo.requestedPermissions?.contains(Manifest.permission.INTERNET) != true
|
||||||
|
val isDisabled: Boolean get() = appInfo.flags and ApplicationInfo.FLAG_INSTALLED == 0
|
||||||
|
|
||||||
|
val applicationIcon by lazy {
|
||||||
|
val drawable = appInfo.loadIcon(packageManager)
|
||||||
|
val bitmap =
|
||||||
|
if (drawable is BitmapDrawable) {
|
||||||
|
drawable.bitmap
|
||||||
|
} else {
|
||||||
|
val imageBitmap =
|
||||||
|
Bitmap.createBitmap(
|
||||||
|
drawable.intrinsicWidth.coerceAtLeast(1),
|
||||||
|
drawable.intrinsicHeight.coerceAtLeast(1),
|
||||||
|
Bitmap.Config.ARGB_8888,
|
||||||
|
)
|
||||||
|
val canvas = Canvas(imageBitmap)
|
||||||
|
drawable.setBounds(0, 0, canvas.width, canvas.height)
|
||||||
|
drawable.draw(canvas)
|
||||||
|
imageBitmap
|
||||||
|
}
|
||||||
|
bitmap.asImageBitmap()
|
||||||
|
}
|
||||||
|
|
||||||
|
val applicationLabel by lazy {
|
||||||
|
appInfo.loadLabel(packageManager).toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
val info: PackageInfo get() = packageInfo
|
||||||
|
}
|
||||||
|
|
||||||
|
fun buildDisplayPackages(
|
||||||
|
packages: List<PackageCache>,
|
||||||
|
selectedUids: Set<Int> = emptySet(),
|
||||||
|
selectedFirst: Boolean = false,
|
||||||
|
hideSystemApps: Boolean,
|
||||||
|
hideOfflineApps: Boolean,
|
||||||
|
hideDisabledApps: Boolean,
|
||||||
|
sortMode: SortMode,
|
||||||
|
sortReverse: Boolean,
|
||||||
|
): List<PackageCache> {
|
||||||
|
val displayPackages =
|
||||||
|
packages.filter { packageCache ->
|
||||||
|
if (hideSystemApps && packageCache.isSystem) {
|
||||||
|
return@filter false
|
||||||
|
}
|
||||||
|
if (hideOfflineApps && packageCache.isOffline) {
|
||||||
|
return@filter false
|
||||||
|
}
|
||||||
|
if (hideDisabledApps && packageCache.isDisabled) {
|
||||||
|
return@filter false
|
||||||
|
}
|
||||||
|
true
|
||||||
|
}
|
||||||
|
val sortComparator =
|
||||||
|
Comparator<PackageCache> { left, right ->
|
||||||
|
if (selectedFirst) {
|
||||||
|
val selectedCompare =
|
||||||
|
compareValues(
|
||||||
|
!selectedUids.contains(left.uid),
|
||||||
|
!selectedUids.contains(right.uid),
|
||||||
|
)
|
||||||
|
if (selectedCompare != 0) {
|
||||||
|
return@Comparator selectedCompare
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val value =
|
||||||
|
when (sortMode) {
|
||||||
|
SortMode.NAME -> compareValues(left.applicationLabel, right.applicationLabel)
|
||||||
|
SortMode.PACKAGE_NAME -> compareValues(left.packageName, right.packageName)
|
||||||
|
SortMode.UID -> compareValues(left.uid, right.uid)
|
||||||
|
SortMode.INSTALL_TIME -> compareValues(left.installTime, right.installTime)
|
||||||
|
SortMode.UPDATE_TIME -> compareValues(left.updateTime, right.updateTime)
|
||||||
|
}
|
||||||
|
if (sortReverse) -value else value
|
||||||
|
}
|
||||||
|
return displayPackages.sortedWith(sortComparator)
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalFoundationApi::class)
|
||||||
|
@Composable
|
||||||
|
fun AppSelectionCard(
|
||||||
|
packageCache: PackageCache,
|
||||||
|
selected: Boolean,
|
||||||
|
onToggle: (Boolean) -> Unit,
|
||||||
|
enableCopyActions: Boolean = true,
|
||||||
|
onCopyLabel: (() -> Unit)? = null,
|
||||||
|
onCopyPackage: (() -> Unit)? = null,
|
||||||
|
onCopyUid: (() -> Unit)? = null,
|
||||||
|
) {
|
||||||
|
var showContextMenu by remember { mutableStateOf(false) }
|
||||||
|
var showCopyMenu by remember { mutableStateOf(false) }
|
||||||
|
val cardShape = MaterialTheme.shapes.medium
|
||||||
|
val cardModifier =
|
||||||
|
if (enableCopyActions) {
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clip(cardShape)
|
||||||
|
.combinedClickable(
|
||||||
|
onClick = { onToggle(!selected) },
|
||||||
|
onLongClick = { showContextMenu = true },
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clip(cardShape)
|
||||||
|
.clickable { onToggle(!selected) }
|
||||||
|
}
|
||||||
|
|
||||||
|
Box {
|
||||||
|
Card(
|
||||||
|
modifier = cardModifier,
|
||||||
|
shape = cardShape,
|
||||||
|
colors =
|
||||||
|
CardDefaults.cardColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.surfaceContainerLow,
|
||||||
|
),
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.padding(12.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
) {
|
||||||
|
Image(
|
||||||
|
bitmap = packageCache.applicationIcon,
|
||||||
|
contentDescription = stringResource(R.string.content_description_app_icon),
|
||||||
|
modifier = Modifier.size(40.dp),
|
||||||
|
)
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = packageCache.applicationLabel,
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = "${packageCache.packageName} (${packageCache.uid})",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
softWrap = true,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Switch(
|
||||||
|
checked = selected,
|
||||||
|
onCheckedChange = { onToggle(it) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (enableCopyActions) {
|
||||||
|
DropdownMenu(
|
||||||
|
expanded = showContextMenu,
|
||||||
|
onDismissRequest = {
|
||||||
|
showContextMenu = false
|
||||||
|
showCopyMenu = false
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text(stringResource(R.string.per_app_proxy_action_copy)) },
|
||||||
|
onClick = { showCopyMenu = !showCopyMenu },
|
||||||
|
leadingIcon = {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.ContentCopy,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.primary,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
trailingIcon = {
|
||||||
|
Icon(
|
||||||
|
imageVector =
|
||||||
|
if (showCopyMenu) {
|
||||||
|
Icons.Default.ExpandLess
|
||||||
|
} else {
|
||||||
|
Icons.Default.ExpandMore
|
||||||
|
},
|
||||||
|
contentDescription = null,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if (showCopyMenu) {
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text(stringResource(R.string.profile_name)) },
|
||||||
|
onClick = {
|
||||||
|
showContextMenu = false
|
||||||
|
showCopyMenu = false
|
||||||
|
onCopyLabel?.invoke()
|
||||||
|
},
|
||||||
|
leadingIcon = {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.ContentCopy,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.primary,
|
||||||
|
modifier = Modifier.padding(start = 24.dp),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text(stringResource(R.string.per_app_proxy_action_copy_package_name)) },
|
||||||
|
onClick = {
|
||||||
|
showContextMenu = false
|
||||||
|
showCopyMenu = false
|
||||||
|
onCopyPackage?.invoke()
|
||||||
|
},
|
||||||
|
leadingIcon = {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.ContentCopy,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.primary,
|
||||||
|
modifier = Modifier.padding(start = 24.dp),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text(stringResource(R.string.per_app_proxy_action_copy_uid)) },
|
||||||
|
onClick = {
|
||||||
|
showContextMenu = false
|
||||||
|
showCopyMenu = false
|
||||||
|
onCopyUid?.invoke()
|
||||||
|
},
|
||||||
|
leadingIcon = {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.ContentCopy,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.primary,
|
||||||
|
modifier = Modifier.padding(start = 24.dp),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
package io.nekohasekai.sfa.compose.topbar
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.DisposableEffect
|
||||||
|
import androidx.compose.runtime.MutableState
|
||||||
|
import androidx.compose.runtime.compositionLocalOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberUpdatedState
|
||||||
|
|
||||||
|
internal data class TopBarEntry(
|
||||||
|
val key: Any,
|
||||||
|
val content: @Composable () -> Unit,
|
||||||
|
)
|
||||||
|
|
||||||
|
class TopBarController internal constructor(
|
||||||
|
private val state: MutableState<List<TopBarEntry>>,
|
||||||
|
) {
|
||||||
|
val current: (@Composable () -> Unit)? get() = state.value.lastOrNull()?.content
|
||||||
|
|
||||||
|
fun set(
|
||||||
|
key: Any,
|
||||||
|
content: @Composable () -> Unit,
|
||||||
|
) {
|
||||||
|
state.value = state.value.filterNot { it.key == key } + TopBarEntry(key, content)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun clear(key: Any) {
|
||||||
|
state.value = state.value.filterNot { it.key == key }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val LocalTopBarController = compositionLocalOf<TopBarController> {
|
||||||
|
error("TopBarController not provided")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun OverrideTopBar(content: @Composable () -> Unit) {
|
||||||
|
val controller = LocalTopBarController.current
|
||||||
|
val token = remember { Any() }
|
||||||
|
val currentContent = rememberUpdatedState(content)
|
||||||
|
DisposableEffect(controller, token) {
|
||||||
|
controller.set(token) { currentContent.value() }
|
||||||
|
onDispose { controller.clear(token) }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -20,7 +20,7 @@ object AnsiColorUtils {
|
|||||||
private val logWhite = Color(0xFFECF0F1)
|
private val logWhite = Color(0xFFECF0F1)
|
||||||
|
|
||||||
fun ansiToAnnotatedString(text: String): AnnotatedString {
|
fun ansiToAnnotatedString(text: String): AnnotatedString {
|
||||||
val cleanText = text.replace(ansiRegex, "")
|
val cleanText = stripAnsi(text)
|
||||||
val matches = ansiRegex.findAll(text).toList()
|
val matches = ansiRegex.findAll(text).toList()
|
||||||
|
|
||||||
if (matches.isEmpty()) {
|
if (matches.isEmpty()) {
|
||||||
@@ -65,6 +65,8 @@ object AnsiColorUtils {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun stripAnsi(text: String): String = text.replace(ansiRegex, "")
|
||||||
|
|
||||||
private fun parseAnsiCode(code: String): SpanStyle? {
|
private fun parseAnsiCode(code: String): SpanStyle? {
|
||||||
val colorCodes = code.substringAfter('[').substringBefore('m').split(';')
|
val colorCodes = code.substringAfter('[').substringBefore('m').split(';')
|
||||||
|
|
||||||
|
|||||||
@@ -23,6 +23,11 @@ object SettingsKey {
|
|||||||
|
|
||||||
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_LIST = "hide_settings_list"
|
||||||
|
const val PRIVILEGE_SETTINGS_INTERFACE_RENAME_ENABLED = "hide_settings_interface_rename_enabled"
|
||||||
|
const val PRIVILEGE_SETTINGS_INTERFACE_PREFIX = "hide_settings_interface_prefix"
|
||||||
|
|
||||||
// dashboard
|
// dashboard
|
||||||
const val DASHBOARD_ITEM_ORDER = "dashboard_item_order"
|
const val DASHBOARD_ITEM_ORDER = "dashboard_item_order"
|
||||||
const val DASHBOARD_DISABLED_ITEMS = "dashboard_disabled_items"
|
const val DASHBOARD_DISABLED_ITEMS = "dashboard_disabled_items"
|
||||||
|
|||||||
@@ -92,6 +92,13 @@ object Settings {
|
|||||||
|
|
||||||
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 privilegeSettingsList by dataStore.stringSet(SettingsKey.PRIVILEGE_SETTINGS_LIST) { emptySet() }
|
||||||
|
var privilegeSettingsInterfaceRenameEnabled by dataStore.boolean(
|
||||||
|
SettingsKey.PRIVILEGE_SETTINGS_INTERFACE_RENAME_ENABLED
|
||||||
|
) { false }
|
||||||
|
var privilegeSettingsInterfacePrefix by dataStore.string(SettingsKey.PRIVILEGE_SETTINGS_INTERFACE_PREFIX) { "wlan" }
|
||||||
|
|
||||||
var dashboardItemOrder by dataStore.string(SettingsKey.DASHBOARD_ITEM_ORDER) { "" }
|
var dashboardItemOrder by dataStore.string(SettingsKey.DASHBOARD_ITEM_ORDER) { "" }
|
||||||
var dashboardDisabledItems by dataStore.stringSet(SettingsKey.DASHBOARD_DISABLED_ITEMS) { emptySet() }
|
var dashboardDisabledItems by dataStore.stringSet(SettingsKey.DASHBOARD_DISABLED_ITEMS) { emptySet() }
|
||||||
|
|
||||||
|
|||||||
@@ -1,26 +1,52 @@
|
|||||||
package io.nekohasekai.sfa.ktx
|
package io.nekohasekai.sfa.ktx
|
||||||
|
|
||||||
|
import android.content.ClipData
|
||||||
|
import android.content.ClipboardManager
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import androidx.annotation.StringRes
|
import androidx.annotation.StringRes
|
||||||
|
import android.widget.ScrollView
|
||||||
|
import android.widget.TextView
|
||||||
|
import android.widget.Toast
|
||||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||||
import io.nekohasekai.sfa.R
|
import io.nekohasekai.sfa.R
|
||||||
|
|
||||||
fun Context.errorDialogBuilder(
|
fun Context.errorDialogBuilder(
|
||||||
@StringRes messageId: Int,
|
@StringRes messageId: Int,
|
||||||
): MaterialAlertDialogBuilder {
|
): MaterialAlertDialogBuilder {
|
||||||
return MaterialAlertDialogBuilder(this)
|
return errorDialogBuilder(getString(messageId))
|
||||||
.setTitle(R.string.error_title)
|
|
||||||
.setMessage(messageId)
|
|
||||||
.setPositiveButton(android.R.string.ok, null)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun Context.errorDialogBuilder(message: String): MaterialAlertDialogBuilder {
|
fun Context.errorDialogBuilder(message: String): MaterialAlertDialogBuilder {
|
||||||
|
val contentView = buildSelectableMessageView(message)
|
||||||
return MaterialAlertDialogBuilder(this)
|
return MaterialAlertDialogBuilder(this)
|
||||||
.setTitle(R.string.error_title)
|
.setTitle(R.string.error_title)
|
||||||
.setMessage(message)
|
.setView(contentView)
|
||||||
|
.setNeutralButton(R.string.per_app_proxy_action_copy) { _, _ ->
|
||||||
|
copyToClipboard(message)
|
||||||
|
}
|
||||||
.setPositiveButton(android.R.string.ok, null)
|
.setPositiveButton(android.R.string.ok, null)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun Context.errorDialogBuilder(exception: Throwable): MaterialAlertDialogBuilder {
|
fun Context.errorDialogBuilder(exception: Throwable): MaterialAlertDialogBuilder {
|
||||||
return errorDialogBuilder(exception.localizedMessage ?: exception.toString())
|
return errorDialogBuilder(exception.localizedMessage ?: exception.toString())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun Context.buildSelectableMessageView(message: String): ScrollView {
|
||||||
|
val density = resources.displayMetrics.density
|
||||||
|
val padding = (16 * density).toInt()
|
||||||
|
val textView =
|
||||||
|
TextView(this).apply {
|
||||||
|
text = message
|
||||||
|
setTextIsSelectable(true)
|
||||||
|
setPadding(padding, padding, padding, padding)
|
||||||
|
}
|
||||||
|
return ScrollView(this).apply {
|
||||||
|
addView(textView)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun Context.copyToClipboard(text: String) {
|
||||||
|
val clipboard = getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
|
||||||
|
clipboard.setPrimaryClip(ClipData.newPlainText(getString(R.string.error_title), text))
|
||||||
|
Toast.makeText(this, getString(R.string.copied_to_clipboard), Toast.LENGTH_SHORT).show()
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,813 +0,0 @@
|
|||||||
package io.nekohasekai.sfa.ui.profileoverride
|
|
||||||
|
|
||||||
import android.Manifest
|
|
||||||
import android.annotation.SuppressLint
|
|
||||||
import android.content.pm.ApplicationInfo
|
|
||||||
import android.content.pm.PackageInfo
|
|
||||||
import android.content.pm.PackageManager
|
|
||||||
import android.os.Build
|
|
||||||
import android.os.Bundle
|
|
||||||
import android.util.Log
|
|
||||||
import android.view.Gravity
|
|
||||||
import android.view.LayoutInflater
|
|
||||||
import android.view.Menu
|
|
||||||
import android.view.MenuItem
|
|
||||||
import android.view.ViewGroup
|
|
||||||
import android.widget.Toast
|
|
||||||
import androidx.appcompat.widget.PopupMenu
|
|
||||||
import androidx.appcompat.widget.SearchView
|
|
||||||
import androidx.core.view.ViewCompat
|
|
||||||
import androidx.core.view.WindowInsetsCompat
|
|
||||||
import androidx.core.view.isVisible
|
|
||||||
import androidx.core.view.updatePadding
|
|
||||||
import androidx.lifecycle.lifecycleScope
|
|
||||||
import androidx.recyclerview.widget.RecyclerView
|
|
||||||
import com.android.tools.smali.dexlib2.dexbacked.DexBackedDexFile
|
|
||||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
|
||||||
import io.nekohasekai.sfa.Application
|
|
||||||
import io.nekohasekai.sfa.R
|
|
||||||
import io.nekohasekai.sfa.database.Settings
|
|
||||||
import io.nekohasekai.sfa.databinding.ActivityPerAppProxyBinding
|
|
||||||
import io.nekohasekai.sfa.databinding.DialogProgressbarBinding
|
|
||||||
import io.nekohasekai.sfa.databinding.ViewAppListItemBinding
|
|
||||||
import io.nekohasekai.sfa.ktx.clipboardText
|
|
||||||
import io.nekohasekai.sfa.ui.shared.AbstractActivity
|
|
||||||
import io.nekohasekai.sfa.vendor.PackageQueryManager
|
|
||||||
import io.nekohasekai.sfa.vendor.PrivilegedAccessRequiredException
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.async
|
|
||||||
import kotlinx.coroutines.awaitAll
|
|
||||||
import kotlinx.coroutines.delay
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import kotlinx.coroutines.withContext
|
|
||||||
import java.io.File
|
|
||||||
import java.util.concurrent.atomic.AtomicInteger
|
|
||||||
import java.util.zip.ZipFile
|
|
||||||
|
|
||||||
class PerAppProxyActivity : AbstractActivity<ActivityPerAppProxyBinding>() {
|
|
||||||
enum class SortMode {
|
|
||||||
NAME,
|
|
||||||
PACKAGE_NAME,
|
|
||||||
UID,
|
|
||||||
INSTALL_TIME,
|
|
||||||
UPDATE_TIME,
|
|
||||||
}
|
|
||||||
|
|
||||||
private var proxyMode = Settings.PER_APP_PROXY_INCLUDE
|
|
||||||
private var sortMode = SortMode.NAME
|
|
||||||
private var sortReverse = false
|
|
||||||
private var hideSystemApps = false
|
|
||||||
private var hideOfflineApps = true
|
|
||||||
private var hideDisabledApps = true
|
|
||||||
|
|
||||||
inner class PackageCache(
|
|
||||||
private val packageInfo: PackageInfo,
|
|
||||||
private val appInfo: ApplicationInfo,
|
|
||||||
) {
|
|
||||||
val packageName: String get() = packageInfo.packageName
|
|
||||||
|
|
||||||
val uid get() = packageInfo.applicationInfo!!.uid
|
|
||||||
|
|
||||||
val installTime get() = packageInfo.firstInstallTime
|
|
||||||
val updateTime get() = packageInfo.lastUpdateTime
|
|
||||||
val isSystem get() = appInfo.flags and ApplicationInfo.FLAG_SYSTEM == 1
|
|
||||||
val isOffline get() = packageInfo.requestedPermissions?.contains(Manifest.permission.INTERNET) != true
|
|
||||||
val isDisabled get() = appInfo.flags and ApplicationInfo.FLAG_INSTALLED == 0
|
|
||||||
|
|
||||||
val applicationIcon by lazy {
|
|
||||||
appInfo.loadIcon(packageManager)
|
|
||||||
}
|
|
||||||
|
|
||||||
val applicationLabel by lazy {
|
|
||||||
appInfo.loadLabel(packageManager).toString()
|
|
||||||
}
|
|
||||||
|
|
||||||
val info: PackageInfo get() = packageInfo
|
|
||||||
}
|
|
||||||
|
|
||||||
private lateinit var adapter: ApplicationAdapter
|
|
||||||
private var packages = listOf<PackageCache>()
|
|
||||||
private var displayPackages = listOf<PackageCache>()
|
|
||||||
private var currentPackages = listOf<PackageCache>()
|
|
||||||
private var selectedUIDs = mutableSetOf<Int>()
|
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
|
||||||
super.onCreate(savedInstanceState)
|
|
||||||
|
|
||||||
setTitle(R.string.per_app_proxy)
|
|
||||||
|
|
||||||
ViewCompat.setOnApplyWindowInsetsListener(binding.appList) { view, windowInsets ->
|
|
||||||
val insets = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars())
|
|
||||||
view.updatePadding(bottom = insets.bottom)
|
|
||||||
WindowInsetsCompat.CONSUMED
|
|
||||||
}
|
|
||||||
|
|
||||||
lifecycleScope.launch {
|
|
||||||
withContext(Dispatchers.IO) {
|
|
||||||
proxyMode =
|
|
||||||
if (Settings.perAppProxyMode == Settings.PER_APP_PROXY_INCLUDE) {
|
|
||||||
Settings.PER_APP_PROXY_INCLUDE
|
|
||||||
} else {
|
|
||||||
Settings.PER_APP_PROXY_EXCLUDE
|
|
||||||
}
|
|
||||||
withContext(Dispatchers.Main) {
|
|
||||||
if (proxyMode == Settings.PER_APP_PROXY_INCLUDE) {
|
|
||||||
binding.perAppProxyMode.setText(R.string.per_app_proxy_mode_include_description)
|
|
||||||
} else {
|
|
||||||
binding.perAppProxyMode.setText(R.string.per_app_proxy_mode_exclude_description)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!reloadApplicationList()) {
|
|
||||||
return@withContext
|
|
||||||
}
|
|
||||||
filterApplicationList()
|
|
||||||
withContext(Dispatchers.Main) {
|
|
||||||
adapter = ApplicationAdapter(displayPackages)
|
|
||||||
binding.appList.adapter = adapter
|
|
||||||
delay(500L)
|
|
||||||
binding.progress.isVisible = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun reloadApplicationList(): Boolean {
|
|
||||||
val packageManagerFlags =
|
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
|
|
||||||
PackageManager.GET_PERMISSIONS or PackageManager.MATCH_UNINSTALLED_PACKAGES or
|
|
||||||
PackageManager.GET_ACTIVITIES or PackageManager.GET_SERVICES or
|
|
||||||
PackageManager.GET_RECEIVERS or PackageManager.GET_PROVIDERS
|
|
||||||
} else {
|
|
||||||
@Suppress("DEPRECATION")
|
|
||||||
PackageManager.GET_PERMISSIONS or PackageManager.GET_UNINSTALLED_PACKAGES or
|
|
||||||
PackageManager.GET_ACTIVITIES or PackageManager.GET_SERVICES or
|
|
||||||
PackageManager.GET_RECEIVERS or PackageManager.GET_PROVIDERS
|
|
||||||
}
|
|
||||||
val installedPackages = try {
|
|
||||||
PackageQueryManager.getInstalledPackages(packageManagerFlags)
|
|
||||||
} catch (e: PrivilegedAccessRequiredException) {
|
|
||||||
withContext(Dispatchers.Main) {
|
|
||||||
Toast.makeText(
|
|
||||||
this@PerAppProxyActivity,
|
|
||||||
R.string.privileged_access_required,
|
|
||||||
Toast.LENGTH_LONG
|
|
||||||
).show()
|
|
||||||
finish()
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
val packages = mutableListOf<PackageCache>()
|
|
||||||
for (packageInfo in installedPackages) {
|
|
||||||
if (packageInfo.packageName == packageName) continue
|
|
||||||
val appInfo = packageInfo.applicationInfo ?: continue
|
|
||||||
packages.add(PackageCache(packageInfo, appInfo))
|
|
||||||
}
|
|
||||||
val selectedPackageNames = Settings.perAppProxyList.toMutableSet()
|
|
||||||
val selectedUIDs = mutableSetOf<Int>()
|
|
||||||
for (packageCache in packages) {
|
|
||||||
if (selectedPackageNames.contains(packageCache.packageName)) {
|
|
||||||
selectedUIDs.add(packageCache.uid)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
this.packages = packages
|
|
||||||
this.selectedUIDs = selectedUIDs
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun filterApplicationList(selectedUIDs: Set<Int> = this.selectedUIDs) {
|
|
||||||
val displayPackages = mutableListOf<PackageCache>()
|
|
||||||
for (packageCache in packages) {
|
|
||||||
if (hideSystemApps && packageCache.isSystem) continue
|
|
||||||
if (hideOfflineApps && packageCache.isOffline) continue
|
|
||||||
if (hideDisabledApps && packageCache.isDisabled) continue
|
|
||||||
displayPackages.add(packageCache)
|
|
||||||
}
|
|
||||||
displayPackages.sortWith(
|
|
||||||
compareBy<PackageCache> {
|
|
||||||
!selectedUIDs.contains(it.uid)
|
|
||||||
}.let {
|
|
||||||
if (!sortReverse) {
|
|
||||||
it.thenBy {
|
|
||||||
when (sortMode) {
|
|
||||||
SortMode.NAME -> it.applicationLabel
|
|
||||||
SortMode.PACKAGE_NAME -> it.packageName
|
|
||||||
SortMode.UID -> it.uid
|
|
||||||
SortMode.INSTALL_TIME -> it.installTime
|
|
||||||
SortMode.UPDATE_TIME -> it.updateTime
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
it.thenByDescending {
|
|
||||||
when (sortMode) {
|
|
||||||
SortMode.NAME -> it.applicationLabel
|
|
||||||
SortMode.PACKAGE_NAME -> it.packageName
|
|
||||||
SortMode.UID -> it.uid
|
|
||||||
SortMode.INSTALL_TIME -> it.installTime
|
|
||||||
SortMode.UPDATE_TIME -> it.updateTime
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
this.displayPackages = displayPackages
|
|
||||||
this.currentPackages = displayPackages
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun updateApplicationSelection(
|
|
||||||
packageCache: PackageCache,
|
|
||||||
selected: Boolean,
|
|
||||||
) {
|
|
||||||
val performed =
|
|
||||||
if (selected) {
|
|
||||||
selectedUIDs.add(packageCache.uid)
|
|
||||||
} else {
|
|
||||||
selectedUIDs.remove(packageCache.uid)
|
|
||||||
}
|
|
||||||
if (!performed) return
|
|
||||||
currentPackages.forEachIndexed { index, it ->
|
|
||||||
if (it.uid == packageCache.uid) {
|
|
||||||
adapter.notifyItemChanged(index, PayloadUpdateSelection(selected))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
saveSelectedApplications()
|
|
||||||
}
|
|
||||||
|
|
||||||
data class PayloadUpdateSelection(val selected: Boolean)
|
|
||||||
|
|
||||||
inner class ApplicationAdapter(private var applicationList: List<PackageCache>) :
|
|
||||||
RecyclerView.Adapter<ApplicationViewHolder>() {
|
|
||||||
@SuppressLint("NotifyDataSetChanged")
|
|
||||||
fun setApplicationList(applicationList: List<PackageCache>) {
|
|
||||||
this.applicationList = applicationList
|
|
||||||
notifyDataSetChanged()
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onCreateViewHolder(
|
|
||||||
parent: ViewGroup,
|
|
||||||
viewType: Int,
|
|
||||||
): ApplicationViewHolder {
|
|
||||||
return ApplicationViewHolder(
|
|
||||||
ViewAppListItemBinding.inflate(
|
|
||||||
LayoutInflater.from(parent.context),
|
|
||||||
parent,
|
|
||||||
false,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun getItemCount(): Int {
|
|
||||||
return applicationList.size
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onBindViewHolder(
|
|
||||||
holder: ApplicationViewHolder,
|
|
||||||
position: Int,
|
|
||||||
) {
|
|
||||||
holder.bind(applicationList[position])
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onBindViewHolder(
|
|
||||||
holder: ApplicationViewHolder,
|
|
||||||
position: Int,
|
|
||||||
payloads: MutableList<Any>,
|
|
||||||
) {
|
|
||||||
if (payloads.isEmpty()) {
|
|
||||||
onBindViewHolder(holder, position)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
payloads.forEach {
|
|
||||||
when (it) {
|
|
||||||
is PayloadUpdateSelection -> holder.updateSelection(it.selected)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
inner class ApplicationViewHolder(
|
|
||||||
private val binding: ViewAppListItemBinding,
|
|
||||||
) : RecyclerView.ViewHolder(binding.root) {
|
|
||||||
@SuppressLint("SetTextI18n")
|
|
||||||
fun bind(packageCache: PackageCache) {
|
|
||||||
binding.appIcon.setImageDrawable(packageCache.applicationIcon)
|
|
||||||
binding.applicationLabel.text = packageCache.applicationLabel
|
|
||||||
binding.packageName.text = "${packageCache.packageName} (${packageCache.uid})"
|
|
||||||
binding.selected.isChecked = selectedUIDs.contains(packageCache.uid)
|
|
||||||
binding.root.setOnClickListener {
|
|
||||||
updateApplicationSelection(packageCache, !binding.selected.isChecked)
|
|
||||||
}
|
|
||||||
binding.root.setOnLongClickListener {
|
|
||||||
val popup = PopupMenu(it.context, it)
|
|
||||||
popup.setForceShowIcon(true)
|
|
||||||
popup.gravity = Gravity.END
|
|
||||||
popup.menuInflater.inflate(R.menu.app_menu, popup.menu)
|
|
||||||
popup.setOnMenuItemClickListener {
|
|
||||||
when (it.itemId) {
|
|
||||||
R.id.action_copy_application_label -> {
|
|
||||||
clipboardText = packageCache.applicationLabel
|
|
||||||
true
|
|
||||||
}
|
|
||||||
|
|
||||||
R.id.action_copy_package_name -> {
|
|
||||||
clipboardText = packageCache.packageName
|
|
||||||
true
|
|
||||||
}
|
|
||||||
|
|
||||||
R.id.action_copy_uid -> {
|
|
||||||
clipboardText = packageCache.uid.toString()
|
|
||||||
true
|
|
||||||
}
|
|
||||||
|
|
||||||
else -> false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
popup.show()
|
|
||||||
true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun updateSelection(selected: Boolean) {
|
|
||||||
binding.selected.isChecked = selected
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun searchApplications(searchText: String) {
|
|
||||||
currentPackages =
|
|
||||||
if (searchText.isEmpty()) {
|
|
||||||
displayPackages
|
|
||||||
} else {
|
|
||||||
displayPackages.filter {
|
|
||||||
it.applicationLabel.contains(
|
|
||||||
searchText, ignoreCase = true,
|
|
||||||
) ||
|
|
||||||
it.packageName.contains(
|
|
||||||
searchText, ignoreCase = true,
|
|
||||||
) || it.uid.toString().contains(searchText)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
adapter.setApplicationList(currentPackages)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onCreateOptionsMenu(menu: Menu?): Boolean {
|
|
||||||
menuInflater.inflate(R.menu.per_app_menu, menu)
|
|
||||||
|
|
||||||
if (menu != null) {
|
|
||||||
val searchView = menu.findItem(R.id.action_search).actionView as SearchView
|
|
||||||
searchView.setOnQueryTextListener(
|
|
||||||
object : SearchView.OnQueryTextListener {
|
|
||||||
override fun onQueryTextSubmit(query: String): Boolean {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onQueryTextChange(newText: String): Boolean {
|
|
||||||
searchApplications(newText)
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
searchView.setOnCloseListener {
|
|
||||||
searchApplications("")
|
|
||||||
true
|
|
||||||
}
|
|
||||||
when (proxyMode) {
|
|
||||||
Settings.PER_APP_PROXY_INCLUDE -> {
|
|
||||||
menu.findItem(R.id.action_mode_include).isChecked = true
|
|
||||||
}
|
|
||||||
|
|
||||||
Settings.PER_APP_PROXY_EXCLUDE -> {
|
|
||||||
menu.findItem(R.id.action_mode_exclude).isChecked = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
when (sortMode) {
|
|
||||||
SortMode.NAME -> {
|
|
||||||
menu.findItem(R.id.action_sort_by_name).isChecked = true
|
|
||||||
}
|
|
||||||
|
|
||||||
SortMode.PACKAGE_NAME -> {
|
|
||||||
menu.findItem(R.id.action_sort_by_package_name).isChecked = true
|
|
||||||
}
|
|
||||||
|
|
||||||
SortMode.UID -> {
|
|
||||||
menu.findItem(R.id.action_sort_by_uid).isChecked = true
|
|
||||||
}
|
|
||||||
|
|
||||||
SortMode.INSTALL_TIME -> {
|
|
||||||
menu.findItem(R.id.action_sort_by_install_time).isChecked = true
|
|
||||||
}
|
|
||||||
|
|
||||||
SortMode.UPDATE_TIME -> {
|
|
||||||
menu.findItem(R.id.action_sort_by_update_time).isChecked = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
menu.findItem(R.id.action_sort_reverse).isChecked = sortReverse
|
|
||||||
menu.findItem(R.id.action_hide_system_apps).isChecked = hideSystemApps
|
|
||||||
menu.findItem(R.id.action_hide_offline_apps).isChecked = hideOfflineApps
|
|
||||||
menu.findItem(R.id.action_hide_disabled_apps).isChecked = hideDisabledApps
|
|
||||||
}
|
|
||||||
|
|
||||||
return super.onCreateOptionsMenu(menu)
|
|
||||||
}
|
|
||||||
|
|
||||||
@SuppressLint("NotifyDataSetChanged")
|
|
||||||
override fun onOptionsItemSelected(item: MenuItem): Boolean {
|
|
||||||
when (item.itemId) {
|
|
||||||
R.id.action_mode_include -> {
|
|
||||||
item.isChecked = true
|
|
||||||
proxyMode = Settings.PER_APP_PROXY_INCLUDE
|
|
||||||
binding.perAppProxyMode.setText(R.string.per_app_proxy_mode_include_description)
|
|
||||||
lifecycleScope.launch {
|
|
||||||
Settings.perAppProxyMode = Settings.PER_APP_PROXY_INCLUDE
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
R.id.action_mode_exclude -> {
|
|
||||||
item.isChecked = true
|
|
||||||
proxyMode = Settings.PER_APP_PROXY_EXCLUDE
|
|
||||||
binding.perAppProxyMode.setText(R.string.per_app_proxy_mode_exclude_description)
|
|
||||||
lifecycleScope.launch {
|
|
||||||
Settings.perAppProxyMode = Settings.PER_APP_PROXY_EXCLUDE
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
R.id.action_sort_by_name -> {
|
|
||||||
item.isChecked = true
|
|
||||||
sortMode = SortMode.NAME
|
|
||||||
filterApplicationList()
|
|
||||||
adapter.setApplicationList(currentPackages)
|
|
||||||
}
|
|
||||||
|
|
||||||
R.id.action_sort_by_package_name -> {
|
|
||||||
item.isChecked = true
|
|
||||||
sortMode = SortMode.PACKAGE_NAME
|
|
||||||
filterApplicationList()
|
|
||||||
adapter.setApplicationList(currentPackages)
|
|
||||||
}
|
|
||||||
|
|
||||||
R.id.action_sort_by_uid -> {
|
|
||||||
item.isChecked = true
|
|
||||||
sortMode = SortMode.UID
|
|
||||||
filterApplicationList()
|
|
||||||
adapter.setApplicationList(currentPackages)
|
|
||||||
}
|
|
||||||
|
|
||||||
R.id.action_sort_by_install_time -> {
|
|
||||||
item.isChecked = true
|
|
||||||
sortMode = SortMode.INSTALL_TIME
|
|
||||||
filterApplicationList()
|
|
||||||
adapter.setApplicationList(currentPackages)
|
|
||||||
}
|
|
||||||
|
|
||||||
R.id.action_sort_by_update_time -> {
|
|
||||||
item.isChecked = true
|
|
||||||
sortMode = SortMode.UPDATE_TIME
|
|
||||||
filterApplicationList()
|
|
||||||
adapter.setApplicationList(currentPackages)
|
|
||||||
}
|
|
||||||
|
|
||||||
R.id.action_sort_reverse -> {
|
|
||||||
item.isChecked = !item.isChecked
|
|
||||||
sortReverse = item.isChecked
|
|
||||||
filterApplicationList()
|
|
||||||
adapter.setApplicationList(currentPackages)
|
|
||||||
}
|
|
||||||
|
|
||||||
R.id.action_hide_system_apps -> {
|
|
||||||
item.isChecked = !item.isChecked
|
|
||||||
hideSystemApps = item.isChecked
|
|
||||||
filterApplicationList()
|
|
||||||
adapter.setApplicationList(currentPackages)
|
|
||||||
}
|
|
||||||
|
|
||||||
R.id.action_hide_offline_apps -> {
|
|
||||||
item.isChecked = !item.isChecked
|
|
||||||
hideOfflineApps = item.isChecked
|
|
||||||
filterApplicationList()
|
|
||||||
adapter.setApplicationList(currentPackages)
|
|
||||||
}
|
|
||||||
|
|
||||||
R.id.action_hide_disabled_apps -> {
|
|
||||||
item.isChecked = !item.isChecked
|
|
||||||
hideDisabledApps = item.isChecked
|
|
||||||
filterApplicationList()
|
|
||||||
adapter.setApplicationList(currentPackages)
|
|
||||||
}
|
|
||||||
|
|
||||||
R.id.action_select_all -> {
|
|
||||||
val selectedUIDs = mutableSetOf<Int>()
|
|
||||||
currentPackages.forEach {
|
|
||||||
selectedUIDs.add(it.uid)
|
|
||||||
}
|
|
||||||
lifecycleScope.launch {
|
|
||||||
postSaveSelectedApplications(selectedUIDs)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
R.id.action_deselect_all -> {
|
|
||||||
lifecycleScope.launch {
|
|
||||||
postSaveSelectedApplications(mutableSetOf())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
R.id.action_export -> {
|
|
||||||
lifecycleScope.launch {
|
|
||||||
val packageList = mutableListOf<String>()
|
|
||||||
for (packageCache in packages) {
|
|
||||||
if (selectedUIDs.contains(packageCache.uid)) {
|
|
||||||
packageList.add(packageCache.packageName)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
clipboardText = packageList.joinToString("\n")
|
|
||||||
withContext(Dispatchers.Main) {
|
|
||||||
Toast.makeText(
|
|
||||||
this@PerAppProxyActivity,
|
|
||||||
R.string.toast_copied_to_clipboard,
|
|
||||||
Toast.LENGTH_SHORT,
|
|
||||||
).show()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
R.id.action_import -> {
|
|
||||||
val packageNames =
|
|
||||||
clipboardText?.split("\n")?.distinct()
|
|
||||||
?.takeIf { it.isNotEmpty() && it[0].isNotEmpty() }
|
|
||||||
if (packageNames.isNullOrEmpty()) {
|
|
||||||
Toast.makeText(
|
|
||||||
this@PerAppProxyActivity,
|
|
||||||
R.string.toast_clipboard_empty,
|
|
||||||
Toast.LENGTH_SHORT,
|
|
||||||
).show()
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
val selectedUIDs = mutableSetOf<Int>()
|
|
||||||
for (packageCache in packages) {
|
|
||||||
if (packageNames.contains(packageCache.packageName)) {
|
|
||||||
selectedUIDs.add(packageCache.uid)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
lifecycleScope.launch {
|
|
||||||
postSaveSelectedApplications(selectedUIDs)
|
|
||||||
withContext(Dispatchers.Main) {
|
|
||||||
Toast.makeText(
|
|
||||||
this@PerAppProxyActivity,
|
|
||||||
R.string.toast_imported_from_clipboard,
|
|
||||||
Toast.LENGTH_SHORT,
|
|
||||||
).show()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
R.id.action_scan_china_apps -> {
|
|
||||||
scanChinaApps()
|
|
||||||
}
|
|
||||||
|
|
||||||
else -> return super.onOptionsItemSelected(item)
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
@SuppressLint("NotifyDataSetChanged")
|
|
||||||
private fun scanChinaApps() {
|
|
||||||
val binding = DialogProgressbarBinding.inflate(layoutInflater)
|
|
||||||
binding.progress.max = currentPackages.size
|
|
||||||
binding.message.setText(R.string.message_scanning)
|
|
||||||
val dialogTheme =
|
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R && resources.configuration.isNightModeActive) {
|
|
||||||
com.google.android.material.R.style.Theme_MaterialComponents_Dialog
|
|
||||||
} else {
|
|
||||||
com.google.android.material.R.style.Theme_MaterialComponents_Light_Dialog
|
|
||||||
}
|
|
||||||
val progress =
|
|
||||||
MaterialAlertDialogBuilder(
|
|
||||||
this,
|
|
||||||
dialogTheme,
|
|
||||||
).setView(binding.root).setCancelable(false).create()
|
|
||||||
progress.show()
|
|
||||||
lifecycleScope.launch {
|
|
||||||
val startTime = System.currentTimeMillis()
|
|
||||||
val foundApps =
|
|
||||||
withContext(Dispatchers.Default) {
|
|
||||||
mutableMapOf<String, PackageCache>().also { foundApps ->
|
|
||||||
val progressInt = AtomicInteger()
|
|
||||||
currentPackages.map { it ->
|
|
||||||
async {
|
|
||||||
if (scanChinaPackage(it.info)) {
|
|
||||||
foundApps[it.packageName] = it
|
|
||||||
}
|
|
||||||
runOnUiThread {
|
|
||||||
binding.progress.progress = progressInt.addAndGet(1)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}.awaitAll()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Log.d(
|
|
||||||
"PerAppProxyActivity",
|
|
||||||
"Scan China apps took ${(System.currentTimeMillis() - startTime).toDouble() / 1000}s",
|
|
||||||
)
|
|
||||||
withContext(Dispatchers.Main) {
|
|
||||||
progress.dismiss()
|
|
||||||
if (foundApps.isEmpty()) {
|
|
||||||
MaterialAlertDialogBuilder(this@PerAppProxyActivity).setTitle(R.string.title_scan_result)
|
|
||||||
.setMessage(R.string.message_scan_app_no_apps_found)
|
|
||||||
.setPositiveButton(R.string.ok, null).show()
|
|
||||||
return@withContext
|
|
||||||
}
|
|
||||||
val dialogContent =
|
|
||||||
getString(R.string.message_scan_app_found) + "\n\n" +
|
|
||||||
foundApps.entries.joinToString(
|
|
||||||
"\n",
|
|
||||||
) {
|
|
||||||
"${it.value.applicationLabel} (${it.key})"
|
|
||||||
}
|
|
||||||
MaterialAlertDialogBuilder(this@PerAppProxyActivity).setTitle(R.string.title_scan_result)
|
|
||||||
.setMessage(dialogContent)
|
|
||||||
.setPositiveButton(R.string.per_app_proxy_select) { dialog, _ ->
|
|
||||||
dialog.dismiss()
|
|
||||||
lifecycleScope.launch {
|
|
||||||
val selectedUIDs = selectedUIDs.toMutableSet()
|
|
||||||
foundApps.values.forEach {
|
|
||||||
selectedUIDs.add(it.uid)
|
|
||||||
}
|
|
||||||
postSaveSelectedApplications(selectedUIDs)
|
|
||||||
}
|
|
||||||
}.setNegativeButton(R.string.action_deselect) { dialog, _ ->
|
|
||||||
dialog.dismiss()
|
|
||||||
lifecycleScope.launch {
|
|
||||||
val selectedUIDs = selectedUIDs.toMutableSet()
|
|
||||||
foundApps.values.forEach {
|
|
||||||
selectedUIDs.remove(it.uid)
|
|
||||||
}
|
|
||||||
postSaveSelectedApplications(selectedUIDs)
|
|
||||||
}
|
|
||||||
}.setNeutralButton(android.R.string.cancel, null).show()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@SuppressLint("NotifyDataSetChanged")
|
|
||||||
private suspend fun postSaveSelectedApplications(newUIDs: MutableSet<Int>) {
|
|
||||||
filterApplicationList(newUIDs)
|
|
||||||
withContext(Dispatchers.Main) {
|
|
||||||
selectedUIDs = newUIDs
|
|
||||||
adapter.notifyDataSetChanged()
|
|
||||||
}
|
|
||||||
val packageList =
|
|
||||||
selectedUIDs.mapNotNull { uid ->
|
|
||||||
packages.find { it.uid == uid }?.packageName
|
|
||||||
}
|
|
||||||
Settings.perAppProxyList = packageList.toSet()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun saveSelectedApplications() {
|
|
||||||
lifecycleScope.launch {
|
|
||||||
val packageList =
|
|
||||||
selectedUIDs.mapNotNull { uid ->
|
|
||||||
packages.find { it.uid == uid }?.packageName
|
|
||||||
}
|
|
||||||
Settings.perAppProxyList = packageList.toSet()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
private val skipPrefixList =
|
|
||||||
listOf(
|
|
||||||
"com.google",
|
|
||||||
"com.android.chrome",
|
|
||||||
"com.android.vending",
|
|
||||||
"com.microsoft",
|
|
||||||
"com.apple",
|
|
||||||
"com.zhiliaoapp.musically", // Banned by China
|
|
||||||
"com.android.providers.downloads",
|
|
||||||
)
|
|
||||||
|
|
||||||
private val chinaAppPrefixList =
|
|
||||||
listOf(
|
|
||||||
"com.tencent",
|
|
||||||
"com.alibaba",
|
|
||||||
"com.umeng",
|
|
||||||
"com.qihoo",
|
|
||||||
"com.ali",
|
|
||||||
"com.alipay",
|
|
||||||
"com.amap",
|
|
||||||
"com.sina",
|
|
||||||
"com.weibo",
|
|
||||||
"com.vivo",
|
|
||||||
"com.xiaomi",
|
|
||||||
"com.huawei",
|
|
||||||
"com.taobao",
|
|
||||||
"com.secneo",
|
|
||||||
"s.h.e.l.l",
|
|
||||||
"com.stub",
|
|
||||||
"com.kiwisec",
|
|
||||||
"com.secshell",
|
|
||||||
"com.wrapper",
|
|
||||||
"cn.securitystack",
|
|
||||||
"com.mogosec",
|
|
||||||
"com.secoen",
|
|
||||||
"com.netease",
|
|
||||||
"com.mx",
|
|
||||||
"com.qq.e",
|
|
||||||
"com.baidu",
|
|
||||||
"com.bytedance",
|
|
||||||
"com.bugly",
|
|
||||||
"com.miui",
|
|
||||||
"com.oppo",
|
|
||||||
"com.coloros",
|
|
||||||
"com.iqoo",
|
|
||||||
"com.meizu",
|
|
||||||
"com.gionee",
|
|
||||||
"cn.nubia",
|
|
||||||
"com.oplus",
|
|
||||||
"andes.oplus",
|
|
||||||
"com.unionpay",
|
|
||||||
"cn.wps",
|
|
||||||
)
|
|
||||||
|
|
||||||
private val chinaAppRegex by lazy {
|
|
||||||
("(" + chinaAppPrefixList.joinToString("|").replace(".", "\\.") + ").*").toRegex()
|
|
||||||
}
|
|
||||||
|
|
||||||
fun scanChinaPackage(packageInfo: PackageInfo): Boolean {
|
|
||||||
val packageName = packageInfo.packageName
|
|
||||||
skipPrefixList.forEach {
|
|
||||||
if (packageName == it || packageName.startsWith("$it.")) return false
|
|
||||||
}
|
|
||||||
|
|
||||||
if (packageName.matches(chinaAppRegex)) {
|
|
||||||
Log.d("PerAppProxyActivity", "Match package name: $packageName")
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
val appInfo = packageInfo.applicationInfo ?: return false
|
|
||||||
packageInfo.services?.forEach {
|
|
||||||
if (it.name.matches(chinaAppRegex)) {
|
|
||||||
Log.d("PerAppProxyActivity", "Match service ${it.name} in $packageName")
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
packageInfo.activities?.forEach {
|
|
||||||
if (it.name.matches(chinaAppRegex)) {
|
|
||||||
Log.d("PerAppProxyActivity", "Match activity ${it.name} in $packageName")
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
packageInfo.receivers?.forEach {
|
|
||||||
if (it.name.matches(chinaAppRegex)) {
|
|
||||||
Log.d("PerAppProxyActivity", "Match receiver ${it.name} in $packageName")
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
packageInfo.providers?.forEach {
|
|
||||||
if (it.name.matches(chinaAppRegex)) {
|
|
||||||
Log.d("PerAppProxyActivity", "Match provider ${it.name} in $packageName")
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
ZipFile(File(appInfo.publicSourceDir)).use {
|
|
||||||
for (packageEntry in it.entries()) {
|
|
||||||
if (packageEntry.name.startsWith("firebase-")) return false
|
|
||||||
}
|
|
||||||
for (packageEntry in it.entries()) {
|
|
||||||
if (!(
|
|
||||||
packageEntry.name.startsWith("classes") &&
|
|
||||||
packageEntry.name.endsWith(
|
|
||||||
".dex",
|
|
||||||
)
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if (packageEntry.size > 15000000) {
|
|
||||||
Log.d(
|
|
||||||
"PerAppProxyActivity",
|
|
||||||
"Confirm $packageName due to large dex file",
|
|
||||||
)
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
val input = it.getInputStream(packageEntry).buffered()
|
|
||||||
val dexFile =
|
|
||||||
try {
|
|
||||||
DexBackedDexFile.fromInputStream(null, input)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Log.e("PerAppProxyActivity", "Error reading dex file", e)
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
for (clazz in dexFile.classes) {
|
|
||||||
val clazzName =
|
|
||||||
clazz.type.substring(1, clazz.type.length - 1).replace("/", ".")
|
|
||||||
.replace("$", ".")
|
|
||||||
if (clazzName.matches(chinaAppRegex)) {
|
|
||||||
Log.d("PerAppProxyActivity", "Match $clazzName in $packageName")
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Log.e("PerAppProxyActivity", "Error scanning package $packageName", e)
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,86 +0,0 @@
|
|||||||
package io.nekohasekai.sfa.ui.shared
|
|
||||||
|
|
||||||
import android.content.res.Configuration
|
|
||||||
import android.os.Build
|
|
||||||
import android.os.Bundle
|
|
||||||
import android.view.LayoutInflater
|
|
||||||
import android.view.MenuItem
|
|
||||||
import android.view.WindowManager
|
|
||||||
import androidx.appcompat.app.AppCompatActivity
|
|
||||||
import androidx.appcompat.content.res.AppCompatResources
|
|
||||||
import androidx.core.view.WindowCompat
|
|
||||||
import androidx.viewbinding.ViewBinding
|
|
||||||
import com.google.android.material.appbar.MaterialToolbar
|
|
||||||
import com.google.android.material.color.DynamicColors
|
|
||||||
import io.nekohasekai.sfa.R
|
|
||||||
import io.nekohasekai.sfa.ktx.getAttrColor
|
|
||||||
import io.nekohasekai.sfa.utils.MIUIUtils
|
|
||||||
import java.lang.reflect.ParameterizedType
|
|
||||||
|
|
||||||
abstract class AbstractActivity<Binding : ViewBinding> : AppCompatActivity() {
|
|
||||||
private var _binding: Binding? = null
|
|
||||||
internal val binding get() = _binding!!
|
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
|
||||||
super.onCreate(savedInstanceState)
|
|
||||||
|
|
||||||
DynamicColors.applyToActivityIfAvailable(this)
|
|
||||||
|
|
||||||
// Set light navigation bar for Android 8.0
|
|
||||||
if (Build.VERSION.SDK_INT == Build.VERSION_CODES.O) {
|
|
||||||
val nightFlag = resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK
|
|
||||||
if (nightFlag != Configuration.UI_MODE_NIGHT_YES) {
|
|
||||||
val insetsController =
|
|
||||||
WindowCompat.getInsetsController(
|
|
||||||
window,
|
|
||||||
window.decorView,
|
|
||||||
)
|
|
||||||
insetsController.isAppearanceLightNavigationBars = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
_binding =
|
|
||||||
createBindingInstance(layoutInflater).also {
|
|
||||||
setContentView(it.root)
|
|
||||||
}
|
|
||||||
|
|
||||||
findViewById<MaterialToolbar>(R.id.toolbar)?.also {
|
|
||||||
setSupportActionBar(it)
|
|
||||||
}
|
|
||||||
|
|
||||||
// MIUI overrides colorSurfaceContainer to colorSurface without below flags
|
|
||||||
@Suppress("DEPRECATION")
|
|
||||||
if (MIUIUtils.isMIUI) {
|
|
||||||
window.addFlags(WindowManager.LayoutParams.FLAG_TRANSLUCENT_STATUS)
|
|
||||||
window.addFlags(WindowManager.LayoutParams.FLAG_TRANSLUCENT_NAVIGATION)
|
|
||||||
}
|
|
||||||
|
|
||||||
supportActionBar?.setHomeAsUpIndicator(
|
|
||||||
AppCompatResources.getDrawable(
|
|
||||||
this@AbstractActivity,
|
|
||||||
R.drawable.ic_arrow_back_24,
|
|
||||||
)!!.apply {
|
|
||||||
setTint(getAttrColor(com.google.android.material.R.attr.colorOnSurface))
|
|
||||||
},
|
|
||||||
)
|
|
||||||
supportActionBar?.setDisplayHomeAsUpEnabled(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onOptionsItemSelected(item: MenuItem): Boolean {
|
|
||||||
when (item.itemId) {
|
|
||||||
android.R.id.home -> {
|
|
||||||
onBackPressedDispatcher.onBackPressed()
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return super.onOptionsItemSelected(item)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Suppress("UNCHECKED_CAST")
|
|
||||||
private fun createBindingInstance(inflater: LayoutInflater): Binding {
|
|
||||||
val vbType = (javaClass.genericSuperclass as ParameterizedType).actualTypeArguments[0]
|
|
||||||
val vbClass = vbType as Class<Binding>
|
|
||||||
val method = vbClass.getMethod("inflate", LayoutInflater::class.java)
|
|
||||||
return method.invoke(null, inflater) as Binding
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
package io.nekohasekai.sfa.utils
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.net.ConnectivityManager
|
||||||
|
import android.os.IBinder
|
||||||
|
import android.os.Parcel
|
||||||
|
import android.util.Log
|
||||||
|
|
||||||
|
object ConnectivityBinderUtils {
|
||||||
|
private const val TAG = "ConnectivityBinderUtils"
|
||||||
|
|
||||||
|
fun getBinder(context: Context): IBinder? {
|
||||||
|
val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager
|
||||||
|
?: return null
|
||||||
|
try {
|
||||||
|
val field = cm.javaClass.getDeclaredField("mService")
|
||||||
|
field.isAccessible = true
|
||||||
|
val service = field.get(cm) as? android.os.IInterface
|
||||||
|
if (service != null) {
|
||||||
|
return service.asBinder()
|
||||||
|
}
|
||||||
|
} catch (e: Throwable) {
|
||||||
|
Log.w(TAG, "Failed to get ConnectivityManager service binder", e)
|
||||||
|
}
|
||||||
|
return try {
|
||||||
|
val serviceManager = Class.forName("android.os.ServiceManager")
|
||||||
|
val getService = serviceManager.getMethod("getService", String::class.java)
|
||||||
|
getService.invoke(null, Context.CONNECTIVITY_SERVICE) as? IBinder
|
||||||
|
} catch (e: Throwable) {
|
||||||
|
Log.w(TAG, "Failed to get binder from ServiceManager", e)
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
inline fun <T> withParcel(block: (data: Parcel, reply: Parcel) -> T): T {
|
||||||
|
val data = Parcel.obtain()
|
||||||
|
val reply = Parcel.obtain()
|
||||||
|
return try {
|
||||||
|
block(data, reply)
|
||||||
|
} finally {
|
||||||
|
reply.recycle()
|
||||||
|
data.recycle()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
package io.nekohasekai.sfa.utils
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.os.RemoteException
|
||||||
|
import io.nekohasekai.sfa.bg.LogEntry
|
||||||
|
import io.nekohasekai.sfa.bg.ParceledListSlice
|
||||||
|
import io.nekohasekai.sfa.xposed.HookStatusKeys
|
||||||
|
|
||||||
|
object HookErrorClient {
|
||||||
|
enum class Failure {
|
||||||
|
SERVICE_UNAVAILABLE,
|
||||||
|
TRANSACTION_FAILED,
|
||||||
|
REMOTE_ERROR,
|
||||||
|
PROTOCOL_ERROR,
|
||||||
|
}
|
||||||
|
|
||||||
|
data class Result(
|
||||||
|
val logs: List<LogEntry>,
|
||||||
|
val hasWarnings: Boolean,
|
||||||
|
val failure: Failure? = null,
|
||||||
|
val detail: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun failureResult(failure: Failure, detail: String? = null) = Result(
|
||||||
|
logs = emptyList(),
|
||||||
|
hasWarnings = false,
|
||||||
|
failure = failure,
|
||||||
|
detail = detail,
|
||||||
|
)
|
||||||
|
|
||||||
|
fun query(context: Context): Result {
|
||||||
|
val binder = ConnectivityBinderUtils.getBinder(context)
|
||||||
|
?: return failureResult(Failure.SERVICE_UNAVAILABLE)
|
||||||
|
return ConnectivityBinderUtils.withParcel { data, reply ->
|
||||||
|
data.writeInterfaceToken(HookStatusKeys.DESCRIPTOR)
|
||||||
|
if (!binder.transact(HookStatusKeys.TRANSACTION_GET_ERRORS, data, reply, 0)) {
|
||||||
|
return@withParcel failureResult(Failure.TRANSACTION_FAILED)
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
reply.readException()
|
||||||
|
} catch (e: RemoteException) {
|
||||||
|
return@withParcel failureResult(Failure.REMOTE_ERROR, e.message)
|
||||||
|
}
|
||||||
|
if (reply.dataAvail() < 4) {
|
||||||
|
return@withParcel failureResult(Failure.PROTOCOL_ERROR, "reply too short: ${reply.dataAvail()}")
|
||||||
|
}
|
||||||
|
val hasWarnings = reply.readInt() != 0
|
||||||
|
val slice = ParceledListSlice.CREATOR.createFromParcel(reply, LogEntry::class.java.classLoader)
|
||||||
|
@Suppress("UNCHECKED_CAST")
|
||||||
|
Result(logs = slice.list as List<LogEntry>, hasWarnings = hasWarnings)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
package io.nekohasekai.sfa.utils
|
||||||
|
|
||||||
|
import android.app.NotificationChannel
|
||||||
|
import android.app.NotificationManager
|
||||||
|
import android.app.PendingIntent
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.os.Build
|
||||||
|
import androidx.core.app.NotificationCompat
|
||||||
|
import androidx.core.app.NotificationManagerCompat
|
||||||
|
import io.nekohasekai.sfa.R
|
||||||
|
import io.nekohasekai.sfa.bg.ServiceNotification
|
||||||
|
import io.nekohasekai.sfa.compose.MainActivity
|
||||||
|
import io.nekohasekai.sfa.xposed.HookModuleVersion
|
||||||
|
|
||||||
|
object HookModuleUpdateNotifier {
|
||||||
|
private const val CHANNEL_ID = "lsposed_module_update"
|
||||||
|
private const val NOTIFICATION_ID = 0x5F10
|
||||||
|
|
||||||
|
fun needsRestart(status: HookStatusClient.Status?): Boolean {
|
||||||
|
return isDowngrade(status) || isUpgrade(status)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun isDowngrade(status: HookStatusClient.Status?): Boolean {
|
||||||
|
return status != null && status.version > HookModuleVersion.CURRENT
|
||||||
|
}
|
||||||
|
|
||||||
|
fun isUpgrade(status: HookStatusClient.Status?): Boolean {
|
||||||
|
return status != null && status.version < HookModuleVersion.CURRENT
|
||||||
|
}
|
||||||
|
|
||||||
|
fun sync(context: Context) {
|
||||||
|
HookStatusClient.refresh()
|
||||||
|
maybeNotify(context, HookStatusClient.status.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun maybeNotify(context: Context, status: HookStatusClient.Status?) {
|
||||||
|
if (!needsRestart(status)) {
|
||||||
|
cancel(context)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ensureChannel(context)
|
||||||
|
val intent =
|
||||||
|
Intent(context, MainActivity::class.java).apply {
|
||||||
|
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
|
||||||
|
addCategory("de.robv.android.xposed.category.MODULE_SETTINGS")
|
||||||
|
}
|
||||||
|
val pendingIntent =
|
||||||
|
PendingIntent.getActivity(
|
||||||
|
context,
|
||||||
|
0,
|
||||||
|
intent,
|
||||||
|
PendingIntent.FLAG_UPDATE_CURRENT or ServiceNotification.flags,
|
||||||
|
)
|
||||||
|
val builder =
|
||||||
|
NotificationCompat.Builder(context, CHANNEL_ID)
|
||||||
|
.setSmallIcon(R.drawable.ic_menu)
|
||||||
|
.setContentTitle(context.getString(R.string.privilege_module_restart_notification_title))
|
||||||
|
.setContentText(context.getString(R.string.privilege_module_restart_notification_message))
|
||||||
|
.setContentIntent(pendingIntent)
|
||||||
|
.setAutoCancel(true)
|
||||||
|
.setCategory(NotificationCompat.CATEGORY_STATUS)
|
||||||
|
.setPriority(NotificationCompat.PRIORITY_HIGH)
|
||||||
|
NotificationManagerCompat.from(context).notify(NOTIFICATION_ID, builder.build())
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun cancel(context: Context) {
|
||||||
|
NotificationManagerCompat.from(context).cancel(NOTIFICATION_ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun ensureChannel(context: Context) {
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
|
val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||||
|
manager.createNotificationChannel(
|
||||||
|
NotificationChannel(
|
||||||
|
CHANNEL_ID,
|
||||||
|
context.getString(R.string.privilege_module_restart_channel),
|
||||||
|
NotificationManager.IMPORTANCE_HIGH,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
package io.nekohasekai.sfa.utils
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import io.nekohasekai.sfa.xposed.HookStatusKeys
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
|
||||||
|
object HookStatusClient {
|
||||||
|
data class Status(
|
||||||
|
val active: Boolean,
|
||||||
|
val lastPatchedAt: Long,
|
||||||
|
val version: Int,
|
||||||
|
val systemPid: Int,
|
||||||
|
)
|
||||||
|
|
||||||
|
private val statusFlow = MutableStateFlow<Status?>(null)
|
||||||
|
val status: StateFlow<Status?> = statusFlow
|
||||||
|
|
||||||
|
@Volatile
|
||||||
|
private var appContext: Context? = null
|
||||||
|
|
||||||
|
fun register(context: Context) {
|
||||||
|
appContext = context.applicationContext
|
||||||
|
refresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun refresh() {
|
||||||
|
val context = appContext ?: return
|
||||||
|
val binder = ConnectivityBinderUtils.getBinder(context) ?: run {
|
||||||
|
statusFlow.value = null
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ConnectivityBinderUtils.withParcel { data, reply ->
|
||||||
|
data.writeInterfaceToken(HookStatusKeys.DESCRIPTOR)
|
||||||
|
val ok = binder.transact(HookStatusKeys.TRANSACTION_STATUS, data, reply, 0)
|
||||||
|
if (!ok) {
|
||||||
|
statusFlow.value = null
|
||||||
|
return
|
||||||
|
}
|
||||||
|
reply.readException()
|
||||||
|
statusFlow.value = Status(
|
||||||
|
active = reply.readInt() != 0,
|
||||||
|
lastPatchedAt = reply.readLong(),
|
||||||
|
version = reply.readInt(),
|
||||||
|
systemPid = reply.readInt(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
package io.nekohasekai.sfa.utils
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.os.RemoteException
|
||||||
|
import android.util.Log
|
||||||
|
import io.nekohasekai.sfa.bg.PackageEntry
|
||||||
|
import io.nekohasekai.sfa.bg.ParceledListSlice
|
||||||
|
import io.nekohasekai.sfa.bg.RootClient
|
||||||
|
import io.nekohasekai.sfa.database.Settings
|
||||||
|
import io.nekohasekai.sfa.xposed.HookModuleVersion
|
||||||
|
import io.nekohasekai.sfa.xposed.HookStatusKeys
|
||||||
|
|
||||||
|
object PrivilegeSettingsClient {
|
||||||
|
private const val TAG = "PrivilegeSettingsClient"
|
||||||
|
|
||||||
|
@Volatile
|
||||||
|
private var appContext: Context? = null
|
||||||
|
|
||||||
|
data class ExportResult(
|
||||||
|
val outputPath: String?,
|
||||||
|
val error: String?,
|
||||||
|
)
|
||||||
|
|
||||||
|
fun register(context: Context) {
|
||||||
|
appContext = context.applicationContext
|
||||||
|
sync()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun sync(): Throwable? {
|
||||||
|
val context = appContext ?: return null
|
||||||
|
if (isVersionMismatch()) return null
|
||||||
|
val binder = ConnectivityBinderUtils.getBinder(context) ?: return null
|
||||||
|
return ConnectivityBinderUtils.withParcel { data, reply ->
|
||||||
|
data.writeInterfaceToken(HookStatusKeys.DESCRIPTOR)
|
||||||
|
data.writeInt(if (Settings.privilegeSettingsEnabled) 1 else 0)
|
||||||
|
ParceledListSlice(Settings.privilegeSettingsList.map { PackageEntry(it) }).writeToParcel(data, 0)
|
||||||
|
data.writeInt(if (Settings.privilegeSettingsInterfaceRenameEnabled) 1 else 0)
|
||||||
|
data.writeString(Settings.privilegeSettingsInterfacePrefix)
|
||||||
|
try {
|
||||||
|
val ok = binder.transact(HookStatusKeys.TRANSACTION_UPDATE_PRIVILEGE_SETTINGS, data, reply, 0)
|
||||||
|
reply.readException()
|
||||||
|
if (!ok) {
|
||||||
|
val error = RemoteException()
|
||||||
|
Log.w(TAG, "Privilege settings sync failed: transaction not handled", error)
|
||||||
|
return@withParcel error
|
||||||
|
}
|
||||||
|
return@withParcel null
|
||||||
|
} catch (e: RemoteException) {
|
||||||
|
Log.w(TAG, "Privilege settings sync failed: remote exception", e)
|
||||||
|
return@withParcel e
|
||||||
|
} catch (e: RuntimeException) {
|
||||||
|
Log.w(TAG, "Privilege settings sync failed: bad reply", e)
|
||||||
|
return@withParcel e
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun exportDebugInfo(outputPath: String): ExportResult {
|
||||||
|
return try {
|
||||||
|
val service = RootClient.bindService()
|
||||||
|
val path = service.exportDebugInfo(outputPath)
|
||||||
|
ExportResult(path, null)
|
||||||
|
} catch (e: Throwable) {
|
||||||
|
Log.e(TAG, "Export debug info failed", e)
|
||||||
|
ExportResult(null, e.message ?: "export failed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun isVersionMismatch(): Boolean {
|
||||||
|
val status = HookStatusClient.status.value ?: return false
|
||||||
|
return status.version != HookModuleVersion.CURRENT
|
||||||
|
}
|
||||||
|
}
|
||||||
162
app/src/main/java/io/nekohasekai/sfa/utils/VpnDetectionTest.kt
Normal file
162
app/src/main/java/io/nekohasekai/sfa/utils/VpnDetectionTest.kt
Normal file
@@ -0,0 +1,162 @@
|
|||||||
|
package io.nekohasekai.sfa.utils
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.net.ConnectivityManager
|
||||||
|
import android.net.Network
|
||||||
|
import android.net.NetworkCapabilities
|
||||||
|
import java.net.NetworkInterface
|
||||||
|
import java.util.Collections
|
||||||
|
|
||||||
|
data class DetectionResult(
|
||||||
|
val frameworkDetected: List<String>,
|
||||||
|
val nativeDetected: Boolean,
|
||||||
|
val frameworkInterfaces: List<String>,
|
||||||
|
val nativeInterfaces: List<String>,
|
||||||
|
val httpProxy: String?,
|
||||||
|
)
|
||||||
|
|
||||||
|
object VpnDetectionTest {
|
||||||
|
|
||||||
|
fun runDetection(context: Context): DetectionResult {
|
||||||
|
val frameworkDetected = LinkedHashSet<String>()
|
||||||
|
val frameworkInterfaces = LinkedHashSet<String>()
|
||||||
|
|
||||||
|
val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager
|
||||||
|
?: return DetectionResult(emptyList(), false, emptyList(), emptyList(), null)
|
||||||
|
|
||||||
|
// Check activeNetworkInfo
|
||||||
|
val activeInfo = cm.activeNetworkInfo
|
||||||
|
if (activeInfo?.type == ConnectivityManager.TYPE_VPN) {
|
||||||
|
frameworkDetected += "ActiveNetworkInfo"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check networkInfo(TYPE_VPN)
|
||||||
|
val vpnInfo = cm.getNetworkInfo(ConnectivityManager.TYPE_VPN)
|
||||||
|
if (vpnInfo != null && vpnInfo.isConnected) {
|
||||||
|
frameworkDetected += "NetworkInfo"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check networkForType(VPN)
|
||||||
|
val vpnNetwork = runCatching {
|
||||||
|
val method = cm.javaClass.getMethod(
|
||||||
|
"getNetworkForType",
|
||||||
|
Int::class.javaPrimitiveType,
|
||||||
|
)
|
||||||
|
method.invoke(cm, ConnectivityManager.TYPE_VPN) as? Network
|
||||||
|
}.getOrNull()
|
||||||
|
if (vpnNetwork != null) {
|
||||||
|
frameworkDetected += "NetworkForType"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check all networks for VPN transport or missing NOT_VPN capability
|
||||||
|
val networks = cm.allNetworks ?: emptyArray()
|
||||||
|
for (network in networks) {
|
||||||
|
val caps = cm.getNetworkCapabilities(network) ?: continue
|
||||||
|
if (caps.hasTransport(NetworkCapabilities.TRANSPORT_VPN)) {
|
||||||
|
frameworkDetected += "NetworkCapabilities"
|
||||||
|
}
|
||||||
|
if (!caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_VPN)) {
|
||||||
|
frameworkDetected += "NetworkCapabilities"
|
||||||
|
}
|
||||||
|
// Check interface name in LinkProperties
|
||||||
|
val lp = cm.getLinkProperties(network)
|
||||||
|
if (isVpnInterface(lp?.interfaceName)) {
|
||||||
|
lp?.interfaceName?.let(frameworkInterfaces::add)
|
||||||
|
frameworkDetected += "LinkProperties"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check activeLinkProperties interface
|
||||||
|
val activeLinkProperties = runCatching { cm.getLinkProperties(cm.activeNetwork) }.getOrNull()
|
||||||
|
if (isVpnInterface(activeLinkProperties?.interfaceName)) {
|
||||||
|
activeLinkProperties?.interfaceName?.let(frameworkInterfaces::add)
|
||||||
|
frameworkDetected += "LinkProperties"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Native: Check network interfaces (getifaddrs)
|
||||||
|
val nativeInterfaces = checkNetworkInterfaces()
|
||||||
|
|
||||||
|
val httpProxy = readHttpProxy(cm)
|
||||||
|
return DetectionResult(
|
||||||
|
frameworkDetected.toList(),
|
||||||
|
nativeInterfaces.isNotEmpty(),
|
||||||
|
frameworkInterfaces.toList(),
|
||||||
|
nativeInterfaces,
|
||||||
|
httpProxy,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun checkNetworkInterfaces(): List<String> {
|
||||||
|
val list = try {
|
||||||
|
Collections.list(NetworkInterface.getNetworkInterfaces())
|
||||||
|
} catch (_: Throwable) {
|
||||||
|
return emptyList()
|
||||||
|
}
|
||||||
|
val matches = ArrayList<String>()
|
||||||
|
for (iface in list) {
|
||||||
|
val name = iface.name ?: continue
|
||||||
|
val isUp = runCatching { iface.isUp }.getOrElse { false }
|
||||||
|
if (!isUp) continue
|
||||||
|
if (isVpnInterface(name)) {
|
||||||
|
matches.add(name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return matches
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun isVpnInterface(name: String?): Boolean {
|
||||||
|
if (name.isNullOrEmpty()) return false
|
||||||
|
val lower = name.lowercase()
|
||||||
|
return lower.startsWith("tun") || lower.startsWith("ppp") || lower.startsWith("tap")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun readHttpProxy(cm: ConnectivityManager): String? {
|
||||||
|
val defaultProxy = try {
|
||||||
|
val method = cm.javaClass.getMethod("getDefaultProxy")
|
||||||
|
method.invoke(cm) as? android.net.ProxyInfo
|
||||||
|
} catch (_: Throwable) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
val activeLinkProperties = runCatching { cm.getLinkProperties(cm.activeNetwork) }.getOrNull()
|
||||||
|
val networks = cm.allNetworks ?: emptyArray()
|
||||||
|
val proxies = buildList {
|
||||||
|
add(formatProxyInfo(defaultProxy))
|
||||||
|
add(formatProxyInfo(readProxyFromLinkProperties(activeLinkProperties)))
|
||||||
|
for (network in networks) {
|
||||||
|
add(formatProxyInfo(readProxyFromLinkProperties(cm.getLinkProperties(network))))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return proxies.firstOrNull { !it.isNullOrEmpty() }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun readProxyFromLinkProperties(lp: android.net.LinkProperties?): android.net.ProxyInfo? {
|
||||||
|
if (lp == null) return null
|
||||||
|
return try {
|
||||||
|
val method = lp.javaClass.getMethod("getHttpProxy")
|
||||||
|
method.invoke(lp) as? android.net.ProxyInfo
|
||||||
|
} catch (_: Throwable) {
|
||||||
|
try {
|
||||||
|
val field = lp.javaClass.getDeclaredField("mHttpProxy")
|
||||||
|
field.isAccessible = true
|
||||||
|
field.get(lp) as? android.net.ProxyInfo
|
||||||
|
} catch (_: Throwable) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun formatProxyInfo(proxyInfo: android.net.ProxyInfo?): String? {
|
||||||
|
if (proxyInfo == null) return null
|
||||||
|
return try {
|
||||||
|
val host = proxyInfo.host
|
||||||
|
val port = proxyInfo.port
|
||||||
|
if (!host.isNullOrEmpty() && port > 0) {
|
||||||
|
return "$host:$port"
|
||||||
|
}
|
||||||
|
val pac = proxyInfo.pacFileUrl?.toString()
|
||||||
|
if (!pac.isNullOrEmpty()) pac else null
|
||||||
|
} catch (_: Throwable) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
7
app/src/main/java/io/nekohasekai/sfa/vendor/PackageQueryStrategy.kt
vendored
Normal file
7
app/src/main/java/io/nekohasekai/sfa/vendor/PackageQueryStrategy.kt
vendored
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
package io.nekohasekai.sfa.vendor
|
||||||
|
|
||||||
|
sealed class PackageQueryStrategy {
|
||||||
|
data object ForcedRoot : PackageQueryStrategy()
|
||||||
|
data class UserSelected(val mode: String) : PackageQueryStrategy()
|
||||||
|
data object Direct : PackageQueryStrategy()
|
||||||
|
}
|
||||||
173
app/src/main/java/io/nekohasekai/sfa/vendor/PrivilegedServiceUtils.kt
vendored
Normal file
173
app/src/main/java/io/nekohasekai/sfa/vendor/PrivilegedServiceUtils.kt
vendored
Normal file
@@ -0,0 +1,173 @@
|
|||||||
|
package io.nekohasekai.sfa.vendor
|
||||||
|
|
||||||
|
import android.content.Intent
|
||||||
|
import android.content.IntentSender
|
||||||
|
import android.content.pm.IPackageInstaller
|
||||||
|
import android.content.pm.IPackageInstallerSession
|
||||||
|
import android.content.pm.PackageInfo
|
||||||
|
import android.content.pm.PackageInstaller
|
||||||
|
import android.os.Build
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.os.IBinder
|
||||||
|
import android.os.ParcelFileDescriptor
|
||||||
|
import android.system.Os
|
||||||
|
import java.util.concurrent.CountDownLatch
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
import android.content.IIntentSender
|
||||||
|
import io.nekohasekai.sfa.BuildConfig
|
||||||
|
import java.io.IOException
|
||||||
|
|
||||||
|
object PrivilegedServiceUtils {
|
||||||
|
|
||||||
|
const val SYSTEM_SERVICE_NAME = "sfa_privileged"
|
||||||
|
|
||||||
|
private fun getPackageManager(): Any {
|
||||||
|
val binder = SystemServiceHelperCompat.getSystemService("package")
|
||||||
|
?: throw IllegalStateException("package service not available")
|
||||||
|
val stubClass = Class.forName("android.content.pm.IPackageManager\$Stub")
|
||||||
|
val asInterface = stubClass.getMethod("asInterface", IBinder::class.java)
|
||||||
|
return asInterface.invoke(null, binder)
|
||||||
|
?: throw IllegalStateException("IPackageManager is null")
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getInstalledPackages(flags: Int, userId: Int): List<PackageInfo> {
|
||||||
|
val iPackageManager = getPackageManager()
|
||||||
|
val iPackageManagerClass = Class.forName("android.content.pm.IPackageManager")
|
||||||
|
val result = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||||
|
val method = iPackageManagerClass.getMethod(
|
||||||
|
"getInstalledPackages",
|
||||||
|
Long::class.javaPrimitiveType,
|
||||||
|
Int::class.javaPrimitiveType
|
||||||
|
)
|
||||||
|
method.invoke(iPackageManager, flags.toLong(), userId)
|
||||||
|
} else {
|
||||||
|
val method = iPackageManagerClass.getMethod(
|
||||||
|
"getInstalledPackages",
|
||||||
|
Int::class.javaPrimitiveType,
|
||||||
|
Int::class.javaPrimitiveType
|
||||||
|
)
|
||||||
|
method.invoke(iPackageManager, flags, userId)
|
||||||
|
}
|
||||||
|
return extractPackageList(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun installPackage(apkFd: ParcelFileDescriptor, size: Long, userId: Int) {
|
||||||
|
val iPackageInstaller = getPackageInstaller()
|
||||||
|
val isRoot = Os.getuid() == 0
|
||||||
|
val installerPackageName = if (isRoot) BuildConfig.APPLICATION_ID else "com.android.shell"
|
||||||
|
val targetUserId = if (isRoot) userId else 0
|
||||||
|
|
||||||
|
val packageInstaller = createPackageInstaller(
|
||||||
|
iPackageInstaller,
|
||||||
|
installerPackageName,
|
||||||
|
null,
|
||||||
|
targetUserId
|
||||||
|
)
|
||||||
|
|
||||||
|
val params = PackageInstaller.SessionParams(PackageInstaller.SessionParams.MODE_FULL_INSTALL)
|
||||||
|
val sessionId = packageInstaller.createSession(params)
|
||||||
|
|
||||||
|
val iSession = IPackageInstallerSession.Stub.asInterface(
|
||||||
|
iPackageInstaller.openSession(sessionId).asBinder()
|
||||||
|
)
|
||||||
|
val session = createSession(iSession)
|
||||||
|
|
||||||
|
try {
|
||||||
|
ParcelFileDescriptor.AutoCloseInputStream(apkFd).use { inputStream ->
|
||||||
|
session.openWrite("base.apk", 0, size).use { outputStream ->
|
||||||
|
inputStream.copyTo(outputStream)
|
||||||
|
session.fsync(outputStream)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val resultIntent = arrayOfNulls<Intent>(1)
|
||||||
|
val latch = CountDownLatch(1)
|
||||||
|
|
||||||
|
val intentSender = createIntentSender { intent ->
|
||||||
|
resultIntent[0] = intent
|
||||||
|
latch.countDown()
|
||||||
|
}
|
||||||
|
|
||||||
|
session.commit(intentSender)
|
||||||
|
latch.await(60, TimeUnit.SECONDS)
|
||||||
|
|
||||||
|
val intent = resultIntent[0]
|
||||||
|
?: throw IOException("Installation timed out")
|
||||||
|
|
||||||
|
val status = intent.getIntExtra(PackageInstaller.EXTRA_STATUS, PackageInstaller.STATUS_FAILURE)
|
||||||
|
if (status != PackageInstaller.STATUS_SUCCESS) {
|
||||||
|
val message = intent.getStringExtra(PackageInstaller.EXTRA_STATUS_MESSAGE)
|
||||||
|
throw IOException("Installation failed ($status): $message")
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
session.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getPackageInstaller(): IPackageInstaller {
|
||||||
|
val iPackageManager = getPackageManager()
|
||||||
|
val iPackageManagerClass = Class.forName("android.content.pm.IPackageManager")
|
||||||
|
val method = iPackageManagerClass.getMethod("getPackageInstaller")
|
||||||
|
val installer = method.invoke(iPackageManager) as IPackageInstaller
|
||||||
|
return IPackageInstaller.Stub.asInterface(installer.asBinder())
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createPackageInstaller(
|
||||||
|
installer: IPackageInstaller,
|
||||||
|
installerPackageName: String,
|
||||||
|
installerAttributionTag: String?,
|
||||||
|
userId: Int
|
||||||
|
): PackageInstaller {
|
||||||
|
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||||
|
PackageInstaller::class.java
|
||||||
|
.getConstructor(
|
||||||
|
IPackageInstaller::class.java,
|
||||||
|
String::class.java,
|
||||||
|
String::class.java,
|
||||||
|
Int::class.javaPrimitiveType
|
||||||
|
)
|
||||||
|
.newInstance(installer, installerPackageName, installerAttributionTag, userId)
|
||||||
|
} else {
|
||||||
|
PackageInstaller::class.java
|
||||||
|
.getConstructor(
|
||||||
|
IPackageInstaller::class.java,
|
||||||
|
String::class.java,
|
||||||
|
Int::class.javaPrimitiveType
|
||||||
|
)
|
||||||
|
.newInstance(installer, installerPackageName, userId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createSession(session: IPackageInstallerSession): PackageInstaller.Session {
|
||||||
|
return PackageInstaller.Session::class.java
|
||||||
|
.getConstructor(IPackageInstallerSession::class.java)
|
||||||
|
.newInstance(session)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createIntentSender(onResult: (Intent) -> Unit): IntentSender {
|
||||||
|
val sender = object : IIntentSender.Stub() {
|
||||||
|
override fun send(
|
||||||
|
code: Int,
|
||||||
|
intent: Intent,
|
||||||
|
resolvedType: String?,
|
||||||
|
whitelistToken: android.os.IBinder?,
|
||||||
|
finishedReceiver: android.content.IIntentReceiver?,
|
||||||
|
requiredPermission: String?,
|
||||||
|
options: Bundle?
|
||||||
|
) {
|
||||||
|
onResult(intent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return IntentSender::class.java
|
||||||
|
.getConstructor(IIntentSender::class.java)
|
||||||
|
.newInstance(sender)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Suppress("UNCHECKED_CAST")
|
||||||
|
private fun extractPackageList(parceledListSlice: Any?): List<PackageInfo> {
|
||||||
|
if (parceledListSlice == null) return emptyList()
|
||||||
|
val getListMethod = parceledListSlice.javaClass.getMethod("getList")
|
||||||
|
val list = getListMethod.invoke(parceledListSlice) as? List<*>
|
||||||
|
return list?.filterIsInstance<PackageInfo>() ?: emptyList()
|
||||||
|
}
|
||||||
|
}
|
||||||
33
app/src/main/java/io/nekohasekai/sfa/vendor/SystemServiceHelperCompat.kt
vendored
Normal file
33
app/src/main/java/io/nekohasekai/sfa/vendor/SystemServiceHelperCompat.kt
vendored
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
package io.nekohasekai.sfa.vendor
|
||||||
|
|
||||||
|
import android.annotation.SuppressLint
|
||||||
|
import android.os.IBinder
|
||||||
|
import android.util.Log
|
||||||
|
import java.lang.reflect.Method
|
||||||
|
|
||||||
|
@SuppressLint("PrivateApi")
|
||||||
|
object SystemServiceHelperCompat {
|
||||||
|
|
||||||
|
private val serviceCache = HashMap<String, IBinder?>()
|
||||||
|
private val getService: Method? = try {
|
||||||
|
val cls = Class.forName("android.os.ServiceManager")
|
||||||
|
cls.getMethod("getService", String::class.java)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.w("SystemServiceHelper", Log.getStackTraceString(e))
|
||||||
|
null
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getSystemService(name: String): IBinder? {
|
||||||
|
if (serviceCache.containsKey(name)) {
|
||||||
|
return serviceCache[name]
|
||||||
|
}
|
||||||
|
val binder = try {
|
||||||
|
getService?.invoke(null, name) as? IBinder
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.w("SystemServiceHelper", Log.getStackTraceString(e))
|
||||||
|
null
|
||||||
|
}
|
||||||
|
serviceCache[name] = binder
|
||||||
|
return binder
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,7 +2,7 @@ package io.nekohasekai.sfa.vendor
|
|||||||
|
|
||||||
import android.app.Activity
|
import android.app.Activity
|
||||||
import androidx.camera.core.ImageAnalysis
|
import androidx.camera.core.ImageAnalysis
|
||||||
import io.nekohasekai.sfa.ui.profile.QRCodeCropArea
|
import io.nekohasekai.sfa.compose.screen.qrscan.QRCodeCropArea
|
||||||
import io.nekohasekai.sfa.update.UpdateInfo
|
import io.nekohasekai.sfa.update.UpdateInfo
|
||||||
|
|
||||||
interface VendorInterface {
|
interface VendorInterface {
|
||||||
@@ -35,6 +35,12 @@ interface VendorInterface {
|
|||||||
*/
|
*/
|
||||||
fun checkUpdateAsync(): UpdateInfo? = null
|
fun checkUpdateAsync(): UpdateInfo? = null
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Force get latest update (ignores version check)
|
||||||
|
* @return UpdateInfo of the latest release, null if unavailable
|
||||||
|
*/
|
||||||
|
fun forceGetLatestUpdate(): UpdateInfo? = null
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if silent install feature is available
|
* Check if silent install feature is available
|
||||||
* @return true if silent install is supported (Other flavor only)
|
* @return true if silent install is supported (Other flavor only)
|
||||||
@@ -63,8 +69,8 @@ interface VendorInterface {
|
|||||||
* Download and install an APK update
|
* Download and install an APK update
|
||||||
* @param context The context
|
* @param context The context
|
||||||
* @param downloadUrl The URL to download the APK from
|
* @param downloadUrl The URL to download the APK from
|
||||||
* @return Result indicating success or failure
|
* @throws Exception if download or install fails
|
||||||
*/
|
*/
|
||||||
suspend fun downloadAndInstall(context: android.content.Context, downloadUrl: String): Result<Unit> =
|
suspend fun downloadAndInstall(context: android.content.Context, downloadUrl: String): Unit =
|
||||||
Result.failure(UnsupportedOperationException("Not supported in this flavor"))
|
throw UnsupportedOperationException("Not supported in this flavor")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,97 @@
|
|||||||
|
package io.nekohasekai.sfa.xposed
|
||||||
|
|
||||||
|
import android.util.Log
|
||||||
|
import io.nekohasekai.sfa.BuildConfig
|
||||||
|
import io.nekohasekai.sfa.bg.LogEntry
|
||||||
|
import java.util.ArrayDeque
|
||||||
|
|
||||||
|
object HookErrorStore {
|
||||||
|
private const val MAX_ENTRIES = 100
|
||||||
|
|
||||||
|
private val lock = Any()
|
||||||
|
private val entries = ArrayDeque<LogEntry>()
|
||||||
|
|
||||||
|
fun i(source: String, message: String, throwable: Throwable? = null) {
|
||||||
|
log(LogEntry.LEVEL_INFO, source, message, throwable, store = true)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun w(source: String, message: String, throwable: Throwable? = null) {
|
||||||
|
log(LogEntry.LEVEL_WARN, source, message, throwable, store = true)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun e(source: String, message: String, throwable: Throwable? = null) {
|
||||||
|
log(LogEntry.LEVEL_ERROR, source, message, throwable, store = true)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun d(source: String, message: String, throwable: Throwable? = null) {
|
||||||
|
log(LogEntry.LEVEL_DEBUG, source, message, throwable, store = false)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun log(
|
||||||
|
level: Int,
|
||||||
|
source: String,
|
||||||
|
message: String,
|
||||||
|
throwable: Throwable?,
|
||||||
|
store: Boolean,
|
||||||
|
) {
|
||||||
|
if (BuildConfig.DEBUG) {
|
||||||
|
when (level) {
|
||||||
|
LogEntry.LEVEL_DEBUG -> {
|
||||||
|
if (throwable != null) {
|
||||||
|
Log.d(XposedInit.TAG, "[$source] $message", throwable)
|
||||||
|
} else {
|
||||||
|
Log.d(XposedInit.TAG, "[$source] $message")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
LogEntry.LEVEL_INFO -> {
|
||||||
|
if (throwable != null) {
|
||||||
|
Log.i(XposedInit.TAG, "[$source] $message", throwable)
|
||||||
|
} else {
|
||||||
|
Log.i(XposedInit.TAG, "[$source] $message")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
LogEntry.LEVEL_WARN -> {
|
||||||
|
if (throwable != null) {
|
||||||
|
Log.w(XposedInit.TAG, "[$source] $message", throwable)
|
||||||
|
} else {
|
||||||
|
Log.w(XposedInit.TAG, "[$source] $message")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
LogEntry.LEVEL_ERROR -> {
|
||||||
|
if (throwable != null) {
|
||||||
|
Log.e(XposedInit.TAG, "[$source] $message", throwable)
|
||||||
|
} else {
|
||||||
|
Log.e(XposedInit.TAG, "[$source] $message")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!store || level == LogEntry.LEVEL_DEBUG) return
|
||||||
|
val stackTrace = throwable?.let { Log.getStackTraceString(it) }
|
||||||
|
val entry = LogEntry(level, System.currentTimeMillis(), source, message, stackTrace)
|
||||||
|
synchronized(lock) {
|
||||||
|
entries.addLast(entry)
|
||||||
|
while (entries.size > MAX_ENTRIES) {
|
||||||
|
entries.removeFirst()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun snapshot(): List<LogEntry> {
|
||||||
|
synchronized(lock) {
|
||||||
|
return entries.toList()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun hasWarnings(): Boolean {
|
||||||
|
synchronized(lock) {
|
||||||
|
return entries.any { it.level >= LogEntry.LEVEL_WARN }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun clear() {
|
||||||
|
synchronized(lock) {
|
||||||
|
entries.clear()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
package io.nekohasekai.sfa.xposed
|
||||||
|
|
||||||
|
object HookModuleVersion {
|
||||||
|
const val CURRENT = 2
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
package io.nekohasekai.sfa.xposed
|
||||||
|
|
||||||
|
object HookStatusKeys {
|
||||||
|
const val DESCRIPTOR = "android.net.IConnectivityManager"
|
||||||
|
const val TRANSACTION_STATUS = 0x5F00
|
||||||
|
const val TRANSACTION_UPDATE_PRIVILEGE_SETTINGS = 0x5F01
|
||||||
|
const val TRANSACTION_GET_ERRORS = 0x5F02
|
||||||
|
const val TRANSACTION_EXPORT_DEBUG_INFO = 0x5F03
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
package io.nekohasekai.sfa.xposed
|
||||||
|
|
||||||
|
import android.os.Process
|
||||||
|
|
||||||
|
object HookStatusStore {
|
||||||
|
@Volatile
|
||||||
|
private var active = false
|
||||||
|
@Volatile
|
||||||
|
private var lastPatchedAt = 0L
|
||||||
|
|
||||||
|
fun markHookActive() {
|
||||||
|
active = true
|
||||||
|
}
|
||||||
|
|
||||||
|
fun markPatched() {
|
||||||
|
lastPatchedAt = System.currentTimeMillis()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun snapshot(): Status {
|
||||||
|
return Status(active, lastPatchedAt, HookModuleVersion.CURRENT, Process.myPid())
|
||||||
|
}
|
||||||
|
|
||||||
|
data class Status(
|
||||||
|
val active: Boolean,
|
||||||
|
val lastPatchedAt: Long,
|
||||||
|
val version: Int,
|
||||||
|
val systemPid: Int,
|
||||||
|
)
|
||||||
|
}
|
||||||
119
app/src/main/java/io/nekohasekai/sfa/xposed/PrivilegeChecker.kt
Normal file
119
app/src/main/java/io/nekohasekai/sfa/xposed/PrivilegeChecker.kt
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
package io.nekohasekai.sfa.xposed
|
||||||
|
|
||||||
|
import android.content.pm.ApplicationInfo
|
||||||
|
import android.content.pm.PackageManager
|
||||||
|
import android.os.Process
|
||||||
|
import de.robv.android.xposed.XposedHelpers
|
||||||
|
import io.nekohasekai.sfa.BuildConfig
|
||||||
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
|
|
||||||
|
object PrivilegeChecker {
|
||||||
|
private const val PER_USER_RANGE = 100000
|
||||||
|
private val privilegedPermissions = arrayOf(
|
||||||
|
"android.permission.NETWORK_STACK",
|
||||||
|
"android.permission.MAINLINE_NETWORK_STACK",
|
||||||
|
"android.permission.NETWORK_SETTINGS",
|
||||||
|
"android.permission.CONNECTIVITY_INTERNAL",
|
||||||
|
"android.permission.CONTROL_VPN",
|
||||||
|
"android.permission.CONTROL_ALWAYS_ON_VPN",
|
||||||
|
)
|
||||||
|
private val exemptPackages = emptySet<String>()
|
||||||
|
private val exemptCache = ConcurrentHashMap<Int, Boolean>()
|
||||||
|
private val privilegedCache = ConcurrentHashMap<Int, Boolean>()
|
||||||
|
|
||||||
|
fun isPrivilegedUid(uid: Int): Boolean {
|
||||||
|
if (uid < Process.FIRST_APPLICATION_UID) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
val cached = privilegedCache[uid]
|
||||||
|
if (cached != null) {
|
||||||
|
return cached
|
||||||
|
}
|
||||||
|
if (isExemptUid(uid)) {
|
||||||
|
privilegedCache[uid] = true
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
val packages = getPackagesForUid(uid)
|
||||||
|
val pm = getPackageManager()
|
||||||
|
if (pm != null && packages.isNotEmpty()) {
|
||||||
|
val userId = uid / PER_USER_RANGE
|
||||||
|
for (pkg in packages) {
|
||||||
|
val appInfo = getApplicationInfo(pm, pkg, userId)
|
||||||
|
if (appInfo != null && isSystemApp(appInfo)) {
|
||||||
|
privilegedCache[uid] = true
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (permission in privilegedPermissions) {
|
||||||
|
val result = try {
|
||||||
|
XposedHelpers.callMethod(pm, "checkUidPermission", permission, uid) as? Int
|
||||||
|
} catch (_: Throwable) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
if (result == PackageManager.PERMISSION_GRANTED) {
|
||||||
|
privilegedCache[uid] = true
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
privilegedCache[uid] = false
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun isSystemApp(appInfo: ApplicationInfo): Boolean {
|
||||||
|
val flags = appInfo.flags
|
||||||
|
return flags and ApplicationInfo.FLAG_SYSTEM != 0 ||
|
||||||
|
flags and ApplicationInfo.FLAG_UPDATED_SYSTEM_APP != 0
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun isExemptUid(uid: Int): Boolean {
|
||||||
|
if (exemptPackages.isEmpty()) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
val cached = exemptCache[uid]
|
||||||
|
if (cached != null) {
|
||||||
|
return cached
|
||||||
|
}
|
||||||
|
val packages = getPackagesForUid(uid)
|
||||||
|
val isExempt = packages.any { it in exemptPackages }
|
||||||
|
exemptCache[uid] = isExempt
|
||||||
|
return isExempt
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getPackagesForUid(uid: Int): List<String> {
|
||||||
|
val pm = getPackageManager() ?: return emptyList()
|
||||||
|
return try {
|
||||||
|
val method = pm.javaClass.getMethod("getPackagesForUid", Int::class.javaPrimitiveType)
|
||||||
|
val result = method.invoke(pm, uid)
|
||||||
|
when (result) {
|
||||||
|
is Array<*> -> result.filterIsInstance<String>()
|
||||||
|
is List<*> -> result.filterIsInstance<String>()
|
||||||
|
else -> emptyList()
|
||||||
|
}
|
||||||
|
} catch (_: Throwable) {
|
||||||
|
emptyList()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getPackageManager(): Any? {
|
||||||
|
return try {
|
||||||
|
val appGlobals = Class.forName("android.app.AppGlobals")
|
||||||
|
val method = appGlobals.getMethod("getPackageManager")
|
||||||
|
method.invoke(null)
|
||||||
|
} catch (_: Throwable) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getApplicationInfo(pm: Any, pkg: String, userId: Int): ApplicationInfo? {
|
||||||
|
return try {
|
||||||
|
XposedHelpers.callMethod(pm, "getApplicationInfo", pkg, 0, userId) as? ApplicationInfo
|
||||||
|
} catch (_: Throwable) {
|
||||||
|
try {
|
||||||
|
XposedHelpers.callMethod(pm, "getApplicationInfo", pkg, 0L, userId) as? ApplicationInfo
|
||||||
|
} catch (_: Throwable) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
package io.nekohasekai.sfa.xposed
|
||||||
|
|
||||||
|
import java.io.File
|
||||||
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
|
import io.nekohasekai.sfa.xposed.HookErrorStore
|
||||||
|
|
||||||
|
object PrivilegeSettingsStore {
|
||||||
|
private const val SETTINGS_DIR = "/data/system/sing-box"
|
||||||
|
private const val SETTINGS_FILE = "privilege_settings.conf"
|
||||||
|
@Volatile
|
||||||
|
private var enabled = false
|
||||||
|
@Volatile
|
||||||
|
private var packageSet: Set<String> = emptySet()
|
||||||
|
@Volatile
|
||||||
|
private var interfaceRenameEnabled = false
|
||||||
|
@Volatile
|
||||||
|
private var interfacePrefix = "en"
|
||||||
|
private val uidCache = ConcurrentHashMap<Int, Boolean>()
|
||||||
|
|
||||||
|
fun update(
|
||||||
|
enabled: Boolean,
|
||||||
|
packages: Set<String>,
|
||||||
|
interfaceRenameEnabled: Boolean,
|
||||||
|
interfacePrefix: String,
|
||||||
|
) {
|
||||||
|
this.enabled = enabled
|
||||||
|
packageSet = packages
|
||||||
|
this.interfaceRenameEnabled = interfaceRenameEnabled
|
||||||
|
this.interfacePrefix = normalizePrefix(interfacePrefix)
|
||||||
|
uidCache.clear()
|
||||||
|
HookErrorStore.i(
|
||||||
|
"PrivilegeSettingsStore",
|
||||||
|
"PrivilegeSettings updated: enabled=$enabled size=${packages.size} rename=$interfaceRenameEnabled prefix=${this.interfacePrefix}",
|
||||||
|
)
|
||||||
|
writeSettingsFile()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun isEnabled(): Boolean = enabled
|
||||||
|
|
||||||
|
fun shouldRenameInterface(): Boolean {
|
||||||
|
return interfaceRenameEnabled
|
||||||
|
}
|
||||||
|
|
||||||
|
fun interfacePrefix(): String = interfacePrefix
|
||||||
|
|
||||||
|
fun isUidSelected(uid: Int): Boolean {
|
||||||
|
val cached = uidCache[uid]
|
||||||
|
if (cached != null) {
|
||||||
|
return cached
|
||||||
|
}
|
||||||
|
val selected = getPackagesForUid(uid).any { packageSet.contains(it) }
|
||||||
|
uidCache[uid] = selected
|
||||||
|
return selected
|
||||||
|
}
|
||||||
|
|
||||||
|
fun shouldHideUid(uid: Int): Boolean {
|
||||||
|
if (!enabled) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return isUidSelected(uid)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun normalizePrefix(prefix: String): String {
|
||||||
|
val trimmed = prefix.trim()
|
||||||
|
if (trimmed.isEmpty()) {
|
||||||
|
return "en"
|
||||||
|
}
|
||||||
|
val filtered = buildString(trimmed.length) {
|
||||||
|
for (ch in trimmed) {
|
||||||
|
if (ch.isLetterOrDigit() || ch == '_') {
|
||||||
|
append(ch)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return if (filtered.isEmpty()) "en" else filtered
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun writeSettingsFile() {
|
||||||
|
try {
|
||||||
|
val dir = File(SETTINGS_DIR)
|
||||||
|
if (!dir.exists() && !dir.mkdirs()) {
|
||||||
|
HookErrorStore.e("PrivilegeSettingsStore", "Failed to create settings dir: ${dir.path}")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val file = File(dir, SETTINGS_FILE)
|
||||||
|
val packagesLine = packageSet.sorted().joinToString(",")
|
||||||
|
val content = buildString {
|
||||||
|
append("version=1\n")
|
||||||
|
append("enabled=")
|
||||||
|
append(if (enabled) "1" else "0")
|
||||||
|
append('\n')
|
||||||
|
append("rename=")
|
||||||
|
append(if (interfaceRenameEnabled) "1" else "0")
|
||||||
|
append('\n')
|
||||||
|
append("prefix=")
|
||||||
|
append(interfacePrefix)
|
||||||
|
append('\n')
|
||||||
|
append("packages=")
|
||||||
|
append(packagesLine)
|
||||||
|
append('\n')
|
||||||
|
}
|
||||||
|
file.writeText(content)
|
||||||
|
file.setReadable(true, true)
|
||||||
|
file.setWritable(true, true)
|
||||||
|
} catch (e: Throwable) {
|
||||||
|
HookErrorStore.e("PrivilegeSettingsStore", "Failed to write privilege settings file", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getPackagesForUid(uid: Int): List<String> {
|
||||||
|
val pm = getPackageManager() ?: return emptyList()
|
||||||
|
return try {
|
||||||
|
val method = pm.javaClass.getMethod("getPackagesForUid", Int::class.javaPrimitiveType)
|
||||||
|
val result = method.invoke(pm, uid)
|
||||||
|
when (result) {
|
||||||
|
is Array<*> -> result.filterIsInstance<String>()
|
||||||
|
is List<*> -> result.filterIsInstance<String>()
|
||||||
|
else -> emptyList()
|
||||||
|
}
|
||||||
|
} catch (e: Throwable) {
|
||||||
|
HookErrorStore.e("PrivilegeSettingsStore", "getPackagesForUid failed for uid=$uid", e)
|
||||||
|
emptyList()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getPackageManager(): Any? {
|
||||||
|
return try {
|
||||||
|
val appGlobals = Class.forName("android.app.AppGlobals")
|
||||||
|
val method = appGlobals.getMethod("getPackageManager")
|
||||||
|
method.invoke(null)
|
||||||
|
} catch (e: Throwable) {
|
||||||
|
HookErrorStore.e("PrivilegeSettingsStore", "getPackageManager failed", e)
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
175
app/src/main/java/io/nekohasekai/sfa/xposed/VpnAppStore.kt
Normal file
175
app/src/main/java/io/nekohasekai/sfa/xposed/VpnAppStore.kt
Normal file
@@ -0,0 +1,175 @@
|
|||||||
|
package io.nekohasekai.sfa.xposed
|
||||||
|
|
||||||
|
import android.Manifest
|
||||||
|
import android.content.pm.ApplicationInfo
|
||||||
|
import android.content.pm.PackageInfo
|
||||||
|
import android.content.pm.PackageManager
|
||||||
|
import android.os.Binder
|
||||||
|
import android.os.SystemClock
|
||||||
|
import io.nekohasekai.sfa.BuildConfig
|
||||||
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
|
|
||||||
|
object VpnAppStore {
|
||||||
|
private const val PER_USER_RANGE = 100000
|
||||||
|
private const val REFRESH_INTERVAL_MS = 60_000L
|
||||||
|
private const val UID_CACHE_MS = 5_000L
|
||||||
|
|
||||||
|
private data class CacheEntry<T>(val atMs: Long, val value: T)
|
||||||
|
|
||||||
|
private val vpnPackagesByUser = ConcurrentHashMap<Int, CacheEntry<Set<String>>>()
|
||||||
|
private val uidVpnCache = ConcurrentHashMap<Int, CacheEntry<Boolean>>()
|
||||||
|
private val uidPackagesCache = ConcurrentHashMap<Int, CacheEntry<List<String>>>()
|
||||||
|
|
||||||
|
fun isVpnUid(uid: Int): Boolean {
|
||||||
|
val now = SystemClock.uptimeMillis()
|
||||||
|
val cached = uidVpnCache[uid]
|
||||||
|
if (cached != null && now - cached.atMs < UID_CACHE_MS) {
|
||||||
|
return cached.value
|
||||||
|
}
|
||||||
|
val callerPackages = getPackagesForUid(uid)
|
||||||
|
val userId = uid / PER_USER_RANGE
|
||||||
|
val vpnSet = getVpnPackages(userId)
|
||||||
|
val result = callerPackages.any { vpnSet.contains(it) }
|
||||||
|
uidVpnCache[uid] = CacheEntry(now, result)
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
fun isVpnPackage(packageName: String, userId: Int): Boolean {
|
||||||
|
return getVpnPackages(userId).contains(packageName)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun isVpnUidExcludeSelf(uid: Int): Boolean {
|
||||||
|
val packages = getPackagesForUid(uid)
|
||||||
|
if (packages.contains(BuildConfig.APPLICATION_ID)) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
val userId = uid / PER_USER_RANGE
|
||||||
|
val vpnSet = getVpnPackages(userId)
|
||||||
|
return packages.any { vpnSet.contains(it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getPackagesForUid(uid: Int): List<String> {
|
||||||
|
val now = SystemClock.uptimeMillis()
|
||||||
|
val cached = uidPackagesCache[uid]
|
||||||
|
if (cached != null && now - cached.atMs < UID_CACHE_MS) {
|
||||||
|
return cached.value
|
||||||
|
}
|
||||||
|
val result = binderLocalScope {
|
||||||
|
val pm = getPackageManager() ?: return@binderLocalScope emptyList<String>()
|
||||||
|
try {
|
||||||
|
val method = pm.javaClass.getMethod("getPackagesForUid", Int::class.javaPrimitiveType)
|
||||||
|
when (val raw = method.invoke(pm, uid)) {
|
||||||
|
is Array<*> -> raw.filterIsInstance<String>()
|
||||||
|
is List<*> -> raw.filterIsInstance<String>()
|
||||||
|
else -> emptyList()
|
||||||
|
}
|
||||||
|
} catch (e: Throwable) {
|
||||||
|
HookErrorStore.e("VpnAppStore", "getPackagesForUid failed for uid=$uid", e)
|
||||||
|
emptyList()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
uidPackagesCache[uid] = CacheEntry(now, result)
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getVpnPackages(userId: Int): Set<String> {
|
||||||
|
val now = SystemClock.uptimeMillis()
|
||||||
|
val cached = vpnPackagesByUser[userId]
|
||||||
|
if (cached != null && now - cached.atMs < REFRESH_INTERVAL_MS) {
|
||||||
|
return cached.value
|
||||||
|
}
|
||||||
|
val refreshed = scanVpnPackages(userId)
|
||||||
|
vpnPackagesByUser[userId] = CacheEntry(now, refreshed)
|
||||||
|
uidVpnCache.clear()
|
||||||
|
return refreshed
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun scanVpnPackages(userId: Int): Set<String> {
|
||||||
|
return binderLocalScope {
|
||||||
|
val pm = getPackageManager() ?: return@binderLocalScope emptySet()
|
||||||
|
val flags = PackageManager.MATCH_DISABLED_COMPONENTS or
|
||||||
|
PackageManager.MATCH_DIRECT_BOOT_AWARE or
|
||||||
|
PackageManager.MATCH_DIRECT_BOOT_UNAWARE or
|
||||||
|
PackageManager.GET_SERVICES
|
||||||
|
val packages = getInstalledPackagesCompat(pm, flags.toLong(), userId)
|
||||||
|
val result = HashSet<String>()
|
||||||
|
for (pkg in packages) {
|
||||||
|
val appInfo = pkg.applicationInfo ?: continue
|
||||||
|
if (isSystemApp(appInfo)) continue
|
||||||
|
val services = pkg.services ?: continue
|
||||||
|
if (services.any { it.permission == Manifest.permission.BIND_VPN_SERVICE }) {
|
||||||
|
result.add(pkg.packageName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
HookErrorStore.d("VpnAppStore", "VPN apps refreshed user=$userId count=${result.size}")
|
||||||
|
result
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getInstalledPackagesCompat(pm: Any, flags: Long, userId: Int): List<PackageInfo> {
|
||||||
|
val result = try {
|
||||||
|
val method = pm.javaClass.getMethod(
|
||||||
|
"getInstalledPackages",
|
||||||
|
Long::class.javaPrimitiveType,
|
||||||
|
Int::class.javaPrimitiveType,
|
||||||
|
)
|
||||||
|
method.invoke(pm, flags, userId)
|
||||||
|
} catch (_: Throwable) {
|
||||||
|
try {
|
||||||
|
val method = pm.javaClass.getMethod(
|
||||||
|
"getInstalledPackages",
|
||||||
|
Int::class.javaPrimitiveType,
|
||||||
|
Int::class.javaPrimitiveType,
|
||||||
|
)
|
||||||
|
method.invoke(pm, flags.toInt(), userId)
|
||||||
|
} catch (e: Throwable) {
|
||||||
|
HookErrorStore.e("VpnAppStore", "getInstalledPackages failed", e)
|
||||||
|
return emptyList()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return unwrapParceledListSlice(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun isSystemApp(info: ApplicationInfo): Boolean {
|
||||||
|
return info.flags and ApplicationInfo.FLAG_SYSTEM != 0 ||
|
||||||
|
info.flags and ApplicationInfo.FLAG_UPDATED_SYSTEM_APP != 0
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getPackageManager(): Any? {
|
||||||
|
return try {
|
||||||
|
val appGlobals = Class.forName("android.app.AppGlobals")
|
||||||
|
val method = appGlobals.getMethod("getPackageManager")
|
||||||
|
method.invoke(null)
|
||||||
|
} catch (e: Throwable) {
|
||||||
|
HookErrorStore.e("VpnAppStore", "getPackageManager failed", e)
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private inline fun <T> binderLocalScope(block: () -> T): T {
|
||||||
|
val token = Binder.clearCallingIdentity()
|
||||||
|
return try {
|
||||||
|
block()
|
||||||
|
} finally {
|
||||||
|
Binder.restoreCallingIdentity(token)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun unwrapParceledListSlice(raw: Any?): List<PackageInfo> {
|
||||||
|
if (raw == null) return emptyList()
|
||||||
|
if (raw is List<*>) {
|
||||||
|
return raw.filterIsInstance<PackageInfo>()
|
||||||
|
}
|
||||||
|
return try {
|
||||||
|
val method = raw.javaClass.getMethod("getList")
|
||||||
|
val list = method.invoke(raw)
|
||||||
|
if (list is List<*>) {
|
||||||
|
list.filterIsInstance<PackageInfo>()
|
||||||
|
} else {
|
||||||
|
emptyList()
|
||||||
|
}
|
||||||
|
} catch (_: Throwable) {
|
||||||
|
emptyList()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
package io.nekohasekai.sfa.xposed
|
||||||
|
|
||||||
|
object VpnHideContext {
|
||||||
|
private val targetUid = ThreadLocal<Int?>()
|
||||||
|
|
||||||
|
fun setTargetUid(uid: Int) {
|
||||||
|
targetUid.set(uid)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun consumeTargetUid(): Int? {
|
||||||
|
val value = targetUid.get()
|
||||||
|
targetUid.remove()
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
fun clear() {
|
||||||
|
targetUid.remove()
|
||||||
|
}
|
||||||
|
}
|
||||||
124
app/src/main/java/io/nekohasekai/sfa/xposed/VpnSanitizer.kt
Normal file
124
app/src/main/java/io/nekohasekai/sfa/xposed/VpnSanitizer.kt
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
package io.nekohasekai.sfa.xposed
|
||||||
|
|
||||||
|
import android.net.LinkProperties
|
||||||
|
import android.net.NetworkCapabilities
|
||||||
|
import android.net.NetworkInfo
|
||||||
|
import android.os.Parcel
|
||||||
|
import android.os.Process
|
||||||
|
import de.robv.android.xposed.XposedHelpers
|
||||||
|
import java.util.Locale
|
||||||
|
|
||||||
|
object VpnSanitizer {
|
||||||
|
private val vpnInterfacePrefixes = arrayOf(
|
||||||
|
"tun",
|
||||||
|
)
|
||||||
|
|
||||||
|
fun shouldHide(uid: Int): Boolean {
|
||||||
|
if (!PrivilegeSettingsStore.shouldHideUid(uid)) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if (VpnAppStore.isVpnUidExcludeSelf(uid)) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
fun sanitizeRequestCapabilities(source: NetworkCapabilities): NetworkCapabilities {
|
||||||
|
val caps = NetworkCapabilities(source)
|
||||||
|
sanitizeTransport(caps)
|
||||||
|
return caps
|
||||||
|
}
|
||||||
|
|
||||||
|
fun sanitizeNetworkCapabilities(source: NetworkCapabilities): NetworkCapabilities {
|
||||||
|
val caps = NetworkCapabilities(source)
|
||||||
|
sanitizeTransport(caps)
|
||||||
|
clearUnderlyingNetworks(caps)
|
||||||
|
clearOwnerUid(caps)
|
||||||
|
clearVpnTransportInfo(caps)
|
||||||
|
return caps
|
||||||
|
}
|
||||||
|
|
||||||
|
fun sanitizeLinkProperties(source: LinkProperties): LinkProperties {
|
||||||
|
val lp = cloneLinkProperties(source)
|
||||||
|
clearHttpProxy(lp)
|
||||||
|
val iface = lp.interfaceName
|
||||||
|
if (isVpnInterface(iface)) {
|
||||||
|
lp.setInterfaceName(null)
|
||||||
|
}
|
||||||
|
@Suppress("UNCHECKED_CAST")
|
||||||
|
val stacked = XposedHelpers.callMethod(lp, "getStackedLinks") as? List<LinkProperties>
|
||||||
|
if (!stacked.isNullOrEmpty()) {
|
||||||
|
for (link in stacked) {
|
||||||
|
clearHttpProxy(link)
|
||||||
|
val name = link.interfaceName
|
||||||
|
if (isVpnInterface(name)) {
|
||||||
|
XposedHelpers.callMethod(lp, "removeStackedLink", name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return lp
|
||||||
|
}
|
||||||
|
|
||||||
|
fun hasVpnInterface(lp: LinkProperties): Boolean {
|
||||||
|
if (isVpnInterface(lp.interfaceName)) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
@Suppress("UNCHECKED_CAST")
|
||||||
|
val stacked = XposedHelpers.callMethod(lp, "getStackedLinks") as? List<LinkProperties>
|
||||||
|
?: return false
|
||||||
|
return stacked.any { isVpnInterface(it.interfaceName) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun isVpnInterface(iface: String?): Boolean {
|
||||||
|
if (iface.isNullOrEmpty()) return false
|
||||||
|
val name = iface.lowercase(Locale.US)
|
||||||
|
return vpnInterfacePrefixes.any { name.startsWith(it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun sanitizeTransport(caps: NetworkCapabilities) {
|
||||||
|
XposedHelpers.callMethod(caps, "removeTransportType", NetworkCapabilities.TRANSPORT_VPN)
|
||||||
|
XposedHelpers.callMethod(caps, "addCapability", NetworkCapabilities.NET_CAPABILITY_NOT_VPN)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun clearUnderlyingNetworks(caps: NetworkCapabilities) {
|
||||||
|
XposedHelpers.callMethod(caps, "setUnderlyingNetworks", null)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun clearOwnerUid(caps: NetworkCapabilities) {
|
||||||
|
XposedHelpers.callMethod(caps, "setOwnerUid", Process.INVALID_UID)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun clearVpnTransportInfo(caps: NetworkCapabilities) {
|
||||||
|
val field = XposedHelpers.findField(NetworkCapabilities::class.java, "mTransportInfo")
|
||||||
|
val info = field.get(caps) ?: return
|
||||||
|
if (info.javaClass.name.contains("VpnTransportInfo")) {
|
||||||
|
field.set(caps, null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun clearHttpProxy(lp: LinkProperties) {
|
||||||
|
XposedHelpers.callMethod(lp, "setHttpProxy", null)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun cloneLinkProperties(source: LinkProperties): LinkProperties {
|
||||||
|
val parcel = Parcel.obtain()
|
||||||
|
return try {
|
||||||
|
source.writeToParcel(parcel, 0)
|
||||||
|
parcel.setDataPosition(0)
|
||||||
|
LinkProperties.CREATOR.createFromParcel(parcel)
|
||||||
|
} finally {
|
||||||
|
parcel.recycle()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun cloneNetworkInfo(source: NetworkInfo): NetworkInfo {
|
||||||
|
val parcel = Parcel.obtain()
|
||||||
|
return try {
|
||||||
|
source.writeToParcel(parcel, 0)
|
||||||
|
parcel.setDataPosition(0)
|
||||||
|
NetworkInfo.CREATOR.createFromParcel(parcel)
|
||||||
|
} finally {
|
||||||
|
parcel.recycle()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
package io.nekohasekai.sfa.xposed
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.os.Process
|
||||||
|
|
||||||
|
object XposedActivation {
|
||||||
|
private const val PREFS_NAME = "xposed_activation"
|
||||||
|
private const val KEY_ACTIVATED_PID = "activated_pid"
|
||||||
|
private const val KEY_ACTIVATED_AT = "activated_at"
|
||||||
|
private const val KEY_SYSTEM_IN_SCOPE = "system_in_scope"
|
||||||
|
|
||||||
|
fun markActivated(context: Context) {
|
||||||
|
context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||||
|
.edit()
|
||||||
|
.putInt(KEY_ACTIVATED_PID, Process.myPid())
|
||||||
|
.putLong(KEY_ACTIVATED_AT, System.currentTimeMillis())
|
||||||
|
.apply()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun updateScope(context: Context, scope: Collection<String>) {
|
||||||
|
val hasSystemScope = scope.any { it == "system" || it == "android" }
|
||||||
|
context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||||
|
.edit()
|
||||||
|
.putBoolean(KEY_SYSTEM_IN_SCOPE, hasSystemScope)
|
||||||
|
.putLong(KEY_ACTIVATED_AT, System.currentTimeMillis())
|
||||||
|
.apply()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun isActivated(context: Context): Boolean {
|
||||||
|
val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||||
|
if (prefs.contains(KEY_SYSTEM_IN_SCOPE)) {
|
||||||
|
return prefs.getBoolean(KEY_SYSTEM_IN_SCOPE, false)
|
||||||
|
}
|
||||||
|
return prefs.getInt(KEY_ACTIVATED_PID, -1) == Process.myPid()
|
||||||
|
}
|
||||||
|
}
|
||||||
56
app/src/main/java/io/nekohasekai/sfa/xposed/XposedInit.kt
Normal file
56
app/src/main/java/io/nekohasekai/sfa/xposed/XposedInit.kt
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
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
|
||||||
|
import io.github.libxposed.api.XposedInterface
|
||||||
|
import io.github.libxposed.api.XposedModule
|
||||||
|
import io.github.libxposed.api.XposedModuleInterface
|
||||||
|
|
||||||
|
class XposedInit(
|
||||||
|
base: XposedInterface,
|
||||||
|
param: XposedModuleInterface.ModuleLoadedParam,
|
||||||
|
) : XposedModule(base, param) {
|
||||||
|
|
||||||
|
override fun onSystemServerLoaded(param: XposedModuleInterface.SystemServerLoadedParam) {
|
||||||
|
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 {
|
||||||
|
const val TAG = "sing-box-lsposed"
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun resolveSystemContext(): Context? {
|
||||||
|
return try {
|
||||||
|
val activityThread = Class.forName("android.app.ActivityThread")
|
||||||
|
val currentThread = activityThread.getMethod("currentActivityThread").invoke(null)
|
||||||
|
activityThread.getMethod("getSystemContext").invoke(currentThread) as? Context
|
||||||
|
} catch (e: Throwable) {
|
||||||
|
HookErrorStore.e("XposedInit", "resolveSystemContext failed", e)
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
package io.nekohasekai.sfa.xposed.hooks
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.os.Binder
|
||||||
|
import android.os.Parcel
|
||||||
|
import de.robv.android.xposed.XposedHelpers
|
||||||
|
import io.nekohasekai.sfa.BuildConfig
|
||||||
|
import io.nekohasekai.sfa.bg.PackageEntry
|
||||||
|
import io.nekohasekai.sfa.bg.ParceledListSlice
|
||||||
|
import io.nekohasekai.sfa.xposed.HookErrorStore
|
||||||
|
import io.nekohasekai.sfa.xposed.HookStatusKeys
|
||||||
|
import io.nekohasekai.sfa.xposed.HookStatusStore
|
||||||
|
import io.nekohasekai.sfa.xposed.PrivilegeSettingsStore
|
||||||
|
|
||||||
|
class HookIConnectivityManagerOnTransact(
|
||||||
|
private val classLoader: ClassLoader,
|
||||||
|
private val context: Context?,
|
||||||
|
) : XHook {
|
||||||
|
private companion object {
|
||||||
|
private const val SOURCE = "HookIConnectivityManagerOnTransact"
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun injectHook() {
|
||||||
|
val stub = XposedHelpers.findClass("android.net.IConnectivityManager\$Stub", classLoader)
|
||||||
|
val descriptor = XposedHelpers.getStaticObjectField(stub, "DESCRIPTOR") as String
|
||||||
|
XposedHelpers.findAndHookMethod(
|
||||||
|
stub,
|
||||||
|
"onTransact",
|
||||||
|
Int::class.javaPrimitiveType,
|
||||||
|
Parcel::class.java,
|
||||||
|
Parcel::class.java,
|
||||||
|
Int::class.javaPrimitiveType,
|
||||||
|
object : SafeMethodHook(SOURCE) {
|
||||||
|
override fun beforeHook(param: MethodHookParam) {
|
||||||
|
val code = param.args[0] as Int
|
||||||
|
if (code != HookStatusKeys.TRANSACTION_STATUS &&
|
||||||
|
code != HookStatusKeys.TRANSACTION_UPDATE_PRIVILEGE_SETTINGS &&
|
||||||
|
code != HookStatusKeys.TRANSACTION_GET_ERRORS) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val data = param.args[1] as Parcel
|
||||||
|
val reply = param.args[2] as Parcel?
|
||||||
|
try {
|
||||||
|
data.enforceInterface(descriptor)
|
||||||
|
} catch (e: Throwable) {
|
||||||
|
HookErrorStore.e(SOURCE, "IConnectivityManager transact bad interface", e)
|
||||||
|
reply?.writeException(SecurityException("bad interface"))
|
||||||
|
param.result = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!isCallerAllowed()) {
|
||||||
|
reply!!.writeException(SecurityException("unauthorized"))
|
||||||
|
param.result = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (code == HookStatusKeys.TRANSACTION_STATUS) {
|
||||||
|
val status = HookStatusStore.snapshot()
|
||||||
|
reply!!.writeNoException()
|
||||||
|
reply.writeInt(if (status.active) 1 else 0)
|
||||||
|
reply.writeLong(status.lastPatchedAt)
|
||||||
|
reply.writeInt(status.version)
|
||||||
|
reply.writeInt(status.systemPid)
|
||||||
|
param.result = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (code == HookStatusKeys.TRANSACTION_GET_ERRORS) {
|
||||||
|
val hasWarnings = HookErrorStore.hasWarnings()
|
||||||
|
val entries = HookErrorStore.snapshot()
|
||||||
|
reply!!.writeNoException()
|
||||||
|
reply.writeInt(if (hasWarnings) 1 else 0)
|
||||||
|
ParceledListSlice(entries).writeToParcel(reply, 0)
|
||||||
|
param.result = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val enabled = data.readInt() != 0
|
||||||
|
val slice = ParceledListSlice.CREATOR.createFromParcel(data, PackageEntry::class.java.classLoader)
|
||||||
|
val packages = HashSet<String>()
|
||||||
|
for (entry in slice.list) {
|
||||||
|
if (entry is PackageEntry) {
|
||||||
|
packages.add(entry.packageName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var renameEnabled = false
|
||||||
|
var prefix = "en"
|
||||||
|
if (data.dataAvail() >= 4) {
|
||||||
|
renameEnabled = data.readInt() != 0
|
||||||
|
if (data.dataAvail() > 0) {
|
||||||
|
prefix = data.readString() ?: "en"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
PrivilegeSettingsStore.update(enabled, packages, renameEnabled, prefix)
|
||||||
|
reply!!.writeNoException()
|
||||||
|
param.result = true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
HookErrorStore.i(SOURCE, "Hooked IConnectivityManager.onTransact")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun isCallerAllowed(): Boolean {
|
||||||
|
val uid = Binder.getCallingUid()
|
||||||
|
if (uid == 0) return true
|
||||||
|
val pm = context?.packageManager
|
||||||
|
if (pm == null) {
|
||||||
|
HookErrorStore.e(SOURCE, "isCallerAllowed: context or packageManager is null, uid=$uid")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return try {
|
||||||
|
val packages = pm.getPackagesForUid(uid)
|
||||||
|
if (packages == null) {
|
||||||
|
HookErrorStore.w(SOURCE, "isCallerAllowed: getPackagesForUid returned null for uid=$uid")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
packages.any { it == BuildConfig.APPLICATION_ID }
|
||||||
|
} catch (e: Throwable) {
|
||||||
|
HookErrorStore.e(SOURCE, "isCallerAllowed failed for uid=$uid", e)
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user