Add vpn hide xposed module
This commit is contained in:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -3,7 +3,7 @@
|
||||
/local.properties
|
||||
/.idea/
|
||||
.DS_Store
|
||||
/build
|
||||
build/
|
||||
/captures
|
||||
.externalNativeBuild
|
||||
.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.tasks.KotlinCompile
|
||||
import org.jlleitschuh.gradle.ktlint.reporter.ReporterType
|
||||
@@ -125,6 +127,7 @@ android {
|
||||
}
|
||||
getByName("otherLegacy") {
|
||||
java.srcDirs("src/minApi21/java", "src/github/java")
|
||||
aidl.srcDirs("src/minApi23/aidl")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -138,8 +141,8 @@ android {
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_1_8
|
||||
targetCompatibility = JavaVersion.VERSION_1_8
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
}
|
||||
|
||||
buildFeatures {
|
||||
@@ -246,10 +249,8 @@ dependencies {
|
||||
val shizukuVersion = "12.2.0"
|
||||
"playImplementation"("dev.rikka.shizuku:api:$shizukuVersion")
|
||||
"playImplementation"("dev.rikka.shizuku:provider:$shizukuVersion")
|
||||
"playImplementation"("org.lsposed.hiddenapibypass:hiddenapibypass:4.3")
|
||||
"otherImplementation"("dev.rikka.shizuku:api:$shizukuVersion")
|
||||
"otherImplementation"("dev.rikka.shizuku:provider:$shizukuVersion")
|
||||
"otherImplementation"("org.lsposed.hiddenapibypass:hiddenapibypass:4.3")
|
||||
|
||||
// libsu for ROOT package query (all flavors)
|
||||
val libsuVersion = "6.0.0"
|
||||
@@ -309,6 +310,10 @@ dependencies {
|
||||
implementation("sh.calvin.reorderable:reorderable:3.0.0")
|
||||
implementation("com.github.jeziellago:compose-markdown:0.5.4")
|
||||
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")
|
||||
@@ -329,7 +334,7 @@ if (playCredentialsJSON.exists()) {
|
||||
|
||||
tasks.withType<KotlinCompile>().configureEach {
|
||||
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 }
|
||||
|
||||
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 release = getLatestRelease(includePrerelease) ?: return null
|
||||
|
||||
@@ -36,7 +44,7 @@ class GitHubUpdateChecker : Closeable {
|
||||
|
||||
val metadata = downloadMetadata(release)!!
|
||||
|
||||
if (metadata.versionCode <= BuildConfig.VERSION_CODE) {
|
||||
if (checkVersion && metadata.versionCode <= BuildConfig.VERSION_CODE) {
|
||||
return null
|
||||
}
|
||||
|
||||
|
||||
@@ -1,12 +1,18 @@
|
||||
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.suspendCancellableCoroutine
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.io.BufferedReader
|
||||
import java.io.BufferedWriter
|
||||
import java.io.File
|
||||
import java.io.InputStreamReader
|
||||
import java.io.OutputStreamWriter
|
||||
import kotlin.coroutines.resume
|
||||
import kotlin.coroutines.resumeWithException
|
||||
|
||||
object RootInstaller {
|
||||
|
||||
@@ -20,20 +26,63 @@ object RootInstaller {
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun install(apkFile: File): Result<Unit> = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val process = Runtime.getRuntime().exec(arrayOf("su", "-c", "pm install -r \"${apkFile.absolutePath}\""))
|
||||
val reader = BufferedReader(InputStreamReader(process.inputStream))
|
||||
val output = reader.readText()
|
||||
val exitCode = process.waitFor()
|
||||
suspend fun install(apkFile: File) {
|
||||
withContext(Dispatchers.IO) {
|
||||
bindRootService().use { handle ->
|
||||
ParcelFileDescriptor.open(apkFile, ParcelFileDescriptor.MODE_READ_ONLY).use { pfd ->
|
||||
handle.service.installPackage(
|
||||
pfd,
|
||||
apkFile.length(),
|
||||
android.os.Process.myUserHandle().hashCode()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (exitCode == 0 && output.contains("Success")) {
|
||||
Result.success(Unit)
|
||||
private suspend fun bindRootService(): RootServiceHandle {
|
||||
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 {
|
||||
Result.failure(Exception("Installation failed: $output"))
|
||||
null
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Result.failure(e)
|
||||
if (svc == null) {
|
||||
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
|
||||
}
|
||||
|
||||
fun install(context: Context, apkFile: File): Result<Unit> {
|
||||
return try {
|
||||
fun install(context: Context, apkFile: File) {
|
||||
val packageInstaller = context.packageManager.packageInstaller
|
||||
val params = AndroidPackageInstaller.SessionParams(AndroidPackageInstaller.SessionParams.MODE_FULL_INSTALL)
|
||||
params.setAppPackageName(context.packageName)
|
||||
@@ -44,9 +43,5 @@ object SystemPackageInstaller {
|
||||
|
||||
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) }
|
||||
|
||||
Log.d(TAG, "Installing update...")
|
||||
val result = ApkInstaller.install(appContext, apkFile)
|
||||
|
||||
if (result.isSuccess) {
|
||||
ApkInstaller.install(appContext, apkFile)
|
||||
Log.d(TAG, "Update installed successfully")
|
||||
} else {
|
||||
Log.e(TAG, "Update installation failed", result.exceptionOrNull())
|
||||
}
|
||||
} else {
|
||||
Log.d(TAG, "Silent install not available, update will be shown on next app launch")
|
||||
}
|
||||
|
||||
@@ -33,6 +33,7 @@
|
||||
android:name=".Application"
|
||||
android:allowBackup="true"
|
||||
android:dataExtractionRules="@xml/data_extraction_rules"
|
||||
android:description="@string/xposed_description"
|
||||
android:fullBackupContent="@xml/backup_rules"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
@@ -41,17 +42,22 @@
|
||||
tools:targetApi="31">
|
||||
|
||||
<activity
|
||||
android:name=".LauncherActivity"
|
||||
android:name=".compose.MainActivity"
|
||||
android:exported="true"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:theme="@style/AppTheme.Translucent">
|
||||
|
||||
android:launchMode="singleTask"
|
||||
android:theme="@style/AppTheme">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</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>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
@@ -90,32 +96,6 @@
|
||||
</intent-filter>
|
||||
|
||||
</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
|
||||
android:name=".bg.TileService"
|
||||
android:directBootAware="true"
|
||||
@@ -159,6 +139,11 @@
|
||||
</intent-filter>
|
||||
</receiver>
|
||||
|
||||
<provider
|
||||
android:name="io.github.libxposed.service.XposedProvider"
|
||||
android:authorities="${applicationId}.XposedService"
|
||||
android:exported="true" />
|
||||
|
||||
<provider
|
||||
android:name="androidx.core.content.FileProvider"
|
||||
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.UpdateProfileWork
|
||||
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 kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
@@ -35,11 +38,14 @@ class Application : Application() {
|
||||
|
||||
Seq.setContext(this)
|
||||
Libbox.setLocale(Locale.getDefault().toLanguageTag().replace("-", "_"))
|
||||
HookStatusClient.register(this)
|
||||
PrivilegeSettingsClient.register(this)
|
||||
|
||||
@Suppress("OPT_IN_USAGE")
|
||||
GlobalScope.launch(Dispatchers.IO) {
|
||||
initialize()
|
||||
UpdateProfileWork.reconfigureUpdater()
|
||||
HookModuleUpdateNotifier.sync(this@Application)
|
||||
}
|
||||
|
||||
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 io.nekohasekai.sfa.R
|
||||
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 kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
@@ -64,7 +64,7 @@ class AppChangeReceiver : BroadcastReceiver() {
|
||||
val installedPackages = PackageQueryManager.getInstalledPackages(packageManagerFlags)
|
||||
val chinaApps = mutableSetOf<String>()
|
||||
for (packageInfo in installedPackages) {
|
||||
if (PerAppProxyActivity.scanChinaPackage(packageInfo)) {
|
||||
if (PerAppProxyScanner.scanChinaPackage(packageInfo)) {
|
||||
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.Intent
|
||||
import android.content.ServiceConnection
|
||||
import android.content.pm.PackageInfo
|
||||
import android.os.IBinder
|
||||
import android.os.RemoteException
|
||||
import com.topjohnwu.superuser.Shell
|
||||
import com.topjohnwu.superuser.ipc.RootService
|
||||
import io.nekohasekai.sfa.Application
|
||||
@@ -17,10 +18,8 @@ import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlin.coroutines.resume
|
||||
import kotlin.coroutines.resumeWithException
|
||||
|
||||
object RootPackageManager {
|
||||
|
||||
object RootClient {
|
||||
init {
|
||||
Shell.enableVerboseLogging = BuildConfig.DEBUG
|
||||
Shell.setDefaultBuilder(
|
||||
@@ -36,7 +35,7 @@ object RootPackageManager {
|
||||
private val _serviceConnected = MutableStateFlow(false)
|
||||
val serviceConnected: StateFlow<Boolean> = _serviceConnected
|
||||
|
||||
private var service: IRootPackageManager? = null
|
||||
private var service: IRootService? = null
|
||||
private var connection: ServiceConnection? = null
|
||||
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 }
|
||||
|
||||
return withContext(Dispatchers.Main) {
|
||||
suspendCancellableCoroutine { continuation ->
|
||||
val conn = object : ServiceConnection {
|
||||
override fun onServiceConnected(name: ComponentName?, binder: IBinder?) {
|
||||
val svc = IRootPackageManager.Stub.asInterface(binder)
|
||||
val svc = IRootService.Stub.asInterface(binder)
|
||||
service = svc
|
||||
connection = this
|
||||
_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)
|
||||
|
||||
continuation.invokeOnCancellation {
|
||||
@@ -91,18 +90,16 @@ object RootPackageManager {
|
||||
}
|
||||
}
|
||||
|
||||
private const val CHUNK_SIZE = 50
|
||||
|
||||
suspend fun getInstalledPackages(flags: Int): List<PackageInfo> {
|
||||
val userId = android.os.Process.myUserHandle().hashCode()
|
||||
val svc = bindService()
|
||||
val result = mutableListOf<PackageInfo>()
|
||||
var offset = 0
|
||||
while (true) {
|
||||
val chunk = svc.getInstalledPackages(flags, offset, CHUNK_SIZE)
|
||||
if (chunk.isEmpty()) break
|
||||
result.addAll(chunk)
|
||||
offset += chunk.size
|
||||
}
|
||||
return result
|
||||
return try {
|
||||
val slice = svc.getInstalledPackages(flags, userId)
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
val list = slice.list as List<PackageInfo>
|
||||
list
|
||||
} catch (e: RemoteException) {
|
||||
throw e.rethrowFromSystemServer()
|
||||
}
|
||||
}
|
||||
}
|
||||
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.StatusMessage
|
||||
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.constant.Action
|
||||
import io.nekohasekai.sfa.constant.Status
|
||||
@@ -61,7 +61,7 @@ class ServiceNotification(
|
||||
0,
|
||||
Intent(
|
||||
service,
|
||||
LauncherActivity::class.java,
|
||||
MainActivity::class.java,
|
||||
).setFlags(Intent.FLAG_ACTIVITY_REORDER_TO_FRONT),
|
||||
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.ui.text.font.FontWeight
|
||||
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.Search
|
||||
import androidx.compose.material.icons.filled.Stop
|
||||
import androidx.compose.material.icons.filled.UnfoldLess
|
||||
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.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.material3.rememberModalBottomSheetState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
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.ServiceNotification
|
||||
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.component.ServiceStatusBar
|
||||
import io.nekohasekai.sfa.compose.component.UptimeText
|
||||
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.Screen
|
||||
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.DashboardViewModel
|
||||
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 showImportProfileDialog by mutableStateOf(false)
|
||||
private var pendingImportProfile by mutableStateOf<Triple<String, String, String>?>(null)
|
||||
private var newProfileArgs by mutableStateOf(NewProfileArgs())
|
||||
|
||||
private val notificationPermissionLauncher =
|
||||
registerForActivityResult(
|
||||
@@ -171,6 +171,7 @@ class MainActivity : ComponentActivity(), ServiceConnection.Callback {
|
||||
onServiceAlert(Alert.RequestVPNPermission, null)
|
||||
}
|
||||
}
|
||||
private val pendingNavigationRoute = mutableStateOf<String?>(null)
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
@@ -204,7 +205,13 @@ class MainActivity : ComponentActivity(), ServiceConnection.Callback {
|
||||
}
|
||||
|
||||
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") {
|
||||
try {
|
||||
val profile = Libbox.parseRemoteProfileImportLink(uri.toString())
|
||||
@@ -286,6 +293,15 @@ class MainActivity : ComponentActivity(), ServiceConnection.Callback {
|
||||
// Error dialog state for UiEvent.ShowError
|
||||
var showErrorDialog by remember { mutableStateOf(false) }
|
||||
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
|
||||
currentAlert?.let { (alertType, message) ->
|
||||
@@ -298,15 +314,10 @@ class MainActivity : ComponentActivity(), ServiceConnection.Callback {
|
||||
|
||||
// Handle UiEvent.ShowError dialog
|
||||
if (showErrorDialog) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { showErrorDialog = false },
|
||||
title = { Text(stringResource(R.string.error_title)) },
|
||||
text = { Text(errorMessage) },
|
||||
confirmButton = {
|
||||
TextButton(onClick = { showErrorDialog = false }) {
|
||||
Text(stringResource(R.string.ok))
|
||||
}
|
||||
},
|
||||
SelectableMessageDialog(
|
||||
title = stringResource(R.string.error_title),
|
||||
message = errorMessage,
|
||||
onDismiss = { showErrorDialog = false },
|
||||
)
|
||||
}
|
||||
|
||||
@@ -341,11 +352,11 @@ class MainActivity : ComponentActivity(), ServiceConnection.Callback {
|
||||
text = { Text(stringResource(R.string.import_remote_profile_message, name, host)) },
|
||||
confirmButton = {
|
||||
TextButton(onClick = {
|
||||
startActivity(
|
||||
Intent(this@MainActivity, NewProfileActivity::class.java).apply {
|
||||
putExtra(NewProfileActivity.EXTRA_IMPORT_NAME, name)
|
||||
putExtra(NewProfileActivity.EXTRA_IMPORT_URL, url)
|
||||
},
|
||||
openNewProfile(
|
||||
NewProfileArgs(
|
||||
importName = name,
|
||||
importUrl = url,
|
||||
),
|
||||
)
|
||||
showImportProfileDialog = false
|
||||
pendingImportProfile = null
|
||||
@@ -432,17 +443,13 @@ class MainActivity : ComponentActivity(), ServiceConnection.Callback {
|
||||
downloadError = null
|
||||
downloadJob = scope.launch {
|
||||
try {
|
||||
val result = withContext(Dispatchers.IO) {
|
||||
withContext(Dispatchers.IO) {
|
||||
Vendor.downloadAndInstall(
|
||||
this@MainActivity,
|
||||
updateInfo!!.downloadUrl,
|
||||
)
|
||||
}
|
||||
if (result.isFailure) {
|
||||
downloadError = result.exceptionOrNull()?.message
|
||||
} else {
|
||||
showDownloadDialog = false
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
downloadError = e.message
|
||||
}
|
||||
@@ -496,40 +503,23 @@ class MainActivity : ComponentActivity(), ServiceConnection.Callback {
|
||||
|
||||
val isSettingsSubScreen = currentRoute?.startsWith("settings/") == true
|
||||
val isConnectionsDetail = currentRoute?.startsWith("connections/detail") == true
|
||||
val isProfileRoute = currentRoute?.startsWith("profile/") == true
|
||||
val currentRootRoute =
|
||||
when {
|
||||
isSettingsSubScreen -> Screen.Settings.route
|
||||
currentRoute?.startsWith(Screen.Connections.route) == true -> Screen.Connections.route
|
||||
currentRoute?.startsWith(Screen.Log.route) == true -> Screen.Log.route
|
||||
isProfileRoute -> Screen.Dashboard.route
|
||||
else -> currentRoute
|
||||
}
|
||||
val isConnectionsRoute = currentRootRoute == Screen.Connections.route
|
||||
val isGroupsRoute = currentRootRoute == Screen.Groups.route
|
||||
val isLogRoute = currentRootRoute == Screen.Log.route
|
||||
|
||||
// Determine current screen title
|
||||
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
|
||||
}
|
||||
|
||||
val isSubScreen = isSettingsSubScreen || isConnectionsDetail || isProfileRoute
|
||||
// Get LogViewModel instance if we're on the Log screen
|
||||
val logViewModel: LogViewModel? =
|
||||
if (currentScreen == Screen.Log) {
|
||||
if (isLogRoute) {
|
||||
viewModel()
|
||||
} else {
|
||||
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) {
|
||||
if (currentRootRoute != null && !allowedRoutes.contains(currentRootRoute)) {
|
||||
navController.navigate(Screen.Dashboard.route) {
|
||||
@@ -627,10 +627,9 @@ class MainActivity : ComponentActivity(), ServiceConnection.Callback {
|
||||
}
|
||||
|
||||
is UiEvent.EditProfile -> {
|
||||
val intent =
|
||||
Intent(this@MainActivity, EditProfileActivity::class.java)
|
||||
intent.putExtra("profile_id", event.profileId)
|
||||
startActivity(intent)
|
||||
navController.navigate(ProfileRoutes.editProfile(event.profileId)) {
|
||||
launchSingleTop = true
|
||||
}
|
||||
}
|
||||
|
||||
is UiEvent.RestartToTakeEffect -> {
|
||||
@@ -656,133 +655,7 @@ class MainActivity : ComponentActivity(), ServiceConnection.Callback {
|
||||
}
|
||||
|
||||
val topBarContent: @Composable () -> Unit = {
|
||||
TopAppBar(
|
||||
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(),
|
||||
)
|
||||
topBarOverride?.invoke()
|
||||
}
|
||||
|
||||
val scaffoldContent: @Composable (PaddingValues) -> Unit = { paddingValues ->
|
||||
@@ -802,6 +675,9 @@ class MainActivity : ComponentActivity(), ServiceConnection.Callback {
|
||||
serviceStatus = currentServiceStatus,
|
||||
showStartFab = showStartFab,
|
||||
showStatusBar = showStatusBar,
|
||||
newProfileArgs = newProfileArgs,
|
||||
onClearNewProfileArgs = { newProfileArgs = NewProfileArgs() },
|
||||
onOpenNewProfile = openNewProfile,
|
||||
dashboardViewModel = dashboardViewModel,
|
||||
logViewModel = logViewModel,
|
||||
groupsViewModel = groupsViewModel,
|
||||
@@ -816,7 +692,7 @@ class MainActivity : ComponentActivity(), ServiceConnection.Callback {
|
||||
groupsCount = dashboardUiState.groupsCount,
|
||||
hasGroups = dashboardUiState.hasGroups,
|
||||
onGroupsClick = { showGroupsSheet = true },
|
||||
connectionsCount = dashboardUiState.connectionsOut.toIntOrNull() ?: 0,
|
||||
connectionsCount = dashboardUiState.connectionsCount,
|
||||
onConnectionsClick = { showConnectionsSheet = true },
|
||||
onStopClick = { dashboardViewModel.toggleService() },
|
||||
modifier = Modifier.align(Alignment.BottomCenter),
|
||||
@@ -936,6 +812,7 @@ class MainActivity : ComponentActivity(), ServiceConnection.Callback {
|
||||
}
|
||||
}
|
||||
|
||||
CompositionLocalProvider(LocalTopBarController provides topBarController) {
|
||||
if (useNavigationRail) {
|
||||
Row(modifier = Modifier.fillMaxSize()) {
|
||||
Surface(tonalElevation = 1.dp) {
|
||||
@@ -1026,6 +903,7 @@ class MainActivity : ComponentActivity(), ServiceConnection.Callback {
|
||||
scaffoldContent(paddingValues)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Groups ModalBottomSheet
|
||||
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.compose.screen.qrscan.QRScanResult
|
||||
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
|
||||
|
||||
@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.foundation.layout.fillMaxSize
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.navigation.NavHostController
|
||||
import androidx.navigation.NavType
|
||||
import androidx.navigation.compose.NavHost
|
||||
import androidx.navigation.compose.composable
|
||||
import androidx.navigation.navArgument
|
||||
import io.nekohasekai.sfa.compose.screen.dashboard.DashboardScreen
|
||||
import io.nekohasekai.sfa.compose.screen.dashboard.DashboardViewModel
|
||||
import io.nekohasekai.sfa.compose.screen.dashboard.GroupsCard
|
||||
import io.nekohasekai.sfa.compose.screen.dashboard.groups.GroupsViewModel
|
||||
import io.nekohasekai.sfa.compose.screen.connections.ConnectionDetailsRoute
|
||||
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.LogViewModel
|
||||
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.CoreSettingsScreen
|
||||
import io.nekohasekai.sfa.compose.screen.settings.PrivilegeSettingsScreen
|
||||
import io.nekohasekai.sfa.compose.screen.settings.ProfileOverrideScreen
|
||||
import io.nekohasekai.sfa.compose.screen.settings.ServiceSettingsScreen
|
||||
import io.nekohasekai.sfa.compose.screen.settings.SettingsScreen
|
||||
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
|
||||
fun SFANavHost(
|
||||
@@ -31,6 +56,9 @@ fun SFANavHost(
|
||||
serviceStatus: Status = Status.Stopped,
|
||||
showStartFab: Boolean = false,
|
||||
showStatusBar: Boolean = false,
|
||||
newProfileArgs: NewProfileArgs = NewProfileArgs(),
|
||||
onClearNewProfileArgs: () -> Unit = {},
|
||||
onOpenNewProfile: (NewProfileArgs) -> Unit = {},
|
||||
dashboardViewModel: DashboardViewModel? = null,
|
||||
logViewModel: LogViewModel? = null,
|
||||
groupsViewModel: GroupsViewModel? = null,
|
||||
@@ -48,6 +76,7 @@ fun SFANavHost(
|
||||
serviceStatus = serviceStatus,
|
||||
showStartFab = showStartFab,
|
||||
showStatusBar = showStatusBar,
|
||||
onOpenNewProfile = onOpenNewProfile,
|
||||
viewModel = dashboardViewModel,
|
||||
)
|
||||
} else {
|
||||
@@ -55,6 +84,7 @@ fun SFANavHost(
|
||||
serviceStatus = serviceStatus,
|
||||
showStartFab = showStartFab,
|
||||
showStatusBar = showStatusBar,
|
||||
onOpenNewProfile = onOpenNewProfile,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -81,11 +111,13 @@ fun SFANavHost(
|
||||
GroupsCard(
|
||||
serviceStatus = serviceStatus,
|
||||
viewModel = groupsViewModel,
|
||||
showTopBar = true,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
)
|
||||
} else {
|
||||
GroupsCard(
|
||||
serviceStatus = serviceStatus,
|
||||
showTopBar = true,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
)
|
||||
}
|
||||
@@ -97,6 +129,7 @@ fun SFANavHost(
|
||||
serviceStatus = serviceStatus,
|
||||
viewModel = connectionsViewModel,
|
||||
showTitle = false,
|
||||
showTopBar = true,
|
||||
onConnectionClick = { connectionId ->
|
||||
navController.navigate("connections/detail/${Uri.encode(connectionId)}")
|
||||
},
|
||||
@@ -106,6 +139,7 @@ fun SFANavHost(
|
||||
ConnectionsPage(
|
||||
serviceStatus = serviceStatus,
|
||||
showTitle = false,
|
||||
showTopBar = true,
|
||||
onConnectionClick = { 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 ->
|
||||
val connectionId = backStackEntry.arguments?.getString("connectionId")
|
||||
if (connectionId != null) {
|
||||
@@ -143,122 +216,82 @@ fun SFANavHost(
|
||||
// Settings subscreens with slide animations
|
||||
composable(
|
||||
route = "settings/app",
|
||||
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),
|
||||
)
|
||||
},
|
||||
enterTransition = slideInFromRight,
|
||||
exitTransition = slideOutToLeft,
|
||||
popEnterTransition = slideInFromLeft,
|
||||
popExitTransition = slideOutToRight,
|
||||
) {
|
||||
AppSettingsScreen(navController = navController)
|
||||
}
|
||||
|
||||
composable(
|
||||
route = "settings/core",
|
||||
enterTransition = {
|
||||
slideIntoContainer(
|
||||
AnimatedContentTransitionScope.SlideDirection.Left,
|
||||
animationSpec = tween(300),
|
||||
)
|
||||
},
|
||||
exitTransition = {
|
||||
slideOutOfContainer(
|
||||
AnimatedContentTransitionScope.SlideDirection.Right,
|
||||
animationSpec = tween(300),
|
||||
)
|
||||
},
|
||||
popEnterTransition = {
|
||||
slideIntoContainer(
|
||||
AnimatedContentTransitionScope.SlideDirection.Left,
|
||||
animationSpec = tween(300),
|
||||
)
|
||||
},
|
||||
popExitTransition = {
|
||||
slideOutOfContainer(
|
||||
AnimatedContentTransitionScope.SlideDirection.Right,
|
||||
animationSpec = tween(300),
|
||||
)
|
||||
},
|
||||
enterTransition = slideInFromRight,
|
||||
exitTransition = slideOutToRight,
|
||||
popEnterTransition = slideInFromRight,
|
||||
popExitTransition = slideOutToRight,
|
||||
) {
|
||||
CoreSettingsScreen(navController = navController)
|
||||
}
|
||||
|
||||
composable(
|
||||
route = "settings/service",
|
||||
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),
|
||||
)
|
||||
},
|
||||
enterTransition = slideInFromRight,
|
||||
exitTransition = slideOutToLeft,
|
||||
popEnterTransition = slideInFromLeft,
|
||||
popExitTransition = slideOutToRight,
|
||||
) {
|
||||
ServiceSettingsScreen(navController = navController)
|
||||
}
|
||||
|
||||
composable(
|
||||
route = "settings/profile_override",
|
||||
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),
|
||||
)
|
||||
},
|
||||
enterTransition = slideInFromRight,
|
||||
exitTransition = slideOutToLeft,
|
||||
popEnterTransition = slideInFromLeft,
|
||||
popExitTransition = slideOutToRight,
|
||||
) {
|
||||
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.Spacer
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
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.FileUpload
|
||||
import androidx.compose.material.icons.filled.Save
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.Card
|
||||
@@ -46,11 +46,9 @@ import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.OutlinedCard
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
@@ -68,6 +66,8 @@ import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import io.nekohasekai.sfa.R
|
||||
import io.nekohasekai.sfa.compose.base.SelectableMessageDialog
|
||||
import io.nekohasekai.sfa.compose.topbar.OverrideTopBar
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
@@ -114,7 +114,6 @@ fun NewProfileScreen(
|
||||
if (uiState.isSuccess) {
|
||||
uiState.createdProfile?.let { profile ->
|
||||
onProfileCreated(profile.id)
|
||||
onNavigateBack()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -128,28 +127,17 @@ fun NewProfileScreen(
|
||||
|
||||
// Error dialog
|
||||
if (showErrorDialog) {
|
||||
AlertDialog(
|
||||
onDismissRequest = {
|
||||
SelectableMessageDialog(
|
||||
title = stringResource(R.string.error_title),
|
||||
message = uiState.errorMessage ?: "",
|
||||
onDismiss = {
|
||||
showErrorDialog = false
|
||||
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(
|
||||
topBar = {
|
||||
OverrideTopBar {
|
||||
TopAppBar(
|
||||
title = { Text(stringResource(R.string.title_new_profile)) },
|
||||
navigationIcon = {
|
||||
@@ -165,52 +153,22 @@ fun NewProfileScreen(
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
) { paddingValues ->
|
||||
val bottomBarPadding = 88.dp + bottomInset
|
||||
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
Column(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.padding(paddingValues)
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(16.dp),
|
||||
.padding(16.dp)
|
||||
.padding(bottom = bottomBarPadding),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
// 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.unit.Velocity
|
||||
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.Clear
|
||||
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.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.FilterChip
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
@@ -54,16 +57,19 @@ import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
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.ConnectionStateFilter
|
||||
import io.nekohasekai.sfa.constant.Status
|
||||
import io.nekohasekai.sfa.compose.model.Connection
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun ConnectionsPage(
|
||||
serviceStatus: Status,
|
||||
viewModel: ConnectionsViewModel = viewModel(),
|
||||
showTitle: Boolean = true,
|
||||
showTopBar: Boolean = false,
|
||||
onConnectionClick: (String) -> Unit = {},
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
@@ -72,6 +78,14 @@ fun ConnectionsPage(
|
||||
var showSortMenu by remember { mutableStateOf(false) }
|
||||
var showConnectionsMenu by remember { mutableStateOf(false) }
|
||||
|
||||
if (showTopBar) {
|
||||
OverrideTopBar {
|
||||
TopAppBar(
|
||||
title = { Text(stringResource(R.string.title_connections)) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = modifier.fillMaxSize(),
|
||||
) {
|
||||
@@ -253,6 +267,7 @@ fun ConnectionsPage(
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun ConnectionDetailsRoute(
|
||||
connectionId: String,
|
||||
@@ -266,6 +281,30 @@ fun ConnectionDetailsRoute(
|
||||
uiState.allConnections.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) {
|
||||
viewModel.updateServiceStatus(serviceStatus)
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package io.nekohasekai.sfa.compose.screen.dashboard
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import io.nekohasekai.sfa.compose.navigation.NewProfileArgs
|
||||
import io.nekohasekai.sfa.constant.Status
|
||||
import io.nekohasekai.sfa.database.Profile
|
||||
import io.nekohasekai.sfa.utils.CommandClient
|
||||
@@ -33,6 +34,7 @@ fun DashboardCardRenderer(
|
||||
onHideAddProfileSheet: () -> Unit = {},
|
||||
onShowProfilePickerSheet: () -> Unit = {},
|
||||
onHideProfilePickerSheet: () -> Unit = {},
|
||||
onOpenNewProfile: (NewProfileArgs) -> Unit = {},
|
||||
commandClient: CommandClient? = null,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
@@ -121,9 +123,7 @@ fun DashboardCardRenderer(
|
||||
onHideAddProfileSheet = onHideAddProfileSheet,
|
||||
onShowProfilePickerSheet = onShowProfilePickerSheet,
|
||||
onHideProfilePickerSheet = onHideProfilePickerSheet,
|
||||
onImportFromFile = { /* Handled in ProfilesCard */ },
|
||||
onScanQrCode = { /* Handled in ProfilesCard */ },
|
||||
onCreateManually = { /* Handled in ProfilesCard */ },
|
||||
onOpenNewProfile = onOpenNewProfile,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,10 +9,15 @@ 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.filled.MoreVert
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.rememberModalBottomSheetState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
@@ -27,6 +32,8 @@ import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import io.nekohasekai.sfa.R
|
||||
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 kotlinx.coroutines.launch
|
||||
|
||||
@@ -41,10 +48,25 @@ fun DashboardScreen(
|
||||
serviceStatus: Status = Status.Stopped,
|
||||
showStartFab: Boolean = false,
|
||||
showStatusBar: Boolean = false,
|
||||
onOpenNewProfile: (NewProfileArgs) -> Unit = {},
|
||||
viewModel: DashboardViewModel = viewModel(),
|
||||
) {
|
||||
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
|
||||
LaunchedEffect(serviceStatus) {
|
||||
viewModel.updateServiceStatus(serviceStatus)
|
||||
@@ -174,6 +196,7 @@ fun DashboardScreen(
|
||||
onHideAddProfileSheet = viewModel::hideAddProfileSheet,
|
||||
onShowProfilePickerSheet = viewModel::showProfilePickerSheet,
|
||||
onHideProfilePickerSheet = viewModel::hideProfilePickerSheet,
|
||||
onOpenNewProfile = onOpenNewProfile,
|
||||
commandClient = viewModel.commandClient,
|
||||
modifier =
|
||||
Modifier
|
||||
@@ -213,6 +236,7 @@ fun DashboardScreen(
|
||||
onHideAddProfileSheet = viewModel::hideAddProfileSheet,
|
||||
onShowProfilePickerSheet = viewModel::showProfilePickerSheet,
|
||||
onHideProfilePickerSheet = viewModel::hideProfilePickerSheet,
|
||||
onOpenNewProfile = onOpenNewProfile,
|
||||
commandClient = viewModel.commandClient,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -64,7 +64,6 @@ import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.zIndex
|
||||
import io.nekohasekai.sfa.BuildConfig
|
||||
import io.nekohasekai.sfa.R
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@@ -78,17 +77,13 @@ fun DashboardSettingsBottomSheet(
|
||||
onResetOrder: () -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
val filteredCardOrder =
|
||||
if (BuildConfig.DEBUG) cardOrder else cardOrder.filter { it != CardGroup.Debug }
|
||||
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) }
|
||||
var reorderedList by remember(cardOrder) { mutableStateOf(cardOrder) }
|
||||
var currentVisibleCards by remember(visibleCards) { mutableStateOf(visibleCards) }
|
||||
|
||||
// Update local state when props change (e.g., after reset)
|
||||
LaunchedEffect(filteredCardOrder, filteredVisibleCards) {
|
||||
reorderedList = filteredCardOrder
|
||||
currentVisibleCards = filteredVisibleCards
|
||||
LaunchedEffect(cardOrder, visibleCards) {
|
||||
reorderedList = cardOrder
|
||||
currentVisibleCards = visibleCards
|
||||
}
|
||||
|
||||
val hapticFeedback = LocalHapticFeedback.current
|
||||
@@ -166,7 +161,7 @@ fun DashboardSettingsBottomSheet(
|
||||
listOfNotNull(
|
||||
CardGroup.UploadTraffic,
|
||||
CardGroup.DownloadTraffic,
|
||||
if (BuildConfig.DEBUG) CardGroup.Debug else null,
|
||||
CardGroup.Debug,
|
||||
CardGroup.Connections,
|
||||
CardGroup.SystemProxy,
|
||||
CardGroup.ClashMode,
|
||||
@@ -177,7 +172,7 @@ fun DashboardSettingsBottomSheet(
|
||||
CardGroup.ClashMode,
|
||||
CardGroup.UploadTraffic,
|
||||
CardGroup.DownloadTraffic,
|
||||
if (BuildConfig.DEBUG) CardGroup.Debug else null,
|
||||
CardGroup.Debug,
|
||||
CardGroup.Connections,
|
||||
CardGroup.SystemProxy,
|
||||
CardGroup.Profiles,
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
package io.nekohasekai.sfa.compose.screen.dashboard
|
||||
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import io.nekohasekai.libbox.Connections
|
||||
import io.nekohasekai.libbox.Libbox
|
||||
import io.nekohasekai.libbox.OutboundGroup
|
||||
import io.nekohasekai.libbox.StatusMessage
|
||||
import io.nekohasekai.sfa.ktx.toList
|
||||
import io.nekohasekai.sfa.bg.BoxService
|
||||
import io.nekohasekai.sfa.compose.base.BaseViewModel
|
||||
import io.nekohasekai.sfa.compose.base.UiEvent
|
||||
@@ -50,6 +52,7 @@ data class DashboardUiState(
|
||||
val isLoading: Boolean = false,
|
||||
val hasGroups: Boolean = false,
|
||||
val groupsCount: Int = 0,
|
||||
val connectionsCount: Int = 0,
|
||||
val serviceStartTime: Long? = null,
|
||||
val deprecatedNotes: List<DeprecatedNote> = emptyList(),
|
||||
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() {
|
||||
updateState {
|
||||
copy(showCardSettingsDialog = !showCardSettingsDialog)
|
||||
|
||||
@@ -26,6 +26,8 @@ import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
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.Speed
|
||||
import androidx.compose.material3.Card
|
||||
@@ -40,6 +42,7 @@ import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.SnackbarHostState
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
@@ -63,17 +66,20 @@ import androidx.lifecycle.ViewModelProvider
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import io.nekohasekai.libbox.Libbox
|
||||
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.constant.Status
|
||||
import io.nekohasekai.sfa.compose.model.Group
|
||||
import io.nekohasekai.sfa.compose.model.GroupItem
|
||||
import io.nekohasekai.sfa.utils.CommandClient
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun GroupsCard(
|
||||
serviceStatus: Status,
|
||||
commandClient: CommandClient? = null,
|
||||
viewModel: GroupsViewModel? = null,
|
||||
showTopBar: Boolean = false,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val actualViewModel: GroupsViewModel = viewModel ?: viewModel(
|
||||
@@ -88,6 +94,35 @@ fun GroupsCard(
|
||||
val snackbarHostState = remember { SnackbarHostState() }
|
||||
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
|
||||
val onToggleExpanded =
|
||||
remember(actualViewModel) {
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
package io.nekohasekai.sfa.compose.screen.dashboard
|
||||
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.widget.Toast
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
@@ -64,10 +63,10 @@ import androidx.compose.ui.unit.dp
|
||||
import io.nekohasekai.libbox.Libbox
|
||||
import io.nekohasekai.libbox.ProfileContent
|
||||
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.QRScanSheet
|
||||
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.configuration.ProfileImportHandler
|
||||
import io.nekohasekai.sfa.compose.util.QRCodeGenerator
|
||||
@@ -103,9 +102,7 @@ fun ProfilesCard(
|
||||
onHideAddProfileSheet: () -> Unit,
|
||||
onShowProfilePickerSheet: () -> Unit,
|
||||
onHideProfilePickerSheet: () -> Unit,
|
||||
onImportFromFile: () -> Unit,
|
||||
onScanQrCode: () -> Unit,
|
||||
onCreateManually: () -> Unit,
|
||||
onOpenNewProfile: (NewProfileArgs) -> Unit,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
@@ -126,28 +123,6 @@ fun ProfilesCard(
|
||||
|
||||
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 =
|
||||
rememberLauncherForActivityResult(
|
||||
ActivityResultContracts.GetContent(),
|
||||
@@ -238,9 +213,6 @@ fun ProfilesCard(
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(onImportFromFile, onScanQrCode) {
|
||||
}
|
||||
|
||||
val selectedProfile = profiles.find { it.id == selectedProfileId }
|
||||
|
||||
Card(
|
||||
@@ -458,8 +430,7 @@ fun ProfilesCard(
|
||||
ListItem(
|
||||
modifier = Modifier.clickable {
|
||||
onHideAddProfileSheet()
|
||||
val intent = Intent(context, NewProfileActivity::class.java)
|
||||
newProfileLauncher.launch(intent)
|
||||
onOpenNewProfile(NewProfileArgs())
|
||||
},
|
||||
leadingContent = {
|
||||
Icon(
|
||||
@@ -608,12 +579,12 @@ fun ProfilesCard(
|
||||
when (val parseResult = importHandler.parseQRCode(result.uri.toString())) {
|
||||
is ProfileImportHandler.QRCodeParseResult.RemoteProfile -> {
|
||||
withContext(Dispatchers.Main) {
|
||||
val newProfileIntent =
|
||||
Intent(context, NewProfileActivity::class.java).apply {
|
||||
putExtra(NewProfileActivity.EXTRA_IMPORT_NAME, parseResult.name)
|
||||
putExtra(NewProfileActivity.EXTRA_IMPORT_URL, parseResult.url)
|
||||
}
|
||||
newProfileLauncher.launch(newProfileIntent)
|
||||
onOpenNewProfile(
|
||||
NewProfileArgs(
|
||||
importName = parseResult.name,
|
||||
importUrl = parseResult.url,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
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.KeyboardOptions
|
||||
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.CheckBoxOutlineBlank
|
||||
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.FilterList
|
||||
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.RadioButtonUnchecked
|
||||
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.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
@@ -88,6 +93,7 @@ import androidx.core.content.FileProvider
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import io.nekohasekai.sfa.Application
|
||||
import io.nekohasekai.sfa.R
|
||||
import io.nekohasekai.sfa.compose.topbar.OverrideTopBar
|
||||
import io.nekohasekai.sfa.constant.Status
|
||||
import java.io.File
|
||||
import java.text.SimpleDateFormat
|
||||
@@ -100,18 +106,97 @@ fun LogScreen(
|
||||
serviceStatus: Status = Status.Stopped,
|
||||
showStartFab: 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 configuration = LocalConfiguration.current
|
||||
val isTablet = configuration.isLayoutSizeAtLeast(Configuration.SCREENLAYOUT_SIZE_LARGE)
|
||||
val listState = rememberLazyListState()
|
||||
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
|
||||
androidx.activity.compose.BackHandler(enabled = uiState.isSelectionMode) {
|
||||
viewModel.clearSelection()
|
||||
resolvedViewModel.clearSelection()
|
||||
}
|
||||
|
||||
// Track if user is at the bottom of the list
|
||||
@@ -126,7 +211,7 @@ fun LogScreen(
|
||||
// Re-enable auto-scroll when user reaches bottom
|
||||
LaunchedEffect(isAtBottom) {
|
||||
if (isAtBottom) {
|
||||
viewModel.setAutoScrollEnabled(true)
|
||||
resolvedViewModel.setAutoScrollEnabled(true)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -154,7 +239,7 @@ fun LogScreen(
|
||||
}
|
||||
|
||||
if (scrolledUp) {
|
||||
viewModel.setAutoScrollEnabled(false)
|
||||
resolvedViewModel.setAutoScrollEnabled(false)
|
||||
}
|
||||
|
||||
dragStartIndex = null
|
||||
@@ -166,7 +251,7 @@ fun LogScreen(
|
||||
}
|
||||
|
||||
// Handle scroll to bottom requests from ViewModel
|
||||
val scrollToBottomTrigger by viewModel.scrollToBottomTrigger.collectAsState()
|
||||
val scrollToBottomTrigger by resolvedViewModel.scrollToBottomTrigger.collectAsState()
|
||||
LaunchedEffect(scrollToBottomTrigger) {
|
||||
if (scrollToBottomTrigger > 0 && uiState.logs.isNotEmpty()) {
|
||||
listState.animateScrollToItem(uiState.logs.size - 1)
|
||||
@@ -175,7 +260,9 @@ fun LogScreen(
|
||||
|
||||
// Update service status in ViewModel
|
||||
LaunchedEffect(serviceStatus) {
|
||||
viewModel.updateServiceStatus(serviceStatus)
|
||||
if (showStatusInfo) {
|
||||
resolvedViewModel.updateServiceStatus(serviceStatus)
|
||||
}
|
||||
}
|
||||
|
||||
Box(
|
||||
@@ -203,7 +290,7 @@ fun LogScreen(
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
IconButton(onClick = { viewModel.clearSelection() }) {
|
||||
IconButton(onClick = { resolvedViewModel.clearSelection() }) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Close,
|
||||
contentDescription = stringResource(R.string.content_description_exit_selection_mode),
|
||||
@@ -222,9 +309,9 @@ fun LogScreen(
|
||||
Row {
|
||||
IconButton(
|
||||
onClick = {
|
||||
val selectedText = viewModel.getSelectedLogsText()
|
||||
val selectedText = resolvedViewModel.getSelectedLogsText()
|
||||
if (selectedText.isNotEmpty()) {
|
||||
val clipLabel = context.getString(R.string.title_log)
|
||||
val clipLabel = resolvedTitle
|
||||
val clip = ClipData.newPlainText(clipLabel, selectedText)
|
||||
Application.clipboard.setPrimaryClip(clip)
|
||||
Toast.makeText(
|
||||
@@ -232,7 +319,7 @@ fun LogScreen(
|
||||
context.getString(R.string.copied_to_clipboard),
|
||||
Toast.LENGTH_SHORT,
|
||||
).show()
|
||||
viewModel.clearSelection()
|
||||
resolvedViewModel.clearSelection()
|
||||
}
|
||||
},
|
||||
enabled = uiState.selectedLogIndices.isNotEmpty(),
|
||||
@@ -271,7 +358,7 @@ fun LogScreen(
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
TextButton(
|
||||
onClick = { viewModel.setLogLevel(LogLevel.Default) },
|
||||
onClick = { resolvedViewModel.setLogLevel(LogLevel.Default) },
|
||||
contentPadding = PaddingValues(horizontal = 8.dp, vertical = 0.dp),
|
||||
modifier = Modifier.height(24.dp),
|
||||
) {
|
||||
@@ -316,7 +403,7 @@ fun LogScreen(
|
||||
|
||||
OutlinedTextField(
|
||||
value = uiState.searchQuery,
|
||||
onValueChange = { viewModel.updateSearchQuery(it) },
|
||||
onValueChange = { resolvedViewModel.updateSearchQuery(it) },
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
@@ -331,7 +418,7 @@ fun LogScreen(
|
||||
},
|
||||
trailingIcon = {
|
||||
if (uiState.searchQuery.isNotEmpty()) {
|
||||
IconButton(onClick = { viewModel.updateSearchQuery("") }) {
|
||||
IconButton(onClick = { resolvedViewModel.updateSearchQuery("") }) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Delete,
|
||||
contentDescription = stringResource(R.string.content_description_clear_search),
|
||||
@@ -351,8 +438,7 @@ fun LogScreen(
|
||||
}
|
||||
}
|
||||
|
||||
if (uiState.logs.isEmpty()) {
|
||||
// Empty state
|
||||
if (uiState.errorMessage != null) {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center,
|
||||
@@ -362,12 +448,36 @@ fun LogScreen(
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
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) {
|
||||
Status.Started -> stringResource(R.string.status_started)
|
||||
Status.Starting -> stringResource(R.string.status_starting)
|
||||
Status.Stopping -> stringResource(R.string.status_stopping)
|
||||
else -> stringResource(R.string.status_default)
|
||||
}
|
||||
} else {
|
||||
emptyStateMessage
|
||||
},
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
@@ -404,13 +514,13 @@ fun LogScreen(
|
||||
isSelectionMode = uiState.isSelectionMode,
|
||||
onLongClick = {
|
||||
if (!uiState.isSelectionMode) {
|
||||
viewModel.toggleSelectionMode()
|
||||
viewModel.toggleLogSelection(index)
|
||||
resolvedViewModel.toggleSelectionMode()
|
||||
resolvedViewModel.toggleLogSelection(index)
|
||||
}
|
||||
},
|
||||
onClick = {
|
||||
if (uiState.isSelectionMode) {
|
||||
viewModel.toggleLogSelection(index)
|
||||
resolvedViewModel.toggleLogSelection(index)
|
||||
}
|
||||
},
|
||||
)
|
||||
@@ -437,7 +547,7 @@ fun LogScreen(
|
||||
uri?.let {
|
||||
try {
|
||||
context.contentResolver.openOutputStream(it)?.use { outputStream ->
|
||||
val logsText = viewModel.getAllLogsText()
|
||||
val logsText = resolvedViewModel.getAllLogsText()
|
||||
outputStream.write(logsText.toByteArray())
|
||||
outputStream.flush()
|
||||
Toast.makeText(
|
||||
@@ -460,7 +570,7 @@ fun LogScreen(
|
||||
DropdownMenu(
|
||||
expanded = uiState.isOptionsMenuOpen,
|
||||
onDismissRequest = {
|
||||
viewModel.toggleOptionsMenu()
|
||||
resolvedViewModel.toggleOptionsMenu()
|
||||
expandedLogLevel = false
|
||||
expandedSave = false
|
||||
},
|
||||
@@ -503,8 +613,8 @@ fun LogScreen(
|
||||
Text(text = level.label)
|
||||
},
|
||||
onClick = {
|
||||
viewModel.setLogLevel(level)
|
||||
viewModel.toggleOptionsMenu()
|
||||
resolvedViewModel.setLogLevel(level)
|
||||
resolvedViewModel.toggleOptionsMenu()
|
||||
expandedLogLevel = false
|
||||
},
|
||||
leadingIcon = {
|
||||
@@ -573,13 +683,10 @@ fun LogScreen(
|
||||
Text(text = stringResource(R.string.save_to_clipboard))
|
||||
},
|
||||
onClick = {
|
||||
val logsText = viewModel.getAllLogsText()
|
||||
val logsText = resolvedViewModel.getAllLogsText()
|
||||
if (logsText.isNotEmpty()) {
|
||||
val clip =
|
||||
ClipData.newPlainText(
|
||||
context.getString(R.string.title_log),
|
||||
logsText,
|
||||
)
|
||||
ClipData.newPlainText(resolvedTitle, logsText)
|
||||
Application.clipboard.setPrimaryClip(clip)
|
||||
Toast.makeText(
|
||||
context,
|
||||
@@ -593,7 +700,7 @@ fun LogScreen(
|
||||
Toast.LENGTH_SHORT,
|
||||
).show()
|
||||
}
|
||||
viewModel.toggleOptionsMenu()
|
||||
resolvedViewModel.toggleOptionsMenu()
|
||||
expandedSave = false
|
||||
},
|
||||
leadingIcon = {
|
||||
@@ -617,8 +724,8 @@ fun LogScreen(
|
||||
"yyyyMMdd_HHmmss",
|
||||
Locale.getDefault(),
|
||||
).format(Date())
|
||||
saveFileLauncher.launch("logs_$timestamp.txt")
|
||||
viewModel.toggleOptionsMenu()
|
||||
saveFileLauncher.launch("${saveFilePrefix}_$timestamp.txt")
|
||||
resolvedViewModel.toggleOptionsMenu()
|
||||
expandedSave = false
|
||||
},
|
||||
leadingIcon = {
|
||||
@@ -637,7 +744,7 @@ fun LogScreen(
|
||||
Text(text = stringResource(R.string.menu_share))
|
||||
},
|
||||
onClick = {
|
||||
val logsText = viewModel.getAllLogsText()
|
||||
val logsText = resolvedViewModel.getAllLogsText()
|
||||
if (logsText.isNotEmpty()) {
|
||||
try {
|
||||
val logsDir =
|
||||
@@ -647,7 +754,7 @@ fun LogScreen(
|
||||
"yyyyMMdd_HHmmss",
|
||||
Locale.getDefault(),
|
||||
).format(Date())
|
||||
val logFile = File(logsDir, "logs_$timestamp.txt")
|
||||
val logFile = File(logsDir, "${saveFilePrefix}_$timestamp.txt")
|
||||
logFile.writeText(logsText)
|
||||
|
||||
val uri =
|
||||
@@ -682,7 +789,7 @@ fun LogScreen(
|
||||
Toast.LENGTH_SHORT,
|
||||
).show()
|
||||
}
|
||||
viewModel.toggleOptionsMenu()
|
||||
resolvedViewModel.toggleOptionsMenu()
|
||||
expandedSave = false
|
||||
},
|
||||
leadingIcon = {
|
||||
@@ -698,7 +805,7 @@ fun LogScreen(
|
||||
HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp))
|
||||
}
|
||||
|
||||
// Clear logs option
|
||||
if (showClear) {
|
||||
DropdownMenuItem(
|
||||
text = {
|
||||
Text(
|
||||
@@ -708,8 +815,8 @@ fun LogScreen(
|
||||
)
|
||||
},
|
||||
onClick = {
|
||||
viewModel.requestClearLogs()
|
||||
viewModel.toggleOptionsMenu()
|
||||
resolvedViewModel.requestClearLogs()
|
||||
resolvedViewModel.toggleOptionsMenu()
|
||||
},
|
||||
leadingIcon = {
|
||||
Icon(
|
||||
@@ -721,6 +828,7 @@ fun LogScreen(
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// FABs - Hide during selection mode
|
||||
val padFabVisible = isTablet && (showStartFab || showStatusBar)
|
||||
@@ -746,7 +854,7 @@ fun LogScreen(
|
||||
exit = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) scaleOut() else fadeOut(),
|
||||
) {
|
||||
FloatingActionButton(
|
||||
onClick = { viewModel.scrollToBottom() },
|
||||
onClick = { resolvedViewModel.scrollToBottom() },
|
||||
containerColor = MaterialTheme.colorScheme.primaryContainer,
|
||||
contentColor = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||
) {
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
package io.nekohasekai.sfa.compose.screen.log
|
||||
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import io.nekohasekai.libbox.Libbox
|
||||
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.utils.CommandClient
|
||||
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.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.util.LinkedList
|
||||
import java.util.concurrent.atomic.AtomicLong
|
||||
|
||||
data class ProcessedLogEntry(
|
||||
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 {
|
||||
class LogViewModel : BaseLogViewModel(), CommandClient.Handler {
|
||||
companion object {
|
||||
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 commandClient =
|
||||
CommandClient(
|
||||
@@ -78,26 +25,16 @@ class LogViewModel : ViewModel(), CommandClient.Handler {
|
||||
handler = this,
|
||||
)
|
||||
|
||||
init {
|
||||
viewModelScope.launch {
|
||||
_searchQueryInternal
|
||||
.debounce(300)
|
||||
.distinctUntilChanged()
|
||||
.collect { _ ->
|
||||
updateDisplayedLogs()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun processLogEntry(entry: LogEntry): ProcessedLogEntry {
|
||||
val level = LogLevel.entries.find { it.priority == entry.level } ?: LogLevel.Default
|
||||
return ProcessedLogEntry(
|
||||
id = logIdGenerator.incrementAndGet(),
|
||||
originalEntry = entry,
|
||||
entry = LogEntryData(level = level, message = entry.message),
|
||||
annotatedString = AnsiColorUtils.ansiToAnnotatedString(entry.message),
|
||||
)
|
||||
}
|
||||
|
||||
fun updateServiceStatus(status: Status) {
|
||||
override fun updateServiceStatus(status: Status) {
|
||||
_uiState.update { it.copy(serviceStatus = status) }
|
||||
|
||||
when (status) {
|
||||
@@ -135,7 +72,7 @@ class LogViewModel : ViewModel(), CommandClient.Handler {
|
||||
updateDisplayedLogs()
|
||||
}
|
||||
|
||||
fun requestClearLogs() {
|
||||
override fun requestClearLogs() {
|
||||
viewModelScope.launch {
|
||||
withContext(Dispatchers.IO) {
|
||||
runCatching {
|
||||
@@ -168,10 +105,9 @@ class LogViewModel : ViewModel(), CommandClient.Handler {
|
||||
}
|
||||
}
|
||||
|
||||
fun togglePause() {
|
||||
override fun togglePause() {
|
||||
val currentState = _uiState.value
|
||||
if (currentState.isPaused && bufferedLogs.isNotEmpty()) {
|
||||
// When resuming, add buffered logs
|
||||
val totalSize = allLogs.size + bufferedLogs.size
|
||||
val removeCount = (totalSize - maxLines).coerceAtLeast(0)
|
||||
|
||||
@@ -189,121 +125,6 @@ class LogViewModel : ViewModel(), CommandClient.Handler {
|
||||
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() {
|
||||
super.onCleared()
|
||||
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.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
@@ -62,6 +61,7 @@ import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.ExperimentalComposeUiApi
|
||||
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.input.key.Key
|
||||
@@ -82,6 +82,7 @@ import androidx.compose.ui.viewinterop.AndroidView
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.blacksquircle.ui.language.json.JsonLanguage
|
||||
import io.nekohasekai.sfa.R
|
||||
import io.nekohasekai.sfa.compose.topbar.OverrideTopBar
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@@ -137,7 +138,91 @@ fun EditProfileContentScreen(
|
||||
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
|
||||
.fillMaxSize()
|
||||
@@ -221,106 +306,17 @@ fun EditProfileContentScreen(
|
||||
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)
|
||||
AnimatedVisibility(
|
||||
visible = uiState.showSearchBar,
|
||||
enter = slideInVertically(initialOffsetY = { -it }) + fadeIn() + expandVertically(),
|
||||
exit = slideOutVertically(targetOffsetY = { -it }) + fadeOut() + shrinkVertically(),
|
||||
enter = expandVertically(expandFrom = Alignment.Top) + fadeIn(),
|
||||
exit = shrinkVertically(shrinkTowards = Alignment.Top) + fadeOut(),
|
||||
) {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
color = MaterialTheme.colorScheme.surfaceContainer,
|
||||
shadowElevation = 4.dp,
|
||||
tonalElevation = 2.dp,
|
||||
) {
|
||||
Row(
|
||||
modifier =
|
||||
@@ -436,6 +432,7 @@ fun EditProfileContentScreen(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.clipToBounds()
|
||||
.weight(1f),
|
||||
) {
|
||||
// Editor
|
||||
@@ -829,8 +826,6 @@ fun EditProfileContentScreen(
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Unsaved changes dialog
|
||||
if (showUnsavedChangesDialog) {
|
||||
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.Spacer
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.navigationBars
|
||||
@@ -44,7 +45,6 @@ import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.LinearProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.Text
|
||||
@@ -66,6 +66,8 @@ import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
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.RelativeTimeFormatter
|
||||
import io.nekohasekai.sfa.compose.util.icons.MaterialIconsLibrary
|
||||
@@ -112,23 +114,13 @@ fun EditProfileScreen(
|
||||
|
||||
// Error dialog
|
||||
if (showErrorDialog) {
|
||||
AlertDialog(
|
||||
onDismissRequest = {
|
||||
SelectableMessageDialog(
|
||||
title = stringResource(R.string.error_title),
|
||||
message = uiState.errorMessage ?: "",
|
||||
onDismiss = {
|
||||
showErrorDialog = false
|
||||
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
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
OverrideTopBar {
|
||||
TopAppBar(
|
||||
title = { Text(stringResource(R.string.title_edit_profile)) },
|
||||
navigationIcon = {
|
||||
@@ -192,56 +183,21 @@ fun EditProfileScreen(
|
||||
containerColor = MaterialTheme.colorScheme.surface,
|
||||
),
|
||||
)
|
||||
},
|
||||
bottomBar = {
|
||||
AnimatedVisibility(
|
||||
visible = uiState.hasChanges,
|
||||
enter = fadeIn() + expandVertically(),
|
||||
exit = fadeOut() + shrinkVertically(),
|
||||
) {
|
||||
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,
|
||||
)
|
||||
}
|
||||
|
||||
val bottomInset =
|
||||
with(LocalDensity.current) {
|
||||
WindowInsets.navigationBars.getBottom(this).toDp()
|
||||
}
|
||||
val bottomBarPadding =
|
||||
if (uiState.hasChanges) {
|
||||
88.dp + bottomInset
|
||||
} else {
|
||||
Icon(
|
||||
Icons.Default.Save,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(18.dp),
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(stringResource(R.string.save))
|
||||
0.dp
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
) { paddingValues ->
|
||||
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.padding(paddingValues),
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
) {
|
||||
// Progress indicator at top (only for initial loading)
|
||||
if (uiState.isLoading) {
|
||||
@@ -256,7 +212,8 @@ fun EditProfileScreen(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(16.dp),
|
||||
.padding(16.dp)
|
||||
.padding(bottom = bottomBarPadding),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
// 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.Spacer
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.foundation.layout.aspectRatio
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
@@ -44,7 +45,6 @@ import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
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.unit.dp
|
||||
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.icons.IconCategory
|
||||
import io.nekohasekai.sfa.compose.util.icons.MaterialIconsLibrary
|
||||
@@ -99,8 +100,7 @@ fun IconSelectionScreen(
|
||||
}
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
OverrideTopBar {
|
||||
TopAppBar(
|
||||
title = { Text(stringResource(R.string.select_icon)) },
|
||||
navigationIcon = {
|
||||
@@ -147,65 +147,29 @@ fun IconSelectionScreen(
|
||||
containerColor = MaterialTheme.colorScheme.surface,
|
||||
),
|
||||
)
|
||||
},
|
||||
bottomBar = {
|
||||
// Footer with current selection info
|
||||
}
|
||||
|
||||
val currentIcon =
|
||||
currentIconId?.let { id ->
|
||||
MaterialIconsLibrary.getIconById(id)?.let { 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,
|
||||
)
|
||||
MaterialIconsLibrary.getIconById(id)?.let { icon -> id to icon }
|
||||
}
|
||||
val bottomInset =
|
||||
with(LocalDensity.current) {
|
||||
WindowInsets.navigationBars.getBottom(this).toDp()
|
||||
}
|
||||
val bottomBarPadding =
|
||||
if (currentIcon != null) {
|
||||
88.dp + bottomInset
|
||||
} else {
|
||||
0.dp
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
) { paddingValues ->
|
||||
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
Column(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.padding(paddingValues),
|
||||
.padding(bottom = bottomBarPadding),
|
||||
) {
|
||||
// Show search bar with animation
|
||||
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.min
|
||||
@@ -16,8 +16,6 @@ import androidx.lifecycle.LifecycleOwner
|
||||
import io.nekohasekai.libbox.Libbox
|
||||
import io.nekohasekai.sfa.qrs.QRSDecoder
|
||||
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 kotlinx.coroutines.flow.MutableStateFlow
|
||||
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.ImageProxy
|
||||
@@ -20,6 +20,7 @@ import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.outlined.AdminPanelSettings
|
||||
import androidx.compose.material.icons.outlined.Autorenew
|
||||
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.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.RadioButton
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
@@ -62,11 +66,14 @@ import androidx.navigation.NavController
|
||||
import io.nekohasekai.sfa.BuildConfig
|
||||
import io.nekohasekai.sfa.R
|
||||
import io.nekohasekai.sfa.database.Settings
|
||||
import io.nekohasekai.sfa.compose.topbar.OverrideTopBar
|
||||
import io.nekohasekai.sfa.compose.component.UpdateAvailableDialog
|
||||
import io.nekohasekai.sfa.update.UpdateCheckException
|
||||
import io.nekohasekai.sfa.update.UpdateState
|
||||
import io.nekohasekai.sfa.update.UpdateTrack
|
||||
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.Job
|
||||
import kotlinx.coroutines.launch
|
||||
@@ -75,6 +82,20 @@ import kotlinx.coroutines.withContext
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
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 scope = rememberCoroutineScope()
|
||||
val hasUpdate by UpdateState.hasUpdate
|
||||
@@ -87,6 +108,8 @@ fun AppSettingsScreen(navController: NavController) {
|
||||
|
||||
var silentInstallEnabled by remember { mutableStateOf(Settings.silentInstallEnabled) }
|
||||
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 autoUpdateEnabled by remember { mutableStateOf(Settings.autoUpdateEnabled) }
|
||||
var showInstallMethodMenu by remember { mutableStateOf(false) }
|
||||
@@ -98,8 +121,13 @@ fun AppSettingsScreen(navController: NavController) {
|
||||
var downloadError by remember { mutableStateOf<String?>(null) }
|
||||
var showUpdateAvailableDialog by remember { mutableStateOf(false) }
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
HookStatusClient.refresh()
|
||||
}
|
||||
|
||||
// Re-check method availability when returning from background (e.g., after granting permission)
|
||||
LifecycleEventEffect(Lifecycle.Event.ON_RESUME) {
|
||||
HookStatusClient.refresh()
|
||||
if (silentInstallEnabled) {
|
||||
scope.launch {
|
||||
val success = withContext(Dispatchers.IO) {
|
||||
@@ -216,14 +244,10 @@ fun AppSettingsScreen(navController: NavController) {
|
||||
downloadError = null
|
||||
downloadJob = scope.launch {
|
||||
try {
|
||||
val result = withContext(Dispatchers.IO) {
|
||||
withContext(Dispatchers.IO) {
|
||||
Vendor.downloadAndInstall(context, updateInfo!!.downloadUrl)
|
||||
}
|
||||
if (result.isFailure) {
|
||||
downloadError = result.exceptionOrNull()?.message
|
||||
} else {
|
||||
showDownloadDialog = false
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
downloadError = e.message
|
||||
}
|
||||
@@ -473,7 +497,7 @@ fun AppSettingsScreen(navController: NavController) {
|
||||
),
|
||||
)
|
||||
|
||||
if (silentInstallEnabled) {
|
||||
if (silentInstallEnabled && !xposedActivated) {
|
||||
ListItem(
|
||||
headlineContent = {
|
||||
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) {
|
||||
ListItem(
|
||||
headlineContent = {
|
||||
|
||||
@@ -17,6 +17,7 @@ import android.content.Intent
|
||||
import android.provider.DocumentsContract
|
||||
import android.widget.Toast
|
||||
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.FolderOpen
|
||||
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.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
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.Switch
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
@@ -46,13 +50,29 @@ import androidx.compose.ui.unit.dp
|
||||
import androidx.navigation.NavController
|
||||
import io.nekohasekai.libbox.Libbox
|
||||
import io.nekohasekai.sfa.R
|
||||
import io.nekohasekai.sfa.compose.topbar.OverrideTopBar
|
||||
import io.nekohasekai.sfa.database.Settings
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
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 scope = rememberCoroutineScope()
|
||||
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.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.outlined.AppShortcut
|
||||
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.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
|
||||
@@ -34,6 +37,7 @@ import androidx.compose.material3.RadioButton
|
||||
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.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
@@ -47,12 +51,16 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalLifecycleOwner
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.navigation.NavController
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleEventObserver
|
||||
import io.nekohasekai.sfa.R
|
||||
import io.nekohasekai.sfa.compose.topbar.OverrideTopBar
|
||||
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 kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.async
|
||||
@@ -60,8 +68,23 @@ import kotlinx.coroutines.awaitAll
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
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 scope = rememberCoroutineScope()
|
||||
|
||||
@@ -73,7 +96,7 @@ fun ProfileOverrideScreen(navController: NavController) {
|
||||
var showRootDialog 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) }
|
||||
val useRootMode = packageQueryMode == Settings.PACKAGE_QUERY_MODE_ROOT
|
||||
|
||||
@@ -82,20 +105,34 @@ fun ProfileOverrideScreen(navController: NavController) {
|
||||
val isShizukuPermissionGranted by PackageQueryManager.shizukuPermissionGranted.collectAsState()
|
||||
val isShizukuAvailable = isShizukuBinderReady && isShizukuPermissionGranted
|
||||
|
||||
DisposableEffect(needsPrivilegedQuery) {
|
||||
if (needsPrivilegedQuery) {
|
||||
DisposableEffect(showModeSelector) {
|
||||
if (showModeSelector) {
|
||||
PackageQueryManager.registerListeners()
|
||||
}
|
||||
onDispose {
|
||||
if (needsPrivilegedQuery) {
|
||||
if (showModeSelector) {
|
||||
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)
|
||||
LaunchedEffect(isShizukuAvailable, useRootMode) {
|
||||
if (needsPrivilegedQuery && !useRootMode && !isShizukuAvailable && perAppProxyEnabled) {
|
||||
if (showModeSelector && !useRootMode && !isShizukuAvailable && perAppProxyEnabled) {
|
||||
perAppProxyEnabled = false
|
||||
withContext(Dispatchers.IO) {
|
||||
Settings.perAppProxyEnabled = false
|
||||
@@ -105,7 +142,7 @@ fun ProfileOverrideScreen(navController: NavController) {
|
||||
|
||||
// Auto-close dialog and enable feature when Shizuku becomes available
|
||||
LaunchedEffect(isShizukuAvailable) {
|
||||
if (needsPrivilegedQuery && isShizukuAvailable && showShizukuDialog) {
|
||||
if (showModeSelector && isShizukuAvailable && showShizukuDialog) {
|
||||
showShizukuDialog = false
|
||||
perAppProxyEnabled = true
|
||||
withContext(Dispatchers.IO) {
|
||||
@@ -212,7 +249,7 @@ fun ProfileOverrideScreen(navController: NavController) {
|
||||
}
|
||||
|
||||
// Section: Per-App Proxy
|
||||
val canUsePerAppProxy = if (needsPrivilegedQuery) {
|
||||
val canUsePerAppProxy = if (showModeSelector) {
|
||||
if (useRootMode) true else isShizukuAvailable
|
||||
} else {
|
||||
true
|
||||
@@ -237,7 +274,7 @@ fun ProfileOverrideScreen(navController: NavController) {
|
||||
) {
|
||||
Column {
|
||||
// Mode selector (only when privileged query is needed)
|
||||
if (needsPrivilegedQuery) {
|
||||
if (showModeSelector) {
|
||||
val modeEnabled = !perAppProxyEnabled
|
||||
val disabledAlpha = 0.38f
|
||||
ListItem(
|
||||
@@ -301,7 +338,7 @@ fun ProfileOverrideScreen(navController: NavController) {
|
||||
Switch(
|
||||
checked = perAppProxyEnabled,
|
||||
onCheckedChange = { checked ->
|
||||
if (checked && needsPrivilegedQuery) {
|
||||
if (checked && showModeSelector) {
|
||||
if (useRootMode) {
|
||||
showRootDialog = true
|
||||
} else {
|
||||
@@ -329,7 +366,7 @@ fun ProfileOverrideScreen(navController: NavController) {
|
||||
},
|
||||
modifier =
|
||||
Modifier.clip(
|
||||
if (needsPrivilegedQuery) {
|
||||
if (showModeSelector) {
|
||||
RoundedCornerShape(0.dp)
|
||||
} else if (perAppProxyEnabled && canUsePerAppProxy) {
|
||||
RoundedCornerShape(topStart = 12.dp, topEnd = 12.dp)
|
||||
@@ -383,8 +420,7 @@ fun ProfileOverrideScreen(navController: NavController) {
|
||||
},
|
||||
modifier =
|
||||
Modifier.clickable(enabled = manageEnabled) {
|
||||
val intent = Intent(context, PerAppProxyActivity::class.java)
|
||||
context.startActivity(intent)
|
||||
navController.navigate("settings/profile_override/manage")
|
||||
},
|
||||
colors =
|
||||
ListItemDefaults.colors(
|
||||
@@ -674,7 +710,7 @@ private suspend fun scanAllChinaApps(): Set<String> = withContext(Dispatchers.De
|
||||
val chinaApps = mutableSetOf<String>()
|
||||
installedPackages.map { packageInfo ->
|
||||
async {
|
||||
if (PerAppProxyActivity.scanChinaPackage(packageInfo)) {
|
||||
if (PerAppProxyScanner.scanChinaPackage(packageInfo)) {
|
||||
synchronized(chinaApps) {
|
||||
chinaApps.add(packageInfo.packageName)
|
||||
}
|
||||
|
||||
@@ -19,18 +19,22 @@ import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.outlined.BatteryChargingFull
|
||||
import androidx.compose.material.icons.outlined.Memory
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
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.OutlinedButton
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
@@ -51,16 +55,32 @@ import io.nekohasekai.sfa.R
|
||||
import io.nekohasekai.sfa.bg.ServiceConnection
|
||||
import io.nekohasekai.sfa.compose.base.GlobalEventBus
|
||||
import io.nekohasekai.sfa.compose.base.UiEvent
|
||||
import io.nekohasekai.sfa.compose.topbar.OverrideTopBar
|
||||
import io.nekohasekai.sfa.database.Settings
|
||||
import io.nekohasekai.sfa.ktx.launchCustomTab
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun ServiceSettingsScreen(
|
||||
navController: NavController,
|
||||
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 scope = rememberCoroutineScope()
|
||||
// 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.SwapHoriz
|
||||
import androidx.compose.material.icons.outlined.Tune
|
||||
import androidx.compose.material.icons.outlined.AdminPanelSettings
|
||||
import androidx.compose.material3.Badge
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
@@ -32,8 +33,10 @@ import androidx.compose.material3.ListItem
|
||||
import androidx.compose.material3.ListItemDefaults
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
@@ -48,20 +51,33 @@ import androidx.compose.ui.unit.dp
|
||||
import androidx.navigation.NavController
|
||||
import io.nekohasekai.sfa.BuildConfig
|
||||
import io.nekohasekai.sfa.R
|
||||
import io.nekohasekai.sfa.compose.topbar.OverrideTopBar
|
||||
import io.nekohasekai.sfa.database.Settings
|
||||
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.launch
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun SettingsScreen(navController: NavController) {
|
||||
OverrideTopBar {
|
||||
TopAppBar(
|
||||
title = { Text(stringResource(R.string.title_settings)) },
|
||||
)
|
||||
}
|
||||
|
||||
val context = LocalContext.current
|
||||
val scope = rememberCoroutineScope()
|
||||
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) }
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
HookStatusClient.refresh()
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||
val pm = context.getSystemService(PowerManager::class.java)
|
||||
isBatteryOptimizationIgnored =
|
||||
@@ -183,13 +199,43 @@ fun SettingsScreen(navController: NavController) {
|
||||
},
|
||||
modifier =
|
||||
Modifier
|
||||
.clip(RoundedCornerShape(bottomStart = 12.dp, bottomEnd = 12.dp))
|
||||
.clickable { navController.navigate("settings/profile_override") },
|
||||
colors =
|
||||
ListItemDefaults.colors(
|
||||
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)
|
||||
|
||||
fun ansiToAnnotatedString(text: String): AnnotatedString {
|
||||
val cleanText = text.replace(ansiRegex, "")
|
||||
val cleanText = stripAnsi(text)
|
||||
val matches = ansiRegex.findAll(text).toList()
|
||||
|
||||
if (matches.isEmpty()) {
|
||||
@@ -65,6 +65,8 @@ object AnsiColorUtils {
|
||||
}
|
||||
}
|
||||
|
||||
fun stripAnsi(text: String): String = text.replace(ansiRegex, "")
|
||||
|
||||
private fun parseAnsiCode(code: String): SpanStyle? {
|
||||
val colorCodes = code.substringAfter('[').substringBefore('m').split(';')
|
||||
|
||||
|
||||
@@ -23,6 +23,11 @@ object SettingsKey {
|
||||
|
||||
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
|
||||
const val DASHBOARD_ITEM_ORDER = "dashboard_item_order"
|
||||
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 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 dashboardDisabledItems by dataStore.stringSet(SettingsKey.DASHBOARD_DISABLED_ITEMS) { emptySet() }
|
||||
|
||||
|
||||
@@ -1,26 +1,52 @@
|
||||
package io.nekohasekai.sfa.ktx
|
||||
|
||||
import android.content.ClipData
|
||||
import android.content.ClipboardManager
|
||||
import android.content.Context
|
||||
import androidx.annotation.StringRes
|
||||
import android.widget.ScrollView
|
||||
import android.widget.TextView
|
||||
import android.widget.Toast
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
import io.nekohasekai.sfa.R
|
||||
|
||||
fun Context.errorDialogBuilder(
|
||||
@StringRes messageId: Int,
|
||||
): MaterialAlertDialogBuilder {
|
||||
return MaterialAlertDialogBuilder(this)
|
||||
.setTitle(R.string.error_title)
|
||||
.setMessage(messageId)
|
||||
.setPositiveButton(android.R.string.ok, null)
|
||||
return errorDialogBuilder(getString(messageId))
|
||||
}
|
||||
|
||||
fun Context.errorDialogBuilder(message: String): MaterialAlertDialogBuilder {
|
||||
val contentView = buildSelectableMessageView(message)
|
||||
return MaterialAlertDialogBuilder(this)
|
||||
.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)
|
||||
}
|
||||
|
||||
fun Context.errorDialogBuilder(exception: Throwable): MaterialAlertDialogBuilder {
|
||||
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 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
|
||||
|
||||
interface VendorInterface {
|
||||
@@ -35,6 +35,12 @@ interface VendorInterface {
|
||||
*/
|
||||
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
|
||||
* @return true if silent install is supported (Other flavor only)
|
||||
@@ -63,8 +69,8 @@ interface VendorInterface {
|
||||
* Download and install an APK update
|
||||
* @param context The context
|
||||
* @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> =
|
||||
Result.failure(UnsupportedOperationException("Not supported in this flavor"))
|
||||
suspend fun downloadAndInstall(context: android.content.Context, downloadUrl: String): Unit =
|
||||
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