Add vpn hide xposed module

This commit is contained in:
世界
2026-01-07 20:31:27 +08:00
parent 8a8686b3df
commit cd83cbfe9e
152 changed files with 12994 additions and 2782 deletions

2
.gitignore vendored
View File

@@ -3,7 +3,7 @@
/local.properties /local.properties
/.idea/ /.idea/
.DS_Store .DS_Store
/build build/
/captures /captures
.externalNativeBuild .externalNativeBuild
.cxx .cxx

View File

@@ -1,3 +1,5 @@
import org.gradle.api.file.DuplicatesStrategy
import org.gradle.api.tasks.Sync
import org.jetbrains.kotlin.gradle.dsl.JvmTarget import org.jetbrains.kotlin.gradle.dsl.JvmTarget
import org.jetbrains.kotlin.gradle.tasks.KotlinCompile import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
import org.jlleitschuh.gradle.ktlint.reporter.ReporterType import org.jlleitschuh.gradle.ktlint.reporter.ReporterType
@@ -125,6 +127,7 @@ android {
} }
getByName("otherLegacy") { getByName("otherLegacy") {
java.srcDirs("src/minApi21/java", "src/github/java") java.srcDirs("src/minApi21/java", "src/github/java")
aidl.srcDirs("src/minApi23/aidl")
} }
} }
@@ -138,8 +141,8 @@ android {
} }
compileOptions { compileOptions {
sourceCompatibility = JavaVersion.VERSION_1_8 sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_1_8 targetCompatibility = JavaVersion.VERSION_17
} }
buildFeatures { buildFeatures {
@@ -246,10 +249,8 @@ dependencies {
val shizukuVersion = "12.2.0" val shizukuVersion = "12.2.0"
"playImplementation"("dev.rikka.shizuku:api:$shizukuVersion") "playImplementation"("dev.rikka.shizuku:api:$shizukuVersion")
"playImplementation"("dev.rikka.shizuku:provider:$shizukuVersion") "playImplementation"("dev.rikka.shizuku:provider:$shizukuVersion")
"playImplementation"("org.lsposed.hiddenapibypass:hiddenapibypass:4.3")
"otherImplementation"("dev.rikka.shizuku:api:$shizukuVersion") "otherImplementation"("dev.rikka.shizuku:api:$shizukuVersion")
"otherImplementation"("dev.rikka.shizuku:provider:$shizukuVersion") "otherImplementation"("dev.rikka.shizuku:provider:$shizukuVersion")
"otherImplementation"("org.lsposed.hiddenapibypass:hiddenapibypass:4.3")
// libsu for ROOT package query (all flavors) // libsu for ROOT package query (all flavors)
val libsuVersion = "6.0.0" val libsuVersion = "6.0.0"
@@ -309,6 +310,10 @@ dependencies {
implementation("sh.calvin.reorderable:reorderable:3.0.0") implementation("sh.calvin.reorderable:reorderable:3.0.0")
implementation("com.github.jeziellago:compose-markdown:0.5.4") implementation("com.github.jeziellago:compose-markdown:0.5.4")
implementation("org.kodein.emoji:emoji-kt:2.3.0") implementation("org.kodein.emoji:emoji-kt:2.3.0")
// Xposed API for self-hooking VPN hide module
compileOnly("de.robv.android.xposed:api:82")
compileOnly(project(":libxposed-api"))
} }
val playCredentialsJSON = rootProject.file("service-account-credentials.json") val playCredentialsJSON = rootProject.file("service-account-credentials.json")
@@ -329,7 +334,7 @@ if (playCredentialsJSON.exists()) {
tasks.withType<KotlinCompile>().configureEach { tasks.withType<KotlinCompile>().configureEach {
compilerOptions { compilerOptions {
jvmTarget.set(JvmTarget.JVM_1_8) jvmTarget.set(JvmTarget.JVM_17)
} }
} }

View File

@@ -27,6 +27,14 @@ class GitHubUpdateChecker : Closeable {
private val json = Json { ignoreUnknownKeys = true } private val json = Json { ignoreUnknownKeys = true }
fun checkUpdate(track: UpdateTrack): UpdateInfo? { fun checkUpdate(track: UpdateTrack): UpdateInfo? {
return getLatestUpdate(track, checkVersion = true)
}
fun forceGetLatestUpdate(track: UpdateTrack): UpdateInfo? {
return getLatestUpdate(track, checkVersion = false)
}
private fun getLatestUpdate(track: UpdateTrack, checkVersion: Boolean): UpdateInfo? {
val includePrerelease = track == UpdateTrack.BETA val includePrerelease = track == UpdateTrack.BETA
val release = getLatestRelease(includePrerelease) ?: return null val release = getLatestRelease(includePrerelease) ?: return null
@@ -36,7 +44,7 @@ class GitHubUpdateChecker : Closeable {
val metadata = downloadMetadata(release)!! val metadata = downloadMetadata(release)!!
if (metadata.versionCode <= BuildConfig.VERSION_CODE) { if (checkVersion && metadata.versionCode <= BuildConfig.VERSION_CODE) {
return null return null
} }

View File

@@ -1,12 +1,18 @@
package io.nekohasekai.sfa.vendor package io.nekohasekai.sfa.vendor
import android.content.Intent
import android.content.ServiceConnection
import android.os.IBinder
import android.os.ParcelFileDescriptor
import com.topjohnwu.superuser.ipc.RootService
import io.nekohasekai.sfa.Application
import io.nekohasekai.sfa.bg.IRootService
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import java.io.BufferedReader
import java.io.BufferedWriter
import java.io.File import java.io.File
import java.io.InputStreamReader import kotlin.coroutines.resume
import java.io.OutputStreamWriter import kotlin.coroutines.resumeWithException
object RootInstaller { object RootInstaller {
@@ -20,20 +26,63 @@ object RootInstaller {
} }
} }
suspend fun install(apkFile: File): Result<Unit> = withContext(Dispatchers.IO) { suspend fun install(apkFile: File) {
try { withContext(Dispatchers.IO) {
val process = Runtime.getRuntime().exec(arrayOf("su", "-c", "pm install -r \"${apkFile.absolutePath}\"")) bindRootService().use { handle ->
val reader = BufferedReader(InputStreamReader(process.inputStream)) ParcelFileDescriptor.open(apkFile, ParcelFileDescriptor.MODE_READ_ONLY).use { pfd ->
val output = reader.readText() handle.service.installPackage(
val exitCode = process.waitFor() pfd,
apkFile.length(),
if (exitCode == 0 && output.contains("Success")) { android.os.Process.myUserHandle().hashCode()
Result.success(Unit) )
} else { }
Result.failure(Exception("Installation failed: $output"))
} }
} catch (e: Exception) { }
Result.failure(e) }
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 {
null
}
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)
} }
} }
} }

View File

@@ -14,39 +14,34 @@ object SystemPackageInstaller {
return Build.VERSION.SDK_INT >= Build.VERSION_CODES.S return Build.VERSION.SDK_INT >= Build.VERSION_CODES.S
} }
fun install(context: Context, apkFile: File): Result<Unit> { fun install(context: Context, apkFile: File) {
return try { val packageInstaller = context.packageManager.packageInstaller
val packageInstaller = context.packageManager.packageInstaller val params = AndroidPackageInstaller.SessionParams(AndroidPackageInstaller.SessionParams.MODE_FULL_INSTALL)
val params = AndroidPackageInstaller.SessionParams(AndroidPackageInstaller.SessionParams.MODE_FULL_INSTALL) params.setAppPackageName(context.packageName)
params.setAppPackageName(context.packageName) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { params.setRequireUserAction(AndroidPackageInstaller.SessionParams.USER_ACTION_NOT_REQUIRED)
params.setRequireUserAction(AndroidPackageInstaller.SessionParams.USER_ACTION_NOT_REQUIRED) }
val sessionId = packageInstaller.createSession(params)
packageInstaller.openSession(sessionId).use { session ->
session.openWrite("update.apk", 0, apkFile.length()).use { outputStream ->
FileInputStream(apkFile).use { inputStream ->
inputStream.copyTo(outputStream)
}
session.fsync(outputStream)
} }
val sessionId = packageInstaller.createSession(params) val intent = Intent(context, InstallResultReceiver::class.java).apply {
packageInstaller.openSession(sessionId).use { session -> action = InstallResultReceiver.ACTION_INSTALL_COMPLETE
session.openWrite("update.apk", 0, apkFile.length()).use { outputStream ->
FileInputStream(apkFile).use { inputStream ->
inputStream.copyTo(outputStream)
}
session.fsync(outputStream)
}
val intent = Intent(context, InstallResultReceiver::class.java).apply {
action = InstallResultReceiver.ACTION_INSTALL_COMPLETE
}
val pendingIntent = PendingIntent.getBroadcast(
context,
sessionId,
intent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_MUTABLE
)
session.commit(pendingIntent.intentSender)
} }
Result.success(Unit) val pendingIntent = PendingIntent.getBroadcast(
} catch (e: Exception) { context,
Result.failure(e) sessionId,
intent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_MUTABLE
)
session.commit(pendingIntent.intentSender)
} }
} }
} }

View File

@@ -77,13 +77,8 @@ class UpdateWorker(
val apkFile = ApkDownloader().use { it.download(updateInfo.downloadUrl) } val apkFile = ApkDownloader().use { it.download(updateInfo.downloadUrl) }
Log.d(TAG, "Installing update...") Log.d(TAG, "Installing update...")
val result = ApkInstaller.install(appContext, apkFile) ApkInstaller.install(appContext, apkFile)
Log.d(TAG, "Update installed successfully")
if (result.isSuccess) {
Log.d(TAG, "Update installed successfully")
} else {
Log.e(TAG, "Update installation failed", result.exceptionOrNull())
}
} else { } else {
Log.d(TAG, "Silent install not available, update will be shown on next app launch") Log.d(TAG, "Silent install not available, update will be shown on next app launch")
} }

View File

@@ -33,6 +33,7 @@
android:name=".Application" android:name=".Application"
android:allowBackup="true" android:allowBackup="true"
android:dataExtractionRules="@xml/data_extraction_rules" android:dataExtractionRules="@xml/data_extraction_rules"
android:description="@string/xposed_description"
android:fullBackupContent="@xml/backup_rules" android:fullBackupContent="@xml/backup_rules"
android:icon="@mipmap/ic_launcher" android:icon="@mipmap/ic_launcher"
android:label="@string/app_name" android:label="@string/app_name"
@@ -41,17 +42,22 @@
tools:targetApi="31"> tools:targetApi="31">
<activity <activity
android:name=".LauncherActivity" android:name=".compose.MainActivity"
android:exported="true" android:exported="true"
android:icon="@mipmap/ic_launcher" android:icon="@mipmap/ic_launcher"
android:theme="@style/AppTheme.Translucent"> android:launchMode="singleTask"
android:theme="@style/AppTheme">
<intent-filter> <intent-filter>
<action android:name="android.intent.action.MAIN" /> <action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" /> <category android:name="android.intent.category.LAUNCHER" />
</intent-filter> </intent-filter>
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="de.robv.android.xposed.category.MODULE_SETTINGS" />
</intent-filter>
<intent-filter> <intent-filter>
<action android:name="android.intent.action.VIEW" /> <action android:name="android.intent.action.VIEW" />
@@ -90,32 +96,6 @@
</intent-filter> </intent-filter>
</activity> </activity>
<activity
android:name=".compose.MainActivity"
android:exported="false"
android:icon="@mipmap/ic_launcher"
android:launchMode="singleTask"
android:theme="@style/AppTheme">
</activity>
<activity
android:name="io.nekohasekai.sfa.compose.NewProfileActivity"
android:exported="false"
android:theme="@style/AppTheme" />
<activity
android:name="io.nekohasekai.sfa.compose.EditProfileActivity"
android:exported="false"
android:theme="@style/AppTheme" />
<activity
android:name="io.nekohasekai.sfa.compose.GroupsActivity"
android:exported="false"
android:theme="@style/AppTheme" />
<activity
android:name="io.nekohasekai.sfa.ui.profileoverride.PerAppProxyActivity"
android:exported="false" />
<service <service
android:name=".bg.TileService" android:name=".bg.TileService"
android:directBootAware="true" android:directBootAware="true"
@@ -159,6 +139,11 @@
</intent-filter> </intent-filter>
</receiver> </receiver>
<provider
android:name="io.github.libxposed.service.XposedProvider"
android:authorities="${applicationId}.XposedService"
android:exported="true" />
<provider <provider
android:name="androidx.core.content.FileProvider" android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.cache" android:authorities="${applicationId}.cache"

View File

@@ -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;
}

View File

@@ -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;
}

View 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;
}

View 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;
}

View File

@@ -0,0 +1,3 @@
package io.nekohasekai.sfa.bg;
parcelable LogEntry;

View File

@@ -0,0 +1,3 @@
package io.nekohasekai.sfa.bg;
parcelable PackageEntry;

View File

@@ -0,0 +1,3 @@
package io.nekohasekai.sfa.bg;
parcelable ParceledListSlice;

View File

@@ -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));
}
}
}

View File

@@ -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;
}
}

View 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);
}
}
}

View File

@@ -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();
}
}
}
}

View File

@@ -16,6 +16,9 @@ import io.nekohasekai.libbox.SetupOptions
import io.nekohasekai.sfa.bg.AppChangeReceiver import io.nekohasekai.sfa.bg.AppChangeReceiver
import io.nekohasekai.sfa.bg.UpdateProfileWork import io.nekohasekai.sfa.bg.UpdateProfileWork
import io.nekohasekai.sfa.constant.Bugs import io.nekohasekai.sfa.constant.Bugs
import io.nekohasekai.sfa.utils.HookModuleUpdateNotifier
import io.nekohasekai.sfa.utils.PrivilegeSettingsClient
import io.nekohasekai.sfa.utils.HookStatusClient
import io.nekohasekai.sfa.vendor.Vendor import io.nekohasekai.sfa.vendor.Vendor
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.GlobalScope
@@ -35,11 +38,14 @@ class Application : Application() {
Seq.setContext(this) Seq.setContext(this)
Libbox.setLocale(Locale.getDefault().toLanguageTag().replace("-", "_")) Libbox.setLocale(Locale.getDefault().toLanguageTag().replace("-", "_"))
HookStatusClient.register(this)
PrivilegeSettingsClient.register(this)
@Suppress("OPT_IN_USAGE") @Suppress("OPT_IN_USAGE")
GlobalScope.launch(Dispatchers.IO) { GlobalScope.launch(Dispatchers.IO) {
initialize() initialize()
UpdateProfileWork.reconfigureUpdater() UpdateProfileWork.reconfigureUpdater()
HookModuleUpdateNotifier.sync(this@Application)
} }
if (Vendor.isPerAppProxyAvailable()) { if (Vendor.isPerAppProxyAvailable()) {

View File

@@ -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()
}
}

View File

@@ -9,7 +9,7 @@ import android.util.Log
import android.widget.Toast import android.widget.Toast
import io.nekohasekai.sfa.R import io.nekohasekai.sfa.R
import io.nekohasekai.sfa.database.Settings import io.nekohasekai.sfa.database.Settings
import io.nekohasekai.sfa.ui.profileoverride.PerAppProxyActivity import io.nekohasekai.sfa.compose.screen.profileoverride.PerAppProxyScanner
import io.nekohasekai.sfa.vendor.PackageQueryManager import io.nekohasekai.sfa.vendor.PackageQueryManager
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@@ -64,7 +64,7 @@ class AppChangeReceiver : BroadcastReceiver() {
val installedPackages = PackageQueryManager.getInstalledPackages(packageManagerFlags) val installedPackages = PackageQueryManager.getInstalledPackages(packageManagerFlags)
val chinaApps = mutableSetOf<String>() val chinaApps = mutableSetOf<String>()
for (packageInfo in installedPackages) { for (packageInfo in installedPackages) {
if (PerAppProxyActivity.scanChinaPackage(packageInfo)) { if (PerAppProxyScanner.scanChinaPackage(packageInfo)) {
chinaApps.add(packageInfo.packageName) chinaApps.add(packageInfo.packageName)
} }
} }

View 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()
}
}

View 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];
}
};
}

View 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];
}
};
}

View 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];
}
};
}

View File

@@ -1,10 +1,11 @@
package io.nekohasekai.sfa.vendor package io.nekohasekai.sfa.bg
import android.content.ComponentName import android.content.ComponentName
import android.content.Intent import android.content.Intent
import android.content.ServiceConnection import android.content.ServiceConnection
import android.content.pm.PackageInfo import android.content.pm.PackageInfo
import android.os.IBinder import android.os.IBinder
import android.os.RemoteException
import com.topjohnwu.superuser.Shell import com.topjohnwu.superuser.Shell
import com.topjohnwu.superuser.ipc.RootService import com.topjohnwu.superuser.ipc.RootService
import io.nekohasekai.sfa.Application import io.nekohasekai.sfa.Application
@@ -17,10 +18,8 @@ import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import kotlin.coroutines.resume import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
object RootPackageManager {
object RootClient {
init { init {
Shell.enableVerboseLogging = BuildConfig.DEBUG Shell.enableVerboseLogging = BuildConfig.DEBUG
Shell.setDefaultBuilder( Shell.setDefaultBuilder(
@@ -36,7 +35,7 @@ object RootPackageManager {
private val _serviceConnected = MutableStateFlow(false) private val _serviceConnected = MutableStateFlow(false)
val serviceConnected: StateFlow<Boolean> = _serviceConnected val serviceConnected: StateFlow<Boolean> = _serviceConnected
private var service: IRootPackageManager? = null private var service: IRootService? = null
private var connection: ServiceConnection? = null private var connection: ServiceConnection? = null
private val connectionMutex = Mutex() private val connectionMutex = Mutex()
@@ -51,14 +50,14 @@ object RootPackageManager {
} }
} }
suspend fun bindService(): IRootPackageManager = connectionMutex.withLock { suspend fun bindService(): IRootService = connectionMutex.withLock {
service?.let { return it } service?.let { return it }
return withContext(Dispatchers.Main) { return withContext(Dispatchers.Main) {
suspendCancellableCoroutine { continuation -> suspendCancellableCoroutine { continuation ->
val conn = object : ServiceConnection { val conn = object : ServiceConnection {
override fun onServiceConnected(name: ComponentName?, binder: IBinder?) { override fun onServiceConnected(name: ComponentName?, binder: IBinder?) {
val svc = IRootPackageManager.Stub.asInterface(binder) val svc = IRootService.Stub.asInterface(binder)
service = svc service = svc
connection = this connection = this
_serviceConnected.value = true _serviceConnected.value = true
@@ -72,7 +71,7 @@ object RootPackageManager {
} }
} }
val intent = Intent(Application.application, RootPackageManagerService::class.java) val intent = Intent(Application.application, RootServer::class.java)
RootService.bind(intent, conn) RootService.bind(intent, conn)
continuation.invokeOnCancellation { continuation.invokeOnCancellation {
@@ -91,18 +90,16 @@ object RootPackageManager {
} }
} }
private const val CHUNK_SIZE = 50
suspend fun getInstalledPackages(flags: Int): List<PackageInfo> { suspend fun getInstalledPackages(flags: Int): List<PackageInfo> {
val userId = android.os.Process.myUserHandle().hashCode()
val svc = bindService() val svc = bindService()
val result = mutableListOf<PackageInfo>() return try {
var offset = 0 val slice = svc.getInstalledPackages(flags, userId)
while (true) { @Suppress("UNCHECKED_CAST")
val chunk = svc.getInstalledPackages(flags, offset, CHUNK_SIZE) val list = slice.list as List<PackageInfo>
if (chunk.isEmpty()) break list
result.addAll(chunk) } catch (e: RemoteException) {
offset += chunk.size throw e.rethrowFromSystemServer()
} }
return result
} }
} }

View 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
}
}

View File

@@ -16,7 +16,7 @@ import androidx.lifecycle.MutableLiveData
import io.nekohasekai.libbox.Libbox import io.nekohasekai.libbox.Libbox
import io.nekohasekai.libbox.StatusMessage import io.nekohasekai.libbox.StatusMessage
import io.nekohasekai.sfa.Application import io.nekohasekai.sfa.Application
import io.nekohasekai.sfa.LauncherActivity import io.nekohasekai.sfa.compose.MainActivity
import io.nekohasekai.sfa.R import io.nekohasekai.sfa.R
import io.nekohasekai.sfa.constant.Action import io.nekohasekai.sfa.constant.Action
import io.nekohasekai.sfa.constant.Status import io.nekohasekai.sfa.constant.Status
@@ -61,7 +61,7 @@ class ServiceNotification(
0, 0,
Intent( Intent(
service, service,
LauncherActivity::class.java, MainActivity::class.java,
).setFlags(Intent.FLAG_ACTIVITY_REORDER_TO_FRONT), ).setFlags(Intent.FLAG_ACTIVITY_REORDER_TO_FRONT),
flags, flags,
), ),

View File

@@ -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,
)
}
}
}
}
}
}
}

View File

@@ -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()
}
}

View File

@@ -23,13 +23,7 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.ExpandLess
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.filled.Pause
import androidx.compose.material.icons.filled.PlayArrow import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.filled.Search
import androidx.compose.material.icons.filled.Stop import androidx.compose.material.icons.filled.Stop
import androidx.compose.material.icons.filled.UnfoldLess import androidx.compose.material.icons.filled.UnfoldLess
import androidx.compose.material.icons.filled.UnfoldMore import androidx.compose.material.icons.filled.UnfoldMore
@@ -64,10 +58,9 @@ import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
@@ -94,13 +87,19 @@ import io.nekohasekai.sfa.R
import io.nekohasekai.sfa.bg.ServiceConnection import io.nekohasekai.sfa.bg.ServiceConnection
import io.nekohasekai.sfa.bg.ServiceNotification import io.nekohasekai.sfa.bg.ServiceNotification
import io.nekohasekai.sfa.compose.base.GlobalEventBus import io.nekohasekai.sfa.compose.base.GlobalEventBus
import io.nekohasekai.sfa.compose.base.SelectableMessageDialog
import io.nekohasekai.sfa.compose.base.UiEvent import io.nekohasekai.sfa.compose.base.UiEvent
import io.nekohasekai.sfa.compose.component.ServiceStatusBar import io.nekohasekai.sfa.compose.component.ServiceStatusBar
import io.nekohasekai.sfa.compose.component.UptimeText import io.nekohasekai.sfa.compose.component.UptimeText
import io.nekohasekai.sfa.compose.component.UpdateAvailableDialog import io.nekohasekai.sfa.compose.component.UpdateAvailableDialog
import io.nekohasekai.sfa.compose.navigation.NewProfileArgs
import io.nekohasekai.sfa.compose.navigation.ProfileRoutes
import io.nekohasekai.sfa.compose.navigation.SFANavHost import io.nekohasekai.sfa.compose.navigation.SFANavHost
import io.nekohasekai.sfa.compose.navigation.Screen import io.nekohasekai.sfa.compose.navigation.Screen
import io.nekohasekai.sfa.compose.navigation.bottomNavigationScreens import io.nekohasekai.sfa.compose.navigation.bottomNavigationScreens
import io.nekohasekai.sfa.compose.topbar.LocalTopBarController
import io.nekohasekai.sfa.compose.topbar.TopBarEntry
import io.nekohasekai.sfa.compose.topbar.TopBarController
import io.nekohasekai.sfa.compose.screen.dashboard.CardGroup import io.nekohasekai.sfa.compose.screen.dashboard.CardGroup
import io.nekohasekai.sfa.compose.screen.dashboard.DashboardViewModel import io.nekohasekai.sfa.compose.screen.dashboard.DashboardViewModel
import io.nekohasekai.sfa.compose.screen.dashboard.GroupsCard import io.nekohasekai.sfa.compose.screen.dashboard.GroupsCard
@@ -131,6 +130,7 @@ class MainActivity : ComponentActivity(), ServiceConnection.Callback {
private var showBackgroundLocationDialog by mutableStateOf(false) private var showBackgroundLocationDialog by mutableStateOf(false)
private var showImportProfileDialog by mutableStateOf(false) private var showImportProfileDialog by mutableStateOf(false)
private var pendingImportProfile by mutableStateOf<Triple<String, String, String>?>(null) private var pendingImportProfile by mutableStateOf<Triple<String, String, String>?>(null)
private var newProfileArgs by mutableStateOf(NewProfileArgs())
private val notificationPermissionLauncher = private val notificationPermissionLauncher =
registerForActivityResult( registerForActivityResult(
@@ -171,6 +171,7 @@ class MainActivity : ComponentActivity(), ServiceConnection.Callback {
onServiceAlert(Alert.RequestVPNPermission, null) onServiceAlert(Alert.RequestVPNPermission, null)
} }
} }
private val pendingNavigationRoute = mutableStateOf<String?>(null)
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
@@ -204,7 +205,13 @@ class MainActivity : ComponentActivity(), ServiceConnection.Callback {
} }
private fun handleIntent(intent: Intent?) { private fun handleIntent(intent: Intent?) {
val uri = intent?.data ?: return if (intent == null) {
return
}
if (intent.categories?.contains("de.robv.android.xposed.category.MODULE_SETTINGS") == true) {
pendingNavigationRoute.value = "settings/privilege"
}
val uri = intent.data ?: return
if (uri.scheme == "sing-box" && uri.host == "import-remote-profile") { if (uri.scheme == "sing-box" && uri.host == "import-remote-profile") {
try { try {
val profile = Libbox.parseRemoteProfileImportLink(uri.toString()) val profile = Libbox.parseRemoteProfileImportLink(uri.toString())
@@ -286,6 +293,15 @@ class MainActivity : ComponentActivity(), ServiceConnection.Callback {
// Error dialog state for UiEvent.ShowError // Error dialog state for UiEvent.ShowError
var showErrorDialog by remember { mutableStateOf(false) } var showErrorDialog by remember { mutableStateOf(false) }
var errorMessage by remember { mutableStateOf("") } var errorMessage by remember { mutableStateOf("") }
val topBarState = remember { mutableStateOf(emptyList<TopBarEntry>()) }
val topBarController = remember { TopBarController(topBarState) }
val topBarOverride = topBarState.value.lastOrNull()?.content
val openNewProfile: (NewProfileArgs) -> Unit = { args ->
newProfileArgs = args
navController.navigate(ProfileRoutes.NewProfile) {
launchSingleTop = true
}
}
// Handle service alerts // Handle service alerts
currentAlert?.let { (alertType, message) -> currentAlert?.let { (alertType, message) ->
@@ -298,15 +314,10 @@ class MainActivity : ComponentActivity(), ServiceConnection.Callback {
// Handle UiEvent.ShowError dialog // Handle UiEvent.ShowError dialog
if (showErrorDialog) { if (showErrorDialog) {
AlertDialog( SelectableMessageDialog(
onDismissRequest = { showErrorDialog = false }, title = stringResource(R.string.error_title),
title = { Text(stringResource(R.string.error_title)) }, message = errorMessage,
text = { Text(errorMessage) }, onDismiss = { showErrorDialog = false },
confirmButton = {
TextButton(onClick = { showErrorDialog = false }) {
Text(stringResource(R.string.ok))
}
},
) )
} }
@@ -341,11 +352,11 @@ class MainActivity : ComponentActivity(), ServiceConnection.Callback {
text = { Text(stringResource(R.string.import_remote_profile_message, name, host)) }, text = { Text(stringResource(R.string.import_remote_profile_message, name, host)) },
confirmButton = { confirmButton = {
TextButton(onClick = { TextButton(onClick = {
startActivity( openNewProfile(
Intent(this@MainActivity, NewProfileActivity::class.java).apply { NewProfileArgs(
putExtra(NewProfileActivity.EXTRA_IMPORT_NAME, name) importName = name,
putExtra(NewProfileActivity.EXTRA_IMPORT_URL, url) importUrl = url,
}, ),
) )
showImportProfileDialog = false showImportProfileDialog = false
pendingImportProfile = null pendingImportProfile = null
@@ -432,17 +443,13 @@ class MainActivity : ComponentActivity(), ServiceConnection.Callback {
downloadError = null downloadError = null
downloadJob = scope.launch { downloadJob = scope.launch {
try { try {
val result = withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
Vendor.downloadAndInstall( Vendor.downloadAndInstall(
this@MainActivity, this@MainActivity,
updateInfo!!.downloadUrl, updateInfo!!.downloadUrl,
) )
} }
if (result.isFailure) { showDownloadDialog = false
downloadError = result.exceptionOrNull()?.message
} else {
showDownloadDialog = false
}
} catch (e: Exception) { } catch (e: Exception) {
downloadError = e.message downloadError = e.message
} }
@@ -496,40 +503,23 @@ class MainActivity : ComponentActivity(), ServiceConnection.Callback {
val isSettingsSubScreen = currentRoute?.startsWith("settings/") == true val isSettingsSubScreen = currentRoute?.startsWith("settings/") == true
val isConnectionsDetail = currentRoute?.startsWith("connections/detail") == true val isConnectionsDetail = currentRoute?.startsWith("connections/detail") == true
val isProfileRoute = currentRoute?.startsWith("profile/") == true
val currentRootRoute = val currentRootRoute =
when { when {
isSettingsSubScreen -> Screen.Settings.route isSettingsSubScreen -> Screen.Settings.route
currentRoute?.startsWith(Screen.Connections.route) == true -> Screen.Connections.route currentRoute?.startsWith(Screen.Connections.route) == true -> Screen.Connections.route
currentRoute?.startsWith(Screen.Log.route) == true -> Screen.Log.route currentRoute?.startsWith(Screen.Log.route) == true -> Screen.Log.route
isProfileRoute -> Screen.Dashboard.route
else -> currentRoute else -> currentRoute
} }
val isConnectionsRoute = currentRootRoute == Screen.Connections.route val isConnectionsRoute = currentRootRoute == Screen.Connections.route
val isGroupsRoute = currentRootRoute == Screen.Groups.route val isGroupsRoute = currentRootRoute == Screen.Groups.route
val isLogRoute = currentRootRoute == Screen.Log.route
// Determine current screen title val isSubScreen = isSettingsSubScreen || isConnectionsDetail || isProfileRoute
val currentScreen =
when (currentRootRoute) {
Screen.Dashboard.route -> Screen.Dashboard
Screen.Groups.route -> Screen.Groups
Screen.Connections.route -> Screen.Connections
Screen.Log.route -> Screen.Log
Screen.Settings.route -> Screen.Settings
else -> Screen.Dashboard
}
val isSubScreen = isSettingsSubScreen || isConnectionsDetail
val settingsScreenTitle =
when (currentRoute) {
"settings/app" -> stringResource(R.string.title_app_settings)
"settings/core" -> stringResource(R.string.core)
"settings/service" -> stringResource(R.string.service)
"settings/profile_override" -> stringResource(R.string.profile_override)
else -> null
}
// Get LogViewModel instance if we're on the Log screen // Get LogViewModel instance if we're on the Log screen
val logViewModel: LogViewModel? = val logViewModel: LogViewModel? =
if (currentScreen == Screen.Log) { if (isLogRoute) {
viewModel() viewModel()
} else { } else {
null null
@@ -593,6 +583,16 @@ class MainActivity : ComponentActivity(), ServiceConnection.Callback {
} }
} }
val pendingRoute = pendingNavigationRoute.value
LaunchedEffect(pendingRoute) {
if (pendingRoute != null) {
navController.navigate(pendingRoute) {
launchSingleTop = true
}
pendingNavigationRoute.value = null
}
}
LaunchedEffect(allowedRoutes, currentRootRoute, useNavigationRail) { LaunchedEffect(allowedRoutes, currentRootRoute, useNavigationRail) {
if (currentRootRoute != null && !allowedRoutes.contains(currentRootRoute)) { if (currentRootRoute != null && !allowedRoutes.contains(currentRootRoute)) {
navController.navigate(Screen.Dashboard.route) { navController.navigate(Screen.Dashboard.route) {
@@ -627,10 +627,9 @@ class MainActivity : ComponentActivity(), ServiceConnection.Callback {
} }
is UiEvent.EditProfile -> { is UiEvent.EditProfile -> {
val intent = navController.navigate(ProfileRoutes.editProfile(event.profileId)) {
Intent(this@MainActivity, EditProfileActivity::class.java) launchSingleTop = true
intent.putExtra("profile_id", event.profileId) }
startActivity(intent)
} }
is UiEvent.RestartToTakeEffect -> { is UiEvent.RestartToTakeEffect -> {
@@ -656,133 +655,7 @@ class MainActivity : ComponentActivity(), ServiceConnection.Callback {
} }
val topBarContent: @Composable () -> Unit = { val topBarContent: @Composable () -> Unit = {
TopAppBar( topBarOverride?.invoke()
title = {
Text(
when {
isSettingsSubScreen && settingsScreenTitle != null -> settingsScreenTitle
isConnectionsDetail -> stringResource(R.string.connection_details)
else -> stringResource(currentScreen.titleRes)
},
)
},
navigationIcon = {
if (isSubScreen) {
IconButton(onClick = { navController.navigateUp() }) {
Icon(
imageVector = Icons.AutoMirrored.Default.ArrowBack,
contentDescription = stringResource(R.string.content_description_back),
)
}
}
},
actions = {
// Show Others menu for Dashboard screen (but not in settings sub-screens)
if (currentScreen == Screen.Dashboard && !isSettingsSubScreen) {
// More options button
IconButton(onClick = { dashboardViewModel.toggleCardSettingsDialog() }) {
Icon(
imageVector = Icons.Default.MoreVert,
contentDescription = stringResource(R.string.title_others),
tint = MaterialTheme.colorScheme.onSurface,
)
}
}
if (currentScreen == Screen.Groups && groupsViewModel != null) {
val groupsUiState by groupsViewModel.uiState.collectAsState()
val allCollapsed = groupsUiState.expandedGroups.isEmpty()
if (groupsUiState.groups.isNotEmpty()) {
IconButton(onClick = { groupsViewModel.toggleAllGroups() }) {
Icon(
imageVector = if (allCollapsed) Icons.Default.UnfoldMore else Icons.Default.UnfoldLess,
contentDescription =
if (allCollapsed) {
stringResource(R.string.expand_all)
} else {
stringResource(R.string.collapse_all)
},
)
}
}
}
if (isConnectionsDetail && connectionsViewModel != null) {
val connectionsUiState by connectionsViewModel.uiState.collectAsState()
val connectionId = navBackStackEntry?.arguments?.getString("connectionId")
val detailConnection =
connectionsUiState.allConnections.find { it.id == connectionId }
?: connectionsUiState.connections.find { it.id == connectionId }
if (detailConnection?.isActive == true) {
IconButton(onClick = { connectionsViewModel.closeConnection(detailConnection.id) }) {
Icon(
imageVector = Icons.Default.Close,
contentDescription = stringResource(R.string.connection_close),
)
}
}
}
if (currentScreen == Screen.Log && logViewModel != null) {
val logUiState by logViewModel.uiState.collectAsState()
if (!logUiState.isSelectionMode) {
IconButton(onClick = { logViewModel.togglePause() }) {
Icon(
imageVector =
if (logUiState.isPaused) {
Icons.Default.PlayArrow
} else {
Icons.Default.Pause
},
contentDescription =
if (logUiState.isPaused) {
stringResource(
R.string.content_description_resume_logs,
)
} else {
stringResource(R.string.content_description_pause_logs)
},
)
}
IconButton(onClick = { logViewModel.toggleSearch() }) {
Icon(
imageVector =
if (logUiState.isSearchActive) {
Icons.Default.ExpandLess
} else {
Icons.Default.Search
},
contentDescription =
if (logUiState.isSearchActive) {
stringResource(
R.string.content_description_collapse_search,
)
} else {
stringResource(R.string.content_description_search_logs)
},
tint =
if (logUiState.isSearchActive) {
MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme.onSurface
},
)
}
IconButton(onClick = { logViewModel.toggleOptionsMenu() }) {
Icon(
imageVector = Icons.Default.MoreVert,
contentDescription = stringResource(R.string.more_options),
tint = MaterialTheme.colorScheme.onSurface,
)
}
}
}
},
colors = TopAppBarDefaults.topAppBarColors(),
)
} }
val scaffoldContent: @Composable (PaddingValues) -> Unit = { paddingValues -> val scaffoldContent: @Composable (PaddingValues) -> Unit = { paddingValues ->
@@ -802,6 +675,9 @@ class MainActivity : ComponentActivity(), ServiceConnection.Callback {
serviceStatus = currentServiceStatus, serviceStatus = currentServiceStatus,
showStartFab = showStartFab, showStartFab = showStartFab,
showStatusBar = showStatusBar, showStatusBar = showStatusBar,
newProfileArgs = newProfileArgs,
onClearNewProfileArgs = { newProfileArgs = NewProfileArgs() },
onOpenNewProfile = openNewProfile,
dashboardViewModel = dashboardViewModel, dashboardViewModel = dashboardViewModel,
logViewModel = logViewModel, logViewModel = logViewModel,
groupsViewModel = groupsViewModel, groupsViewModel = groupsViewModel,
@@ -816,7 +692,7 @@ class MainActivity : ComponentActivity(), ServiceConnection.Callback {
groupsCount = dashboardUiState.groupsCount, groupsCount = dashboardUiState.groupsCount,
hasGroups = dashboardUiState.hasGroups, hasGroups = dashboardUiState.hasGroups,
onGroupsClick = { showGroupsSheet = true }, onGroupsClick = { showGroupsSheet = true },
connectionsCount = dashboardUiState.connectionsOut.toIntOrNull() ?: 0, connectionsCount = dashboardUiState.connectionsCount,
onConnectionsClick = { showConnectionsSheet = true }, onConnectionsClick = { showConnectionsSheet = true },
onStopClick = { dashboardViewModel.toggleService() }, onStopClick = { dashboardViewModel.toggleService() },
modifier = Modifier.align(Alignment.BottomCenter), modifier = Modifier.align(Alignment.BottomCenter),
@@ -936,61 +812,18 @@ class MainActivity : ComponentActivity(), ServiceConnection.Callback {
} }
} }
if (useNavigationRail) { CompositionLocalProvider(LocalTopBarController provides topBarController) {
Row(modifier = Modifier.fillMaxSize()) { if (useNavigationRail) {
Surface(tonalElevation = 1.dp) { Row(modifier = Modifier.fillMaxSize()) {
NavigationRail( Surface(tonalElevation = 1.dp) {
modifier = Modifier.fillMaxHeight(), NavigationRail(
) { modifier = Modifier.fillMaxHeight(),
val hasUpdate by UpdateState.hasUpdate ) {
railScreens.forEach { screen -> val hasUpdate by UpdateState.hasUpdate
val selected = currentRootRoute == screen.route railScreens.forEach { screen ->
val selected = currentRootRoute == screen.route
NavigationRailItem( NavigationRailItem(
icon = {
if (screen == Screen.Settings && hasUpdate) {
BadgedBox(badge = { Badge(containerColor = MaterialTheme.colorScheme.primary) }) {
Icon(screen.icon, contentDescription = null)
}
} else {
Icon(screen.icon, contentDescription = null)
}
},
label = { Text(stringResource(screen.titleRes)) },
selected = selected,
onClick = {
navController.navigate(screen.route) {
popUpTo(navController.graph.findStartDestination().id) {
saveState = true
}
launchSingleTop = true
restoreState = true
}
},
)
}
}
}
Scaffold(
modifier = Modifier.weight(1f),
snackbarHost = { SnackbarHost(hostState = snackbarHostState) },
topBar = topBarContent,
) { paddingValues ->
scaffoldContent(paddingValues)
}
}
} else {
Scaffold(
modifier = Modifier.fillMaxSize(),
snackbarHost = { SnackbarHost(hostState = snackbarHostState) },
topBar = topBarContent,
bottomBar = {
if (!isSubScreen) {
val hasUpdate by UpdateState.hasUpdate
NavigationBar {
bottomNavigationScreens.forEach { screen ->
NavigationBarItem(
icon = { icon = {
if (screen == Screen.Settings && hasUpdate) { if (screen == Screen.Settings && hasUpdate) {
BadgedBox(badge = { Badge(containerColor = MaterialTheme.colorScheme.primary) }) { BadgedBox(badge = { Badge(containerColor = MaterialTheme.colorScheme.primary) }) {
@@ -1000,20 +833,14 @@ class MainActivity : ComponentActivity(), ServiceConnection.Callback {
Icon(screen.icon, contentDescription = null) Icon(screen.icon, contentDescription = null)
} }
}, },
selected = label = { Text(stringResource(screen.titleRes)) },
currentDestination?.hierarchy?.any { selected = selected,
it.route == screen.route
} == true,
onClick = { onClick = {
navController.navigate(screen.route) { navController.navigate(screen.route) {
// Pop up to the start destination of the graph to
// avoid building up a large stack of destinations
popUpTo(navController.graph.findStartDestination().id) { popUpTo(navController.graph.findStartDestination().id) {
saveState = true saveState = true
} }
// Avoid multiple copies of the same destination
launchSingleTop = true launchSingleTop = true
// Restore state when reselecting a previously selected item
restoreState = true restoreState = true
} }
}, },
@@ -1021,9 +848,60 @@ class MainActivity : ComponentActivity(), ServiceConnection.Callback {
} }
} }
} }
},
) { paddingValues -> Scaffold(
scaffoldContent(paddingValues) modifier = Modifier.weight(1f),
snackbarHost = { SnackbarHost(hostState = snackbarHostState) },
topBar = topBarContent,
) { paddingValues ->
scaffoldContent(paddingValues)
}
}
} else {
Scaffold(
modifier = Modifier.fillMaxSize(),
snackbarHost = { SnackbarHost(hostState = snackbarHostState) },
topBar = topBarContent,
bottomBar = {
if (!isSubScreen) {
val hasUpdate by UpdateState.hasUpdate
NavigationBar {
bottomNavigationScreens.forEach { screen ->
NavigationBarItem(
icon = {
if (screen == Screen.Settings && hasUpdate) {
BadgedBox(badge = { Badge(containerColor = MaterialTheme.colorScheme.primary) }) {
Icon(screen.icon, contentDescription = null)
}
} else {
Icon(screen.icon, contentDescription = null)
}
},
selected =
currentDestination?.hierarchy?.any {
it.route == screen.route
} == true,
onClick = {
navController.navigate(screen.route) {
// Pop up to the start destination of the graph to
// avoid building up a large stack of destinations
popUpTo(navController.graph.findStartDestination().id) {
saveState = true
}
// Avoid multiple copies of the same destination
launchSingleTop = true
// Restore state when reselecting a previously selected item
restoreState = true
}
},
)
}
}
}
},
) { paddingValues ->
scaffoldContent(paddingValues)
}
} }
} }

View File

@@ -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()
},
)
}
}
}
}
}

View File

@@ -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))
}
},
)
}

View File

@@ -56,7 +56,7 @@ import androidx.lifecycle.viewmodel.compose.viewModel
import io.nekohasekai.sfa.R import io.nekohasekai.sfa.R
import io.nekohasekai.sfa.compose.screen.qrscan.QRScanResult import io.nekohasekai.sfa.compose.screen.qrscan.QRScanResult
import io.nekohasekai.sfa.compose.screen.qrscan.QRScanViewModel import io.nekohasekai.sfa.compose.screen.qrscan.QRScanViewModel
import io.nekohasekai.sfa.ui.profile.QRCodeCropArea import io.nekohasekai.sfa.compose.screen.qrscan.QRCodeCropArea
import kotlin.math.max import kotlin.math.max
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)

View File

@@ -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"
}

View File

@@ -5,25 +5,50 @@ import androidx.compose.animation.AnimatedContentTransitionScope
import androidx.compose.animation.core.tween import androidx.compose.animation.core.tween
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.navigation.NavHostController import androidx.navigation.NavHostController
import androidx.navigation.NavType
import androidx.navigation.compose.NavHost import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable import androidx.navigation.compose.composable
import androidx.navigation.navArgument
import io.nekohasekai.sfa.compose.screen.dashboard.DashboardScreen import io.nekohasekai.sfa.compose.screen.dashboard.DashboardScreen
import io.nekohasekai.sfa.compose.screen.dashboard.DashboardViewModel import io.nekohasekai.sfa.compose.screen.dashboard.DashboardViewModel
import io.nekohasekai.sfa.compose.screen.dashboard.GroupsCard import io.nekohasekai.sfa.compose.screen.dashboard.GroupsCard
import io.nekohasekai.sfa.compose.screen.dashboard.groups.GroupsViewModel import io.nekohasekai.sfa.compose.screen.dashboard.groups.GroupsViewModel
import io.nekohasekai.sfa.compose.screen.connections.ConnectionDetailsRoute import io.nekohasekai.sfa.compose.screen.connections.ConnectionDetailsRoute
import io.nekohasekai.sfa.compose.screen.connections.ConnectionsPage import io.nekohasekai.sfa.compose.screen.connections.ConnectionsPage
import io.nekohasekai.sfa.compose.screen.log.HookLogScreen
import io.nekohasekai.sfa.compose.screen.log.LogScreen import io.nekohasekai.sfa.compose.screen.log.LogScreen
import io.nekohasekai.sfa.compose.screen.log.LogViewModel import io.nekohasekai.sfa.compose.screen.log.LogViewModel
import io.nekohasekai.sfa.compose.screen.connections.ConnectionsViewModel import io.nekohasekai.sfa.compose.screen.connections.ConnectionsViewModel
import io.nekohasekai.sfa.compose.screen.configuration.NewProfileScreen
import io.nekohasekai.sfa.compose.screen.profile.EditProfileRoute
import io.nekohasekai.sfa.compose.screen.settings.AppSettingsScreen import io.nekohasekai.sfa.compose.screen.settings.AppSettingsScreen
import io.nekohasekai.sfa.compose.screen.settings.CoreSettingsScreen import io.nekohasekai.sfa.compose.screen.settings.CoreSettingsScreen
import io.nekohasekai.sfa.compose.screen.settings.PrivilegeSettingsScreen
import io.nekohasekai.sfa.compose.screen.settings.ProfileOverrideScreen import io.nekohasekai.sfa.compose.screen.settings.ProfileOverrideScreen
import io.nekohasekai.sfa.compose.screen.settings.ServiceSettingsScreen import io.nekohasekai.sfa.compose.screen.settings.ServiceSettingsScreen
import io.nekohasekai.sfa.compose.screen.settings.SettingsScreen import io.nekohasekai.sfa.compose.screen.settings.SettingsScreen
import io.nekohasekai.sfa.constant.Status import io.nekohasekai.sfa.constant.Status
import io.nekohasekai.sfa.compose.screen.privilegesettings.PrivilegeSettingsManageScreen
import io.nekohasekai.sfa.compose.screen.profileoverride.PerAppProxyScreen
private val slideInFromRight: AnimatedContentTransitionScope<*>.() -> androidx.compose.animation.EnterTransition = {
slideIntoContainer(AnimatedContentTransitionScope.SlideDirection.Left, animationSpec = tween(300))
}
private val slideOutToRight: AnimatedContentTransitionScope<*>.() -> androidx.compose.animation.ExitTransition = {
slideOutOfContainer(AnimatedContentTransitionScope.SlideDirection.Right, animationSpec = tween(300))
}
private val slideInFromLeft: AnimatedContentTransitionScope<*>.() -> androidx.compose.animation.EnterTransition = {
slideIntoContainer(AnimatedContentTransitionScope.SlideDirection.Right, animationSpec = tween(300))
}
private val slideOutToLeft: AnimatedContentTransitionScope<*>.() -> androidx.compose.animation.ExitTransition = {
slideOutOfContainer(AnimatedContentTransitionScope.SlideDirection.Left, animationSpec = tween(300))
}
@Composable @Composable
fun SFANavHost( fun SFANavHost(
@@ -31,6 +56,9 @@ fun SFANavHost(
serviceStatus: Status = Status.Stopped, serviceStatus: Status = Status.Stopped,
showStartFab: Boolean = false, showStartFab: Boolean = false,
showStatusBar: Boolean = false, showStatusBar: Boolean = false,
newProfileArgs: NewProfileArgs = NewProfileArgs(),
onClearNewProfileArgs: () -> Unit = {},
onOpenNewProfile: (NewProfileArgs) -> Unit = {},
dashboardViewModel: DashboardViewModel? = null, dashboardViewModel: DashboardViewModel? = null,
logViewModel: LogViewModel? = null, logViewModel: LogViewModel? = null,
groupsViewModel: GroupsViewModel? = null, groupsViewModel: GroupsViewModel? = null,
@@ -48,6 +76,7 @@ fun SFANavHost(
serviceStatus = serviceStatus, serviceStatus = serviceStatus,
showStartFab = showStartFab, showStartFab = showStartFab,
showStatusBar = showStatusBar, showStatusBar = showStatusBar,
onOpenNewProfile = onOpenNewProfile,
viewModel = dashboardViewModel, viewModel = dashboardViewModel,
) )
} else { } else {
@@ -55,6 +84,7 @@ fun SFANavHost(
serviceStatus = serviceStatus, serviceStatus = serviceStatus,
showStartFab = showStartFab, showStartFab = showStartFab,
showStatusBar = showStatusBar, showStatusBar = showStatusBar,
onOpenNewProfile = onOpenNewProfile,
) )
} }
} }
@@ -81,11 +111,13 @@ fun SFANavHost(
GroupsCard( GroupsCard(
serviceStatus = serviceStatus, serviceStatus = serviceStatus,
viewModel = groupsViewModel, viewModel = groupsViewModel,
showTopBar = true,
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
) )
} else { } else {
GroupsCard( GroupsCard(
serviceStatus = serviceStatus, serviceStatus = serviceStatus,
showTopBar = true,
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
) )
} }
@@ -97,6 +129,7 @@ fun SFANavHost(
serviceStatus = serviceStatus, serviceStatus = serviceStatus,
viewModel = connectionsViewModel, viewModel = connectionsViewModel,
showTitle = false, showTitle = false,
showTopBar = true,
onConnectionClick = { connectionId -> onConnectionClick = { connectionId ->
navController.navigate("connections/detail/${Uri.encode(connectionId)}") navController.navigate("connections/detail/${Uri.encode(connectionId)}")
}, },
@@ -106,6 +139,7 @@ fun SFANavHost(
ConnectionsPage( ConnectionsPage(
serviceStatus = serviceStatus, serviceStatus = serviceStatus,
showTitle = false, showTitle = false,
showTopBar = true,
onConnectionClick = { connectionId -> onConnectionClick = { connectionId ->
navController.navigate("connections/detail/${Uri.encode(connectionId)}") navController.navigate("connections/detail/${Uri.encode(connectionId)}")
}, },
@@ -114,6 +148,45 @@ fun SFANavHost(
} }
} }
composable(ProfileRoutes.NewProfile) {
DisposableEffect(Unit) {
onDispose { onClearNewProfileArgs() }
}
NewProfileScreen(
importName = newProfileArgs.importName,
importUrl = newProfileArgs.importUrl,
qrsData = newProfileArgs.qrsData,
onNavigateBack = {
onClearNewProfileArgs()
navController.navigateUp()
},
onProfileCreated = { profileId ->
onClearNewProfileArgs()
navController.navigate(ProfileRoutes.editProfile(profileId)) {
popUpTo(ProfileRoutes.NewProfile) {
inclusive = true
}
}
},
)
}
composable(
route = ProfileRoutes.EditProfile,
arguments = listOf(
navArgument("profileId") {
type = NavType.LongType
},
),
) { backStackEntry ->
val profileId = backStackEntry.arguments?.getLong("profileId") ?: -1L
EditProfileRoute(
profileId = profileId,
onNavigateBack = { navController.navigateUp() },
modifier = Modifier.fillMaxSize(),
)
}
composable("connections/detail/{connectionId}") { backStackEntry -> composable("connections/detail/{connectionId}") { backStackEntry ->
val connectionId = backStackEntry.arguments?.getString("connectionId") val connectionId = backStackEntry.arguments?.getString("connectionId")
if (connectionId != null) { if (connectionId != null) {
@@ -143,122 +216,82 @@ fun SFANavHost(
// Settings subscreens with slide animations // Settings subscreens with slide animations
composable( composable(
route = "settings/app", route = "settings/app",
enterTransition = { enterTransition = slideInFromRight,
slideIntoContainer( exitTransition = slideOutToLeft,
AnimatedContentTransitionScope.SlideDirection.Left, popEnterTransition = slideInFromLeft,
animationSpec = tween(300), popExitTransition = slideOutToRight,
)
},
exitTransition = {
slideOutOfContainer(
AnimatedContentTransitionScope.SlideDirection.Left,
animationSpec = tween(300),
)
},
popEnterTransition = {
slideIntoContainer(
AnimatedContentTransitionScope.SlideDirection.Right,
animationSpec = tween(300),
)
},
popExitTransition = {
slideOutOfContainer(
AnimatedContentTransitionScope.SlideDirection.Right,
animationSpec = tween(300),
)
},
) { ) {
AppSettingsScreen(navController = navController) AppSettingsScreen(navController = navController)
} }
composable( composable(
route = "settings/core", route = "settings/core",
enterTransition = { enterTransition = slideInFromRight,
slideIntoContainer( exitTransition = slideOutToRight,
AnimatedContentTransitionScope.SlideDirection.Left, popEnterTransition = slideInFromRight,
animationSpec = tween(300), popExitTransition = slideOutToRight,
)
},
exitTransition = {
slideOutOfContainer(
AnimatedContentTransitionScope.SlideDirection.Right,
animationSpec = tween(300),
)
},
popEnterTransition = {
slideIntoContainer(
AnimatedContentTransitionScope.SlideDirection.Left,
animationSpec = tween(300),
)
},
popExitTransition = {
slideOutOfContainer(
AnimatedContentTransitionScope.SlideDirection.Right,
animationSpec = tween(300),
)
},
) { ) {
CoreSettingsScreen(navController = navController) CoreSettingsScreen(navController = navController)
} }
composable( composable(
route = "settings/service", route = "settings/service",
enterTransition = { enterTransition = slideInFromRight,
slideIntoContainer( exitTransition = slideOutToLeft,
AnimatedContentTransitionScope.SlideDirection.Left, popEnterTransition = slideInFromLeft,
animationSpec = tween(300), popExitTransition = slideOutToRight,
)
},
exitTransition = {
slideOutOfContainer(
AnimatedContentTransitionScope.SlideDirection.Left,
animationSpec = tween(300),
)
},
popEnterTransition = {
slideIntoContainer(
AnimatedContentTransitionScope.SlideDirection.Right,
animationSpec = tween(300),
)
},
popExitTransition = {
slideOutOfContainer(
AnimatedContentTransitionScope.SlideDirection.Right,
animationSpec = tween(300),
)
},
) { ) {
ServiceSettingsScreen(navController = navController) ServiceSettingsScreen(navController = navController)
} }
composable( composable(
route = "settings/profile_override", route = "settings/profile_override",
enterTransition = { enterTransition = slideInFromRight,
slideIntoContainer( exitTransition = slideOutToLeft,
AnimatedContentTransitionScope.SlideDirection.Left, popEnterTransition = slideInFromLeft,
animationSpec = tween(300), popExitTransition = slideOutToRight,
)
},
exitTransition = {
slideOutOfContainer(
AnimatedContentTransitionScope.SlideDirection.Left,
animationSpec = tween(300),
)
},
popEnterTransition = {
slideIntoContainer(
AnimatedContentTransitionScope.SlideDirection.Right,
animationSpec = tween(300),
)
},
popExitTransition = {
slideOutOfContainer(
AnimatedContentTransitionScope.SlideDirection.Right,
animationSpec = tween(300),
)
},
) { ) {
ProfileOverrideScreen(navController = navController) ProfileOverrideScreen(navController = navController)
} }
composable(
route = "settings/profile_override/manage",
enterTransition = slideInFromRight,
exitTransition = slideOutToLeft,
popEnterTransition = slideInFromLeft,
popExitTransition = slideOutToRight,
) {
PerAppProxyScreen(onBack = { navController.navigateUp() })
}
composable(
route = "settings/privilege",
enterTransition = slideInFromRight,
exitTransition = slideOutToLeft,
popEnterTransition = slideInFromLeft,
popExitTransition = slideOutToRight,
) {
PrivilegeSettingsScreen(navController = navController, serviceStatus = serviceStatus)
}
composable(
route = "settings/privilege/manage",
enterTransition = slideInFromRight,
exitTransition = slideOutToLeft,
popEnterTransition = slideInFromLeft,
popExitTransition = slideOutToRight,
) {
PrivilegeSettingsManageScreen(onBack = { navController.navigateUp() })
}
composable(
route = "settings/privilege/logs",
enterTransition = slideInFromRight,
exitTransition = slideOutToLeft,
popEnterTransition = slideInFromLeft,
popExitTransition = slideOutToRight,
) {
HookLogScreen(onBack = { navController.navigateUp() })
}
} }
} }

View File

@@ -15,6 +15,7 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.navigationBars import androidx.compose.foundation.layout.navigationBars
@@ -32,7 +33,6 @@ import androidx.compose.material.icons.filled.CloudDownload
import androidx.compose.material.icons.filled.CreateNewFolder import androidx.compose.material.icons.filled.CreateNewFolder
import androidx.compose.material.icons.filled.FileUpload import androidx.compose.material.icons.filled.FileUpload
import androidx.compose.material.icons.filled.Save import androidx.compose.material.icons.filled.Save
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Card import androidx.compose.material3.Card
@@ -46,11 +46,9 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedCard import androidx.compose.material3.OutlinedCard
import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.Switch import androidx.compose.material3.Switch
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@@ -68,6 +66,8 @@ import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import io.nekohasekai.sfa.R import io.nekohasekai.sfa.R
import io.nekohasekai.sfa.compose.base.SelectableMessageDialog
import io.nekohasekai.sfa.compose.topbar.OverrideTopBar
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
@@ -114,7 +114,6 @@ fun NewProfileScreen(
if (uiState.isSuccess) { if (uiState.isSuccess) {
uiState.createdProfile?.let { profile -> uiState.createdProfile?.let { profile ->
onProfileCreated(profile.id) onProfileCreated(profile.id)
onNavigateBack()
} }
} }
} }
@@ -128,89 +127,48 @@ fun NewProfileScreen(
// Error dialog // Error dialog
if (showErrorDialog) { if (showErrorDialog) {
AlertDialog( SelectableMessageDialog(
onDismissRequest = { title = stringResource(R.string.error_title),
message = uiState.errorMessage ?: "",
onDismiss = {
showErrorDialog = false showErrorDialog = false
viewModel.clearError() viewModel.clearError()
}, },
title = { Text(stringResource(R.string.error_title)) },
text = { Text(uiState.errorMessage ?: "") },
confirmButton = {
TextButton(
onClick = {
showErrorDialog = false
viewModel.clearError()
},
) {
Text(stringResource(R.string.ok))
}
},
) )
} }
Scaffold( OverrideTopBar {
topBar = { TopAppBar(
TopAppBar( title = { Text(stringResource(R.string.title_new_profile)) },
title = { Text(stringResource(R.string.title_new_profile)) }, navigationIcon = {
navigationIcon = { IconButton(onClick = onNavigateBack) {
IconButton(onClick = onNavigateBack) { Icon(
Icon( Icons.AutoMirrored.Filled.ArrowBack,
Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.content_description_back),
contentDescription = stringResource(R.string.content_description_back), )
)
}
},
colors =
TopAppBarDefaults.topAppBarColors(
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))
}
}
} }
} },
}, colors =
) { paddingValues -> TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.surface,
),
)
}
val bottomInset =
with(LocalDensity.current) {
WindowInsets.navigationBars.getBottom(this).toDp()
}
val bottomBarPadding = 88.dp + bottomInset
Box(modifier = Modifier.fillMaxSize()) {
Column( Column(
modifier = modifier =
Modifier Modifier
.fillMaxSize() .fillMaxSize()
.padding(paddingValues)
.verticalScroll(rememberScrollState()) .verticalScroll(rememberScrollState())
.padding(16.dp), .padding(16.dp)
.padding(bottom = bottomBarPadding),
verticalArrangement = Arrangement.spacedBy(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp),
) { ) {
// Profile Name // Profile Name
@@ -589,5 +547,44 @@ fun NewProfileScreen(
} }
} }
} }
Surface(
modifier =
Modifier
.fillMaxWidth()
.align(Alignment.BottomCenter),
color = MaterialTheme.colorScheme.surface,
tonalElevation = 3.dp,
) {
Box(
modifier =
Modifier
.fillMaxWidth()
.windowInsetsPadding(WindowInsets.navigationBars)
.padding(16.dp),
) {
Button(
onClick = { viewModel.validateAndCreateProfile() },
modifier = Modifier.fillMaxWidth(),
enabled = !uiState.isSaving,
) {
if (uiState.isSaving) {
CircularProgressIndicator(
modifier = Modifier.size(18.dp),
strokeWidth = 2.dp,
color = MaterialTheme.colorScheme.onPrimary,
)
} else {
Icon(
Icons.Default.Save,
contentDescription = null,
modifier = Modifier.size(18.dp),
)
Spacer(modifier = Modifier.width(8.dp))
Text(stringResource(R.string.profile_create))
}
}
}
}
} }
} }

View File

@@ -24,6 +24,7 @@ import androidx.compose.ui.input.nestedscroll.NestedScrollSource
import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.unit.Velocity import androidx.compose.ui.unit.Velocity
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Check import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Clear import androidx.compose.material.icons.filled.Clear
import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Close
@@ -33,12 +34,14 @@ import androidx.compose.material.icons.filled.SwapVert
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilterChip import androidx.compose.material3.FilterChip
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
@@ -54,16 +57,19 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import io.nekohasekai.sfa.R import io.nekohasekai.sfa.R
import io.nekohasekai.sfa.compose.topbar.OverrideTopBar
import io.nekohasekai.sfa.compose.model.ConnectionSort import io.nekohasekai.sfa.compose.model.ConnectionSort
import io.nekohasekai.sfa.compose.model.ConnectionStateFilter import io.nekohasekai.sfa.compose.model.ConnectionStateFilter
import io.nekohasekai.sfa.constant.Status import io.nekohasekai.sfa.constant.Status
import io.nekohasekai.sfa.compose.model.Connection import io.nekohasekai.sfa.compose.model.Connection
@OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun ConnectionsPage( fun ConnectionsPage(
serviceStatus: Status, serviceStatus: Status,
viewModel: ConnectionsViewModel = viewModel(), viewModel: ConnectionsViewModel = viewModel(),
showTitle: Boolean = true, showTitle: Boolean = true,
showTopBar: Boolean = false,
onConnectionClick: (String) -> Unit = {}, onConnectionClick: (String) -> Unit = {},
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
@@ -72,6 +78,14 @@ fun ConnectionsPage(
var showSortMenu by remember { mutableStateOf(false) } var showSortMenu by remember { mutableStateOf(false) }
var showConnectionsMenu by remember { mutableStateOf(false) } var showConnectionsMenu by remember { mutableStateOf(false) }
if (showTopBar) {
OverrideTopBar {
TopAppBar(
title = { Text(stringResource(R.string.title_connections)) },
)
}
}
Column( Column(
modifier = modifier.fillMaxSize(), modifier = modifier.fillMaxSize(),
) { ) {
@@ -253,6 +267,7 @@ fun ConnectionsPage(
} }
} }
@OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun ConnectionDetailsRoute( fun ConnectionDetailsRoute(
connectionId: String, connectionId: String,
@@ -266,6 +281,30 @@ fun ConnectionDetailsRoute(
uiState.allConnections.find { it.id == connectionId } uiState.allConnections.find { it.id == connectionId }
?: uiState.connections.find { it.id == connectionId } ?: uiState.connections.find { it.id == connectionId }
OverrideTopBar {
TopAppBar(
title = { Text(stringResource(R.string.connection_details)) },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = stringResource(R.string.content_description_back),
)
}
},
actions = {
if (connection?.isActive == true) {
IconButton(onClick = { viewModel.closeConnection(connectionId) }) {
Icon(
imageVector = Icons.Default.Close,
contentDescription = stringResource(R.string.connection_close),
)
}
}
},
)
}
LaunchedEffect(serviceStatus) { LaunchedEffect(serviceStatus) {
viewModel.updateServiceStatus(serviceStatus) viewModel.updateServiceStatus(serviceStatus)
} }

View File

@@ -2,6 +2,7 @@ package io.nekohasekai.sfa.compose.screen.dashboard
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import io.nekohasekai.sfa.compose.navigation.NewProfileArgs
import io.nekohasekai.sfa.constant.Status import io.nekohasekai.sfa.constant.Status
import io.nekohasekai.sfa.database.Profile import io.nekohasekai.sfa.database.Profile
import io.nekohasekai.sfa.utils.CommandClient import io.nekohasekai.sfa.utils.CommandClient
@@ -33,6 +34,7 @@ fun DashboardCardRenderer(
onHideAddProfileSheet: () -> Unit = {}, onHideAddProfileSheet: () -> Unit = {},
onShowProfilePickerSheet: () -> Unit = {}, onShowProfilePickerSheet: () -> Unit = {},
onHideProfilePickerSheet: () -> Unit = {}, onHideProfilePickerSheet: () -> Unit = {},
onOpenNewProfile: (NewProfileArgs) -> Unit = {},
commandClient: CommandClient? = null, commandClient: CommandClient? = null,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
@@ -121,9 +123,7 @@ fun DashboardCardRenderer(
onHideAddProfileSheet = onHideAddProfileSheet, onHideAddProfileSheet = onHideAddProfileSheet,
onShowProfilePickerSheet = onShowProfilePickerSheet, onShowProfilePickerSheet = onShowProfilePickerSheet,
onHideProfilePickerSheet = onHideProfilePickerSheet, onHideProfilePickerSheet = onHideProfilePickerSheet,
onImportFromFile = { /* Handled in ProfilesCard */ }, onOpenNewProfile = onOpenNewProfile,
onScanQrCode = { /* Handled in ProfilesCard */ },
onCreateManually = { /* Handled in ProfilesCard */ },
) )
} }
} }

View File

@@ -9,10 +9,15 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material3.AlertDialog import androidx.compose.material3.AlertDialog
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
@@ -27,6 +32,8 @@ import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import io.nekohasekai.sfa.R import io.nekohasekai.sfa.R
import io.nekohasekai.sfa.compose.base.UiEvent import io.nekohasekai.sfa.compose.base.UiEvent
import io.nekohasekai.sfa.compose.navigation.NewProfileArgs
import io.nekohasekai.sfa.compose.topbar.OverrideTopBar
import io.nekohasekai.sfa.constant.Status import io.nekohasekai.sfa.constant.Status
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -41,10 +48,25 @@ fun DashboardScreen(
serviceStatus: Status = Status.Stopped, serviceStatus: Status = Status.Stopped,
showStartFab: Boolean = false, showStartFab: Boolean = false,
showStatusBar: Boolean = false, showStatusBar: Boolean = false,
onOpenNewProfile: (NewProfileArgs) -> Unit = {},
viewModel: DashboardViewModel = viewModel(), viewModel: DashboardViewModel = viewModel(),
) { ) {
val uiState by viewModel.uiState.collectAsState() val uiState by viewModel.uiState.collectAsState()
OverrideTopBar {
TopAppBar(
title = { Text(stringResource(R.string.title_dashboard)) },
actions = {
IconButton(onClick = { viewModel.toggleCardSettingsDialog() }) {
Icon(
imageVector = Icons.Default.MoreVert,
contentDescription = stringResource(R.string.title_others),
)
}
},
)
}
// Update service status in ViewModel // Update service status in ViewModel
LaunchedEffect(serviceStatus) { LaunchedEffect(serviceStatus) {
viewModel.updateServiceStatus(serviceStatus) viewModel.updateServiceStatus(serviceStatus)
@@ -174,6 +196,7 @@ fun DashboardScreen(
onHideAddProfileSheet = viewModel::hideAddProfileSheet, onHideAddProfileSheet = viewModel::hideAddProfileSheet,
onShowProfilePickerSheet = viewModel::showProfilePickerSheet, onShowProfilePickerSheet = viewModel::showProfilePickerSheet,
onHideProfilePickerSheet = viewModel::hideProfilePickerSheet, onHideProfilePickerSheet = viewModel::hideProfilePickerSheet,
onOpenNewProfile = onOpenNewProfile,
commandClient = viewModel.commandClient, commandClient = viewModel.commandClient,
modifier = modifier =
Modifier Modifier
@@ -213,6 +236,7 @@ fun DashboardScreen(
onHideAddProfileSheet = viewModel::hideAddProfileSheet, onHideAddProfileSheet = viewModel::hideAddProfileSheet,
onShowProfilePickerSheet = viewModel::showProfilePickerSheet, onShowProfilePickerSheet = viewModel::showProfilePickerSheet,
onHideProfilePickerSheet = viewModel::hideProfilePickerSheet, onHideProfilePickerSheet = viewModel::hideProfilePickerSheet,
onOpenNewProfile = onOpenNewProfile,
commandClient = viewModel.commandClient, commandClient = viewModel.commandClient,
) )
} }

View File

@@ -64,7 +64,6 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.zIndex import androidx.compose.ui.zIndex
import io.nekohasekai.sfa.BuildConfig
import io.nekohasekai.sfa.R import io.nekohasekai.sfa.R
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@@ -78,17 +77,13 @@ fun DashboardSettingsBottomSheet(
onResetOrder: () -> Unit, onResetOrder: () -> Unit,
onDismiss: () -> Unit, onDismiss: () -> Unit,
) { ) {
val filteredCardOrder = var reorderedList by remember(cardOrder) { mutableStateOf(cardOrder) }
if (BuildConfig.DEBUG) cardOrder else cardOrder.filter { it != CardGroup.Debug } var currentVisibleCards by remember(visibleCards) { mutableStateOf(visibleCards) }
val filteredVisibleCards =
if (BuildConfig.DEBUG) visibleCards else visibleCards.filter { it != CardGroup.Debug }.toSet()
var reorderedList by remember(filteredCardOrder) { mutableStateOf(filteredCardOrder) }
var currentVisibleCards by remember(filteredVisibleCards) { mutableStateOf(filteredVisibleCards) }
// Update local state when props change (e.g., after reset) // Update local state when props change (e.g., after reset)
LaunchedEffect(filteredCardOrder, filteredVisibleCards) { LaunchedEffect(cardOrder, visibleCards) {
reorderedList = filteredCardOrder reorderedList = cardOrder
currentVisibleCards = filteredVisibleCards currentVisibleCards = visibleCards
} }
val hapticFeedback = LocalHapticFeedback.current val hapticFeedback = LocalHapticFeedback.current
@@ -166,7 +161,7 @@ fun DashboardSettingsBottomSheet(
listOfNotNull( listOfNotNull(
CardGroup.UploadTraffic, CardGroup.UploadTraffic,
CardGroup.DownloadTraffic, CardGroup.DownloadTraffic,
if (BuildConfig.DEBUG) CardGroup.Debug else null, CardGroup.Debug,
CardGroup.Connections, CardGroup.Connections,
CardGroup.SystemProxy, CardGroup.SystemProxy,
CardGroup.ClashMode, CardGroup.ClashMode,
@@ -177,7 +172,7 @@ fun DashboardSettingsBottomSheet(
CardGroup.ClashMode, CardGroup.ClashMode,
CardGroup.UploadTraffic, CardGroup.UploadTraffic,
CardGroup.DownloadTraffic, CardGroup.DownloadTraffic,
if (BuildConfig.DEBUG) CardGroup.Debug else null, CardGroup.Debug,
CardGroup.Connections, CardGroup.Connections,
CardGroup.SystemProxy, CardGroup.SystemProxy,
CardGroup.Profiles, CardGroup.Profiles,

View File

@@ -1,9 +1,11 @@
package io.nekohasekai.sfa.compose.screen.dashboard package io.nekohasekai.sfa.compose.screen.dashboard
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import io.nekohasekai.libbox.Connections
import io.nekohasekai.libbox.Libbox import io.nekohasekai.libbox.Libbox
import io.nekohasekai.libbox.OutboundGroup import io.nekohasekai.libbox.OutboundGroup
import io.nekohasekai.libbox.StatusMessage import io.nekohasekai.libbox.StatusMessage
import io.nekohasekai.sfa.ktx.toList
import io.nekohasekai.sfa.bg.BoxService import io.nekohasekai.sfa.bg.BoxService
import io.nekohasekai.sfa.compose.base.BaseViewModel import io.nekohasekai.sfa.compose.base.BaseViewModel
import io.nekohasekai.sfa.compose.base.UiEvent import io.nekohasekai.sfa.compose.base.UiEvent
@@ -50,6 +52,7 @@ data class DashboardUiState(
val isLoading: Boolean = false, val isLoading: Boolean = false,
val hasGroups: Boolean = false, val hasGroups: Boolean = false,
val groupsCount: Int = 0, val groupsCount: Int = 0,
val connectionsCount: Int = 0,
val serviceStartTime: Long? = null, val serviceStartTime: Long? = null,
val deprecatedNotes: List<DeprecatedNote> = emptyList(), val deprecatedNotes: List<DeprecatedNote> = emptyList(),
val showDeprecatedDialog: Boolean = false, val showDeprecatedDialog: Boolean = false,
@@ -630,6 +633,13 @@ class DashboardViewModel : BaseViewModel<DashboardUiState, UiEvent>(), CommandCl
} }
} }
override fun updateConnections(connections: Connections) {
viewModelScope.launch(Dispatchers.Main) {
val count = connections.iterator().toList().count { it.outboundType != "dns" }
updateState { copy(connectionsCount = count) }
}
}
fun toggleCardSettingsDialog() { fun toggleCardSettingsDialog() {
updateState { updateState {
copy(showCardSettingsDialog = !showCardSettingsDialog) copy(showCardSettingsDialog = !showCardSettingsDialog)

View File

@@ -26,6 +26,8 @@ import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.UnfoldLess
import androidx.compose.material.icons.filled.UnfoldMore
import androidx.compose.material.icons.filled.ExpandMore import androidx.compose.material.icons.filled.ExpandMore
import androidx.compose.material.icons.filled.Speed import androidx.compose.material.icons.filled.Speed
import androidx.compose.material3.Card import androidx.compose.material3.Card
@@ -40,6 +42,7 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
@@ -63,17 +66,20 @@ import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import io.nekohasekai.libbox.Libbox import io.nekohasekai.libbox.Libbox
import io.nekohasekai.sfa.R import io.nekohasekai.sfa.R
import io.nekohasekai.sfa.compose.topbar.OverrideTopBar
import io.nekohasekai.sfa.compose.screen.dashboard.groups.GroupsViewModel import io.nekohasekai.sfa.compose.screen.dashboard.groups.GroupsViewModel
import io.nekohasekai.sfa.constant.Status import io.nekohasekai.sfa.constant.Status
import io.nekohasekai.sfa.compose.model.Group import io.nekohasekai.sfa.compose.model.Group
import io.nekohasekai.sfa.compose.model.GroupItem import io.nekohasekai.sfa.compose.model.GroupItem
import io.nekohasekai.sfa.utils.CommandClient import io.nekohasekai.sfa.utils.CommandClient
@OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun GroupsCard( fun GroupsCard(
serviceStatus: Status, serviceStatus: Status,
commandClient: CommandClient? = null, commandClient: CommandClient? = null,
viewModel: GroupsViewModel? = null, viewModel: GroupsViewModel? = null,
showTopBar: Boolean = false,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val actualViewModel: GroupsViewModel = viewModel ?: viewModel( val actualViewModel: GroupsViewModel = viewModel ?: viewModel(
@@ -88,6 +94,35 @@ fun GroupsCard(
val snackbarHostState = remember { SnackbarHostState() } val snackbarHostState = remember { SnackbarHostState() }
val uiState by actualViewModel.uiState.collectAsState() val uiState by actualViewModel.uiState.collectAsState()
if (showTopBar) {
val allCollapsed = uiState.expandedGroups.isEmpty()
OverrideTopBar {
TopAppBar(
title = { Text(stringResource(R.string.title_groups)) },
actions = {
if (uiState.groups.isNotEmpty()) {
IconButton(onClick = { actualViewModel.toggleAllGroups() }) {
Icon(
imageVector =
if (allCollapsed) {
Icons.Default.UnfoldMore
} else {
Icons.Default.UnfoldLess
},
contentDescription =
if (allCollapsed) {
stringResource(R.string.expand_all)
} else {
stringResource(R.string.collapse_all)
},
)
}
}
},
)
}
}
// Stable callbacks to prevent recomposition - use remember with viewModel as key // Stable callbacks to prevent recomposition - use remember with viewModel as key
val onToggleExpanded = val onToggleExpanded =
remember(actualViewModel) { remember(actualViewModel) {

View File

@@ -1,6 +1,5 @@
package io.nekohasekai.sfa.compose.screen.dashboard package io.nekohasekai.sfa.compose.screen.dashboard
import android.content.Intent
import android.net.Uri import android.net.Uri
import android.widget.Toast import android.widget.Toast
import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.rememberLauncherForActivityResult
@@ -64,10 +63,10 @@ import androidx.compose.ui.unit.dp
import io.nekohasekai.libbox.Libbox import io.nekohasekai.libbox.Libbox
import io.nekohasekai.libbox.ProfileContent import io.nekohasekai.libbox.ProfileContent
import io.nekohasekai.sfa.R import io.nekohasekai.sfa.R
import io.nekohasekai.sfa.compose.NewProfileActivity
import io.nekohasekai.sfa.compose.component.qr.QRCodeDialog import io.nekohasekai.sfa.compose.component.qr.QRCodeDialog
import io.nekohasekai.sfa.compose.component.qr.QRScanSheet import io.nekohasekai.sfa.compose.component.qr.QRScanSheet
import io.nekohasekai.sfa.compose.component.qr.QRSDialog import io.nekohasekai.sfa.compose.component.qr.QRSDialog
import io.nekohasekai.sfa.compose.navigation.NewProfileArgs
import io.nekohasekai.sfa.compose.screen.qrscan.QRScanResult import io.nekohasekai.sfa.compose.screen.qrscan.QRScanResult
import io.nekohasekai.sfa.compose.screen.configuration.ProfileImportHandler import io.nekohasekai.sfa.compose.screen.configuration.ProfileImportHandler
import io.nekohasekai.sfa.compose.util.QRCodeGenerator import io.nekohasekai.sfa.compose.util.QRCodeGenerator
@@ -103,9 +102,7 @@ fun ProfilesCard(
onHideAddProfileSheet: () -> Unit, onHideAddProfileSheet: () -> Unit,
onShowProfilePickerSheet: () -> Unit, onShowProfilePickerSheet: () -> Unit,
onHideProfilePickerSheet: () -> Unit, onHideProfilePickerSheet: () -> Unit,
onImportFromFile: () -> Unit, onOpenNewProfile: (NewProfileArgs) -> Unit,
onScanQrCode: () -> Unit,
onCreateManually: () -> Unit,
) { ) {
val context = LocalContext.current val context = LocalContext.current
val coroutineScope = rememberCoroutineScope() val coroutineScope = rememberCoroutineScope()
@@ -126,28 +123,6 @@ fun ProfilesCard(
var showQRScanSheet by remember { mutableStateOf(false) } var showQRScanSheet by remember { mutableStateOf(false) }
val newProfileLauncher =
rememberLauncherForActivityResult(
ActivityResultContracts.StartActivityForResult(),
) { result ->
if (result.resultCode == android.app.Activity.RESULT_OK) {
val profileId = result.data?.getLongExtra(NewProfileActivity.EXTRA_PROFILE_ID, -1L)
if (profileId != null && profileId != -1L) {
coroutineScope.launch {
val profile =
withContext(Dispatchers.IO) {
io.nekohasekai.sfa.database.ProfileManager.get(profileId)
}
profile?.let {
withContext(Dispatchers.Main) {
onProfileEdit(it)
}
}
}
}
}
}
val importFromFileLauncher = val importFromFileLauncher =
rememberLauncherForActivityResult( rememberLauncherForActivityResult(
ActivityResultContracts.GetContent(), ActivityResultContracts.GetContent(),
@@ -238,9 +213,6 @@ fun ProfilesCard(
} }
} }
LaunchedEffect(onImportFromFile, onScanQrCode) {
}
val selectedProfile = profiles.find { it.id == selectedProfileId } val selectedProfile = profiles.find { it.id == selectedProfileId }
Card( Card(
@@ -458,8 +430,7 @@ fun ProfilesCard(
ListItem( ListItem(
modifier = Modifier.clickable { modifier = Modifier.clickable {
onHideAddProfileSheet() onHideAddProfileSheet()
val intent = Intent(context, NewProfileActivity::class.java) onOpenNewProfile(NewProfileArgs())
newProfileLauncher.launch(intent)
}, },
leadingContent = { leadingContent = {
Icon( Icon(
@@ -608,12 +579,12 @@ fun ProfilesCard(
when (val parseResult = importHandler.parseQRCode(result.uri.toString())) { when (val parseResult = importHandler.parseQRCode(result.uri.toString())) {
is ProfileImportHandler.QRCodeParseResult.RemoteProfile -> { is ProfileImportHandler.QRCodeParseResult.RemoteProfile -> {
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
val newProfileIntent = onOpenNewProfile(
Intent(context, NewProfileActivity::class.java).apply { NewProfileArgs(
putExtra(NewProfileActivity.EXTRA_IMPORT_NAME, parseResult.name) importName = parseResult.name,
putExtra(NewProfileActivity.EXTRA_IMPORT_URL, parseResult.url) importUrl = parseResult.url,
} ),
newProfileLauncher.launch(newProfileIntent) )
} }
} }
is ProfileImportHandler.QRCodeParseResult.LocalProfile -> { is ProfileImportHandler.QRCodeParseResult.LocalProfile -> {

View File

@@ -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) }
}
}

View File

@@ -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,
)
}

View File

@@ -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() {
}
}

View File

@@ -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,
)

View File

@@ -35,6 +35,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.CheckBox import androidx.compose.material.icons.filled.CheckBox
import androidx.compose.material.icons.filled.CheckBoxOutlineBlank import androidx.compose.material.icons.filled.CheckBoxOutlineBlank
import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Close
@@ -44,6 +45,9 @@ import androidx.compose.material.icons.filled.ExpandLess
import androidx.compose.material.icons.filled.ExpandMore import androidx.compose.material.icons.filled.ExpandMore
import androidx.compose.material.icons.filled.FilterList import androidx.compose.material.icons.filled.FilterList
import androidx.compose.material.icons.filled.KeyboardArrowDown import androidx.compose.material.icons.filled.KeyboardArrowDown
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.filled.Pause
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.filled.RadioButtonChecked import androidx.compose.material.icons.filled.RadioButtonChecked
import androidx.compose.material.icons.filled.RadioButtonUnchecked import androidx.compose.material.icons.filled.RadioButtonUnchecked
import androidx.compose.material.icons.filled.Save import androidx.compose.material.icons.filled.Save
@@ -63,6 +67,7 @@ import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
@@ -88,6 +93,7 @@ import androidx.core.content.FileProvider
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import io.nekohasekai.sfa.Application import io.nekohasekai.sfa.Application
import io.nekohasekai.sfa.R import io.nekohasekai.sfa.R
import io.nekohasekai.sfa.compose.topbar.OverrideTopBar
import io.nekohasekai.sfa.constant.Status import io.nekohasekai.sfa.constant.Status
import java.io.File import java.io.File
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
@@ -100,18 +106,97 @@ fun LogScreen(
serviceStatus: Status = Status.Stopped, serviceStatus: Status = Status.Stopped,
showStartFab: Boolean = false, showStartFab: Boolean = false,
showStatusBar: Boolean = false, showStatusBar: Boolean = false,
viewModel: LogViewModel = viewModel(), title: String? = null,
viewModel: LogViewerViewModel? = null,
showPause: Boolean = true,
showClear: Boolean = true,
showStatusInfo: Boolean = true,
emptyMessage: String? = null,
saveFilePrefix: String = "logs",
onBack: (() -> Unit)? = null,
) { ) {
val uiState by viewModel.uiState.collectAsState() val resolvedViewModel = viewModel ?: viewModel<LogViewModel>()
val uiState by resolvedViewModel.uiState.collectAsState()
val context = LocalContext.current val context = LocalContext.current
val configuration = LocalConfiguration.current val configuration = LocalConfiguration.current
val isTablet = configuration.isLayoutSizeAtLeast(Configuration.SCREENLAYOUT_SIZE_LARGE) val isTablet = configuration.isLayoutSizeAtLeast(Configuration.SCREENLAYOUT_SIZE_LARGE)
val listState = rememberLazyListState() val listState = rememberLazyListState()
val coroutineScope = rememberCoroutineScope() val coroutineScope = rememberCoroutineScope()
val resolvedTitle = title ?: stringResource(R.string.title_log)
val emptyStateMessage = emptyMessage ?: stringResource(R.string.privilege_settings_hook_logs_empty)
OverrideTopBar {
TopAppBar(
title = { Text(resolvedTitle) },
navigationIcon = {
if (onBack != null) {
IconButton(onClick = onBack) {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = stringResource(R.string.content_description_back),
)
}
}
},
actions = {
if (!uiState.isSelectionMode) {
if (showPause) {
IconButton(onClick = { resolvedViewModel.togglePause() }) {
Icon(
imageVector =
if (uiState.isPaused) {
Icons.Default.PlayArrow
} else {
Icons.Default.Pause
},
contentDescription =
if (uiState.isPaused) {
stringResource(R.string.content_description_resume_logs)
} else {
stringResource(R.string.content_description_pause_logs)
},
)
}
}
IconButton(onClick = { resolvedViewModel.toggleSearch() }) {
Icon(
imageVector =
if (uiState.isSearchActive) {
Icons.Default.ExpandLess
} else {
Icons.Default.Search
},
contentDescription =
if (uiState.isSearchActive) {
stringResource(R.string.content_description_collapse_search)
} else {
stringResource(R.string.content_description_search_logs)
},
tint =
if (uiState.isSearchActive) {
MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme.onSurface
},
)
}
IconButton(onClick = { resolvedViewModel.toggleOptionsMenu() }) {
Icon(
imageVector = Icons.Default.MoreVert,
contentDescription = stringResource(R.string.more_options),
tint = MaterialTheme.colorScheme.onSurface,
)
}
}
},
)
}
// Handle back press in selection mode // Handle back press in selection mode
androidx.activity.compose.BackHandler(enabled = uiState.isSelectionMode) { androidx.activity.compose.BackHandler(enabled = uiState.isSelectionMode) {
viewModel.clearSelection() resolvedViewModel.clearSelection()
} }
// Track if user is at the bottom of the list // Track if user is at the bottom of the list
@@ -126,7 +211,7 @@ fun LogScreen(
// Re-enable auto-scroll when user reaches bottom // Re-enable auto-scroll when user reaches bottom
LaunchedEffect(isAtBottom) { LaunchedEffect(isAtBottom) {
if (isAtBottom) { if (isAtBottom) {
viewModel.setAutoScrollEnabled(true) resolvedViewModel.setAutoScrollEnabled(true)
} }
} }
@@ -154,7 +239,7 @@ fun LogScreen(
} }
if (scrolledUp) { if (scrolledUp) {
viewModel.setAutoScrollEnabled(false) resolvedViewModel.setAutoScrollEnabled(false)
} }
dragStartIndex = null dragStartIndex = null
@@ -166,7 +251,7 @@ fun LogScreen(
} }
// Handle scroll to bottom requests from ViewModel // Handle scroll to bottom requests from ViewModel
val scrollToBottomTrigger by viewModel.scrollToBottomTrigger.collectAsState() val scrollToBottomTrigger by resolvedViewModel.scrollToBottomTrigger.collectAsState()
LaunchedEffect(scrollToBottomTrigger) { LaunchedEffect(scrollToBottomTrigger) {
if (scrollToBottomTrigger > 0 && uiState.logs.isNotEmpty()) { if (scrollToBottomTrigger > 0 && uiState.logs.isNotEmpty()) {
listState.animateScrollToItem(uiState.logs.size - 1) listState.animateScrollToItem(uiState.logs.size - 1)
@@ -175,7 +260,9 @@ fun LogScreen(
// Update service status in ViewModel // Update service status in ViewModel
LaunchedEffect(serviceStatus) { LaunchedEffect(serviceStatus) {
viewModel.updateServiceStatus(serviceStatus) if (showStatusInfo) {
resolvedViewModel.updateServiceStatus(serviceStatus)
}
} }
Box( Box(
@@ -203,7 +290,7 @@ fun LogScreen(
Row( Row(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
IconButton(onClick = { viewModel.clearSelection() }) { IconButton(onClick = { resolvedViewModel.clearSelection() }) {
Icon( Icon(
imageVector = Icons.Default.Close, imageVector = Icons.Default.Close,
contentDescription = stringResource(R.string.content_description_exit_selection_mode), contentDescription = stringResource(R.string.content_description_exit_selection_mode),
@@ -222,9 +309,9 @@ fun LogScreen(
Row { Row {
IconButton( IconButton(
onClick = { onClick = {
val selectedText = viewModel.getSelectedLogsText() val selectedText = resolvedViewModel.getSelectedLogsText()
if (selectedText.isNotEmpty()) { if (selectedText.isNotEmpty()) {
val clipLabel = context.getString(R.string.title_log) val clipLabel = resolvedTitle
val clip = ClipData.newPlainText(clipLabel, selectedText) val clip = ClipData.newPlainText(clipLabel, selectedText)
Application.clipboard.setPrimaryClip(clip) Application.clipboard.setPrimaryClip(clip)
Toast.makeText( Toast.makeText(
@@ -232,7 +319,7 @@ fun LogScreen(
context.getString(R.string.copied_to_clipboard), context.getString(R.string.copied_to_clipboard),
Toast.LENGTH_SHORT, Toast.LENGTH_SHORT,
).show() ).show()
viewModel.clearSelection() resolvedViewModel.clearSelection()
} }
}, },
enabled = uiState.selectedLogIndices.isNotEmpty(), enabled = uiState.selectedLogIndices.isNotEmpty(),
@@ -271,7 +358,7 @@ fun LogScreen(
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
) )
TextButton( TextButton(
onClick = { viewModel.setLogLevel(LogLevel.Default) }, onClick = { resolvedViewModel.setLogLevel(LogLevel.Default) },
contentPadding = PaddingValues(horizontal = 8.dp, vertical = 0.dp), contentPadding = PaddingValues(horizontal = 8.dp, vertical = 0.dp),
modifier = Modifier.height(24.dp), modifier = Modifier.height(24.dp),
) { ) {
@@ -316,7 +403,7 @@ fun LogScreen(
OutlinedTextField( OutlinedTextField(
value = uiState.searchQuery, value = uiState.searchQuery,
onValueChange = { viewModel.updateSearchQuery(it) }, onValueChange = { resolvedViewModel.updateSearchQuery(it) },
modifier = modifier =
Modifier Modifier
.fillMaxWidth() .fillMaxWidth()
@@ -331,7 +418,7 @@ fun LogScreen(
}, },
trailingIcon = { trailingIcon = {
if (uiState.searchQuery.isNotEmpty()) { if (uiState.searchQuery.isNotEmpty()) {
IconButton(onClick = { viewModel.updateSearchQuery("") }) { IconButton(onClick = { resolvedViewModel.updateSearchQuery("") }) {
Icon( Icon(
imageVector = Icons.Default.Delete, imageVector = Icons.Default.Delete,
contentDescription = stringResource(R.string.content_description_clear_search), contentDescription = stringResource(R.string.content_description_clear_search),
@@ -351,8 +438,7 @@ fun LogScreen(
} }
} }
if (uiState.logs.isEmpty()) { if (uiState.errorMessage != null) {
// Empty state
Box( Box(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center,
@@ -362,13 +448,37 @@ fun LogScreen(
verticalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp),
) { ) {
Text( Text(
text = text = uiState.errorTitle ?: "Error",
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.error,
)
Text(
text = uiState.errorMessage ?: "",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
} else if (uiState.logs.isEmpty()) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center,
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Text(
text = if (showStatusInfo) {
when (serviceStatus) { when (serviceStatus) {
Status.Started -> stringResource(R.string.status_started) Status.Started -> stringResource(R.string.status_started)
Status.Starting -> stringResource(R.string.status_starting) Status.Starting -> stringResource(R.string.status_starting)
Status.Stopping -> stringResource(R.string.status_stopping) Status.Stopping -> stringResource(R.string.status_stopping)
else -> stringResource(R.string.status_default) else -> stringResource(R.string.status_default)
}, }
} else {
emptyStateMessage
},
style = MaterialTheme.typography.bodyLarge, style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
) )
@@ -404,13 +514,13 @@ fun LogScreen(
isSelectionMode = uiState.isSelectionMode, isSelectionMode = uiState.isSelectionMode,
onLongClick = { onLongClick = {
if (!uiState.isSelectionMode) { if (!uiState.isSelectionMode) {
viewModel.toggleSelectionMode() resolvedViewModel.toggleSelectionMode()
viewModel.toggleLogSelection(index) resolvedViewModel.toggleLogSelection(index)
} }
}, },
onClick = { onClick = {
if (uiState.isSelectionMode) { if (uiState.isSelectionMode) {
viewModel.toggleLogSelection(index) resolvedViewModel.toggleLogSelection(index)
} }
}, },
) )
@@ -437,7 +547,7 @@ fun LogScreen(
uri?.let { uri?.let {
try { try {
context.contentResolver.openOutputStream(it)?.use { outputStream -> context.contentResolver.openOutputStream(it)?.use { outputStream ->
val logsText = viewModel.getAllLogsText() val logsText = resolvedViewModel.getAllLogsText()
outputStream.write(logsText.toByteArray()) outputStream.write(logsText.toByteArray())
outputStream.flush() outputStream.flush()
Toast.makeText( Toast.makeText(
@@ -460,7 +570,7 @@ fun LogScreen(
DropdownMenu( DropdownMenu(
expanded = uiState.isOptionsMenuOpen, expanded = uiState.isOptionsMenuOpen,
onDismissRequest = { onDismissRequest = {
viewModel.toggleOptionsMenu() resolvedViewModel.toggleOptionsMenu()
expandedLogLevel = false expandedLogLevel = false
expandedSave = false expandedSave = false
}, },
@@ -503,8 +613,8 @@ fun LogScreen(
Text(text = level.label) Text(text = level.label)
}, },
onClick = { onClick = {
viewModel.setLogLevel(level) resolvedViewModel.setLogLevel(level)
viewModel.toggleOptionsMenu() resolvedViewModel.toggleOptionsMenu()
expandedLogLevel = false expandedLogLevel = false
}, },
leadingIcon = { leadingIcon = {
@@ -573,13 +683,10 @@ fun LogScreen(
Text(text = stringResource(R.string.save_to_clipboard)) Text(text = stringResource(R.string.save_to_clipboard))
}, },
onClick = { onClick = {
val logsText = viewModel.getAllLogsText() val logsText = resolvedViewModel.getAllLogsText()
if (logsText.isNotEmpty()) { if (logsText.isNotEmpty()) {
val clip = val clip =
ClipData.newPlainText( ClipData.newPlainText(resolvedTitle, logsText)
context.getString(R.string.title_log),
logsText,
)
Application.clipboard.setPrimaryClip(clip) Application.clipboard.setPrimaryClip(clip)
Toast.makeText( Toast.makeText(
context, context,
@@ -593,7 +700,7 @@ fun LogScreen(
Toast.LENGTH_SHORT, Toast.LENGTH_SHORT,
).show() ).show()
} }
viewModel.toggleOptionsMenu() resolvedViewModel.toggleOptionsMenu()
expandedSave = false expandedSave = false
}, },
leadingIcon = { leadingIcon = {
@@ -617,8 +724,8 @@ fun LogScreen(
"yyyyMMdd_HHmmss", "yyyyMMdd_HHmmss",
Locale.getDefault(), Locale.getDefault(),
).format(Date()) ).format(Date())
saveFileLauncher.launch("logs_$timestamp.txt") saveFileLauncher.launch("${saveFilePrefix}_$timestamp.txt")
viewModel.toggleOptionsMenu() resolvedViewModel.toggleOptionsMenu()
expandedSave = false expandedSave = false
}, },
leadingIcon = { leadingIcon = {
@@ -637,7 +744,7 @@ fun LogScreen(
Text(text = stringResource(R.string.menu_share)) Text(text = stringResource(R.string.menu_share))
}, },
onClick = { onClick = {
val logsText = viewModel.getAllLogsText() val logsText = resolvedViewModel.getAllLogsText()
if (logsText.isNotEmpty()) { if (logsText.isNotEmpty()) {
try { try {
val logsDir = val logsDir =
@@ -647,7 +754,7 @@ fun LogScreen(
"yyyyMMdd_HHmmss", "yyyyMMdd_HHmmss",
Locale.getDefault(), Locale.getDefault(),
).format(Date()) ).format(Date())
val logFile = File(logsDir, "logs_$timestamp.txt") val logFile = File(logsDir, "${saveFilePrefix}_$timestamp.txt")
logFile.writeText(logsText) logFile.writeText(logsText)
val uri = val uri =
@@ -682,7 +789,7 @@ fun LogScreen(
Toast.LENGTH_SHORT, Toast.LENGTH_SHORT,
).show() ).show()
} }
viewModel.toggleOptionsMenu() resolvedViewModel.toggleOptionsMenu()
expandedSave = false expandedSave = false
}, },
leadingIcon = { leadingIcon = {
@@ -698,27 +805,28 @@ fun LogScreen(
HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp)) HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp))
} }
// Clear logs option if (showClear) {
DropdownMenuItem( DropdownMenuItem(
text = { text = {
Text( Text(
text = stringResource(R.string.clear_logs), text = stringResource(R.string.clear_logs),
style = MaterialTheme.typography.bodyLarge, style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.error, color = MaterialTheme.colorScheme.error,
) )
}, },
onClick = { onClick = {
viewModel.requestClearLogs() resolvedViewModel.requestClearLogs()
viewModel.toggleOptionsMenu() resolvedViewModel.toggleOptionsMenu()
}, },
leadingIcon = { leadingIcon = {
Icon( Icon(
imageVector = Icons.Default.Delete, imageVector = Icons.Default.Delete,
contentDescription = null, contentDescription = null,
tint = MaterialTheme.colorScheme.error, tint = MaterialTheme.colorScheme.error,
) )
}, },
) )
}
} }
} }
@@ -746,7 +854,7 @@ fun LogScreen(
exit = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) scaleOut() else fadeOut(), exit = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) scaleOut() else fadeOut(),
) { ) {
FloatingActionButton( FloatingActionButton(
onClick = { viewModel.scrollToBottom() }, onClick = { resolvedViewModel.scrollToBottom() },
containerColor = MaterialTheme.colorScheme.primaryContainer, containerColor = MaterialTheme.colorScheme.primaryContainer,
contentColor = MaterialTheme.colorScheme.onPrimaryContainer, contentColor = MaterialTheme.colorScheme.onPrimaryContainer,
) { ) {

View File

@@ -1,7 +1,5 @@
package io.nekohasekai.sfa.compose.screen.log package io.nekohasekai.sfa.compose.screen.log
import androidx.compose.ui.text.AnnotatedString
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import io.nekohasekai.libbox.Libbox import io.nekohasekai.libbox.Libbox
import io.nekohasekai.libbox.LogEntry import io.nekohasekai.libbox.LogEntry
@@ -9,67 +7,16 @@ import io.nekohasekai.sfa.compose.util.AnsiColorUtils
import io.nekohasekai.sfa.constant.Status import io.nekohasekai.sfa.constant.Status
import io.nekohasekai.sfa.utils.CommandClient import io.nekohasekai.sfa.utils.CommandClient
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import java.util.LinkedList import java.util.LinkedList
import java.util.concurrent.atomic.AtomicLong
data class ProcessedLogEntry( class LogViewModel : BaseLogViewModel(), CommandClient.Handler {
val id: Long,
val originalEntry: LogEntry,
val annotatedString: AnnotatedString,
)
enum class LogLevel(val label: String, val priority: Int) {
Default("Default", 7),
PANIC("Panic", 0),
FATAL("Fatal", 1),
ERROR("Error", 2),
WARNING("Warn", 3),
INFO("Info", 4),
DEBUG("Debug", 5),
TRACE("Trace", 6),
}
data class LogUiState(
val logs: List<ProcessedLogEntry> = emptyList(),
val isConnected: Boolean = false,
val serviceStatus: Status = Status.Stopped,
val isPaused: Boolean = false,
val searchQuery: String = "",
val isSearchActive: Boolean = false,
val defaultLogLevel: LogLevel = LogLevel.Default,
val filterLogLevel: LogLevel = LogLevel.Default,
val isOptionsMenuOpen: Boolean = false,
val isSelectionMode: Boolean = false,
val selectedLogIndices: Set<Int> = emptySet(),
)
class LogViewModel : ViewModel(), CommandClient.Handler {
companion object { companion object {
private val maxLines = 3000 private val maxLines = 3000
} }
private val _uiState = MutableStateFlow(LogUiState())
val uiState: StateFlow<LogUiState> = _uiState.asStateFlow()
private val _autoScrollEnabled = MutableStateFlow(true)
val isAtBottom: StateFlow<Boolean> = _autoScrollEnabled.asStateFlow()
private val _scrollToBottomTrigger = MutableStateFlow(0)
val scrollToBottomTrigger: StateFlow<Int> = _scrollToBottomTrigger.asStateFlow()
private val _searchQueryInternal = MutableStateFlow("")
private val logIdGenerator = AtomicLong(0)
private val allLogs = LinkedList<ProcessedLogEntry>()
private val bufferedLogs = LinkedList<ProcessedLogEntry>() private val bufferedLogs = LinkedList<ProcessedLogEntry>()
private val commandClient = private val commandClient =
CommandClient( CommandClient(
@@ -78,26 +25,16 @@ class LogViewModel : ViewModel(), CommandClient.Handler {
handler = this, handler = this,
) )
init {
viewModelScope.launch {
_searchQueryInternal
.debounce(300)
.distinctUntilChanged()
.collect { _ ->
updateDisplayedLogs()
}
}
}
private fun processLogEntry(entry: LogEntry): ProcessedLogEntry { private fun processLogEntry(entry: LogEntry): ProcessedLogEntry {
val level = LogLevel.entries.find { it.priority == entry.level } ?: LogLevel.Default
return ProcessedLogEntry( return ProcessedLogEntry(
id = logIdGenerator.incrementAndGet(), id = logIdGenerator.incrementAndGet(),
originalEntry = entry, entry = LogEntryData(level = level, message = entry.message),
annotatedString = AnsiColorUtils.ansiToAnnotatedString(entry.message), annotatedString = AnsiColorUtils.ansiToAnnotatedString(entry.message),
) )
} }
fun updateServiceStatus(status: Status) { override fun updateServiceStatus(status: Status) {
_uiState.update { it.copy(serviceStatus = status) } _uiState.update { it.copy(serviceStatus = status) }
when (status) { when (status) {
@@ -135,7 +72,7 @@ class LogViewModel : ViewModel(), CommandClient.Handler {
updateDisplayedLogs() updateDisplayedLogs()
} }
fun requestClearLogs() { override fun requestClearLogs() {
viewModelScope.launch { viewModelScope.launch {
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
runCatching { runCatching {
@@ -168,10 +105,9 @@ class LogViewModel : ViewModel(), CommandClient.Handler {
} }
} }
fun togglePause() { override fun togglePause() {
val currentState = _uiState.value val currentState = _uiState.value
if (currentState.isPaused && bufferedLogs.isNotEmpty()) { if (currentState.isPaused && bufferedLogs.isNotEmpty()) {
// When resuming, add buffered logs
val totalSize = allLogs.size + bufferedLogs.size val totalSize = allLogs.size + bufferedLogs.size
val removeCount = (totalSize - maxLines).coerceAtLeast(0) val removeCount = (totalSize - maxLines).coerceAtLeast(0)
@@ -189,121 +125,6 @@ class LogViewModel : ViewModel(), CommandClient.Handler {
updateDisplayedLogs() updateDisplayedLogs()
} }
fun toggleSearch() {
_uiState.update {
it.copy(
isSearchActive = !it.isSearchActive,
searchQuery = if (!it.isSearchActive) it.searchQuery else "",
)
}
updateDisplayedLogs()
}
fun updateSearchQuery(query: String) {
_uiState.update { it.copy(searchQuery = query) }
_searchQueryInternal.value = query
}
fun setLogLevel(level: LogLevel) {
_uiState.update { it.copy(filterLogLevel = level) }
updateDisplayedLogs()
}
fun toggleOptionsMenu() {
_uiState.update { it.copy(isOptionsMenuOpen = !it.isOptionsMenuOpen) }
}
fun setAutoScrollEnabled(enabled: Boolean) {
_autoScrollEnabled.value = enabled
}
fun scrollToBottom() {
_autoScrollEnabled.value = true
_scrollToBottomTrigger.value++
}
fun toggleSelectionMode() {
_uiState.update {
if (it.isSelectionMode) {
// Exit selection mode, clear selections, and resume if it was paused by selection mode
it.copy(isSelectionMode = false, selectedLogIndices = emptySet(), isPaused = false)
} else {
// Enter selection mode and pause log updates
it.copy(isSelectionMode = true, isPaused = true)
}
}
}
fun toggleLogSelection(index: Int) {
_uiState.update { state ->
val newSelection =
if (state.selectedLogIndices.contains(index)) {
state.selectedLogIndices - index
} else {
state.selectedLogIndices + index
}
// Exit selection mode and unpause if no items are selected
if (newSelection.isEmpty()) {
state.copy(
isSelectionMode = false,
selectedLogIndices = emptySet(),
isPaused = false,
)
} else {
state.copy(selectedLogIndices = newSelection)
}
}
}
fun clearSelection() {
_uiState.update {
it.copy(isSelectionMode = false, selectedLogIndices = emptySet(), isPaused = false)
}
}
fun getSelectedLogsText(): String {
val state = _uiState.value
return state.selectedLogIndices
.sorted()
.mapNotNull { index ->
state.logs.getOrNull(index)?.originalEntry?.message
}
.joinToString("\n")
}
fun getAllLogsText(): String {
return _uiState.value.logs.joinToString("\n") { it.originalEntry.message }
}
private fun updateDisplayedLogs() {
val currentState = _uiState.value
val levelPriority =
if (currentState.filterLogLevel != LogLevel.Default) {
currentState.filterLogLevel.priority
} else {
currentState.defaultLogLevel.priority
}
val searchQuery = currentState.searchQuery
val logsToDisplay =
allLogs.asSequence()
.filter { log -> log.originalEntry.level <= levelPriority }
.filter { log ->
searchQuery.isEmpty() || log.originalEntry.message.contains(searchQuery, ignoreCase = true)
}
.toList()
val selectionCleared =
if (_uiState.value.isSelectionMode && _uiState.value.logs != logsToDisplay) {
emptySet<Int>()
} else {
_uiState.value.selectedLogIndices
}
_uiState.update { it.copy(logs = logsToDisplay, selectedLogIndices = selectionCleared) }
}
override fun onCleared() { override fun onCleared() {
super.onCleared() super.onCleared()
commandClient.disconnect() commandClient.disconnect()

View File

@@ -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()
}

View File

@@ -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),
)
},
)
}
}
}

View File

@@ -44,7 +44,6 @@ import androidx.compose.material3.IconButton
import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
@@ -62,6 +61,7 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.input.key.Key import androidx.compose.ui.input.key.Key
@@ -82,6 +82,7 @@ import androidx.compose.ui.viewinterop.AndroidView
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import com.blacksquircle.ui.language.json.JsonLanguage import com.blacksquircle.ui.language.json.JsonLanguage
import io.nekohasekai.sfa.R import io.nekohasekai.sfa.R
import io.nekohasekai.sfa.compose.topbar.OverrideTopBar
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -137,7 +138,91 @@ fun EditProfileContentScreen(
showUnsavedChangesDialog = true showUnsavedChangesDialog = true
} }
Scaffold( OverrideTopBar {
TopAppBar(
title = {
Column {
Text(
if (uiState.isReadOnly) {
stringResource(R.string.view_configuration)
} else {
stringResource(R.string.title_edit_configuration)
},
)
if (uiState.profileName.isNotEmpty()) {
Text(
text = uiState.profileName,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
},
navigationIcon = {
IconButton(
onClick = {
if (uiState.hasUnsavedChanges && !uiState.isReadOnly) {
showUnsavedChangesDialog = true
} else {
onNavigateBack()
}
},
) {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = stringResource(R.string.content_description_back),
)
}
},
actions = {
// Search/Collapse button (Ctrl/Cmd+F)
IconButton(
onClick = { viewModel.toggleSearchBar() },
) {
Icon(
imageVector = if (uiState.showSearchBar) Icons.Default.ExpandLess else Icons.Default.Search,
contentDescription =
if (uiState.showSearchBar) {
stringResource(R.string.content_description_collapse_search)
} else {
stringResource(R.string.search)
},
tint =
if (uiState.showSearchBar) {
MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme.onSurface
},
)
}
// Save button (only show if not read-only) (Ctrl/Cmd+S)
if (!uiState.isReadOnly) {
IconButton(
onClick = { viewModel.saveConfiguration() },
enabled = uiState.hasUnsavedChanges && !uiState.isLoading,
) {
Icon(
imageVector = Icons.Default.Save,
contentDescription = stringResource(R.string.save),
tint =
if (uiState.hasUnsavedChanges) {
MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f)
},
)
}
}
},
colors =
TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.surface,
),
)
}
Column(
modifier = modifier =
modifier modifier
.fillMaxSize() .fillMaxSize()
@@ -221,106 +306,17 @@ fun EditProfileContentScreen(
false false
} }
}, },
topBar = { ) {
TopAppBar(
title = {
Column {
Text(
if (uiState.isReadOnly) {
stringResource(R.string.view_configuration)
} else {
stringResource(R.string.title_edit_configuration)
},
)
if (uiState.profileName.isNotEmpty()) {
Text(
text = uiState.profileName,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
},
navigationIcon = {
IconButton(
onClick = {
if (uiState.hasUnsavedChanges && !uiState.isReadOnly) {
showUnsavedChangesDialog = true
} else {
onNavigateBack()
}
},
) {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = stringResource(R.string.content_description_back),
)
}
},
actions = {
// Search/Collapse button (Ctrl/Cmd+F)
IconButton(
onClick = { viewModel.toggleSearchBar() },
) {
Icon(
imageVector = if (uiState.showSearchBar) Icons.Default.ExpandLess else Icons.Default.Search,
contentDescription =
if (uiState.showSearchBar) {
stringResource(R.string.content_description_collapse_search)
} else {
stringResource(R.string.search)
},
tint =
if (uiState.showSearchBar) {
MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme.onSurface
},
)
}
// Save button (only show if not read-only) (Ctrl/Cmd+S)
if (!uiState.isReadOnly) {
IconButton(
onClick = { viewModel.saveConfiguration() },
enabled = uiState.hasUnsavedChanges && !uiState.isLoading,
) {
Icon(
imageVector = Icons.Default.Save,
contentDescription = stringResource(R.string.save),
tint =
if (uiState.hasUnsavedChanges) {
MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f)
},
)
}
}
},
colors =
TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.surfaceContainer,
),
)
},
) { paddingValues ->
Column(
modifier =
Modifier
.fillMaxSize()
.padding(paddingValues),
) {
// Search bar (appears at top when activated) // Search bar (appears at top when activated)
AnimatedVisibility( AnimatedVisibility(
visible = uiState.showSearchBar, visible = uiState.showSearchBar,
enter = slideInVertically(initialOffsetY = { -it }) + fadeIn() + expandVertically(), enter = expandVertically(expandFrom = Alignment.Top) + fadeIn(),
exit = slideOutVertically(targetOffsetY = { -it }) + fadeOut() + shrinkVertically(), exit = shrinkVertically(shrinkTowards = Alignment.Top) + fadeOut(),
) { ) {
Surface( Surface(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
color = MaterialTheme.colorScheme.surfaceContainer, color = MaterialTheme.colorScheme.surfaceContainer,
shadowElevation = 4.dp, tonalElevation = 2.dp,
) { ) {
Row( Row(
modifier = modifier =
@@ -435,7 +431,8 @@ fun EditProfileContentScreen(
Box( Box(
modifier = modifier =
Modifier Modifier
.fillMaxSize() .fillMaxSize()
.clipToBounds()
.weight(1f), .weight(1f),
) { ) {
// Editor // Editor
@@ -829,8 +826,6 @@ fun EditProfileContentScreen(
} }
} }
} }
}
// Unsaved changes dialog // Unsaved changes dialog
if (showUnsavedChangesDialog) { if (showUnsavedChangesDialog) {
AlertDialog( AlertDialog(

View File

@@ -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,
)
}
}
}

View File

@@ -13,6 +13,7 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.navigationBars import androidx.compose.foundation.layout.navigationBars
@@ -44,7 +45,6 @@ import androidx.compose.material3.IconButton
import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.Switch import androidx.compose.material3.Switch
import androidx.compose.material3.Text import androidx.compose.material3.Text
@@ -66,6 +66,8 @@ import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import io.nekohasekai.sfa.R import io.nekohasekai.sfa.R
import io.nekohasekai.sfa.compose.base.SelectableMessageDialog
import io.nekohasekai.sfa.compose.topbar.OverrideTopBar
import io.nekohasekai.sfa.compose.util.ProfileIcons import io.nekohasekai.sfa.compose.util.ProfileIcons
import io.nekohasekai.sfa.compose.util.RelativeTimeFormatter import io.nekohasekai.sfa.compose.util.RelativeTimeFormatter
import io.nekohasekai.sfa.compose.util.icons.MaterialIconsLibrary import io.nekohasekai.sfa.compose.util.icons.MaterialIconsLibrary
@@ -112,23 +114,13 @@ fun EditProfileScreen(
// Error dialog // Error dialog
if (showErrorDialog) { if (showErrorDialog) {
AlertDialog( SelectableMessageDialog(
onDismissRequest = { title = stringResource(R.string.error_title),
message = uiState.errorMessage ?: "",
onDismiss = {
showErrorDialog = false showErrorDialog = false
viewModel.clearError() viewModel.clearError()
}, },
title = { Text(stringResource(R.string.error_title)) },
text = { Text(uiState.errorMessage ?: "") },
confirmButton = {
TextButton(
onClick = {
showErrorDialog = false
viewModel.clearError()
},
) {
Text(stringResource(R.string.ok))
}
},
) )
} }
@@ -175,74 +167,38 @@ fun EditProfileScreen(
showUnsavedChangesDialog = true showUnsavedChangesDialog = true
} }
Scaffold( OverrideTopBar {
topBar = { TopAppBar(
TopAppBar( title = { Text(stringResource(R.string.title_edit_profile)) },
title = { Text(stringResource(R.string.title_edit_profile)) }, navigationIcon = {
navigationIcon = { IconButton(onClick = handleBack) {
IconButton(onClick = handleBack) { Icon(
Icon( Icons.AutoMirrored.Filled.ArrowBack,
Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.content_description_back),
contentDescription = stringResource(R.string.content_description_back), )
)
}
},
colors =
TopAppBarDefaults.topAppBarColors(
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,
)
} else {
Icon(
Icons.Default.Save,
contentDescription = null,
modifier = Modifier.size(18.dp),
)
Spacer(modifier = Modifier.width(8.dp))
Text(stringResource(R.string.save))
}
}
}
} }
} },
}, colors =
) { paddingValues -> TopAppBarDefaults.topAppBarColors(
Box( containerColor = MaterialTheme.colorScheme.surface,
modifier = ),
Modifier )
.fillMaxSize() }
.padding(paddingValues),
) { val bottomInset =
with(LocalDensity.current) {
WindowInsets.navigationBars.getBottom(this).toDp()
}
val bottomBarPadding =
if (uiState.hasChanges) {
88.dp + bottomInset
} else {
0.dp
}
Box(
modifier = Modifier.fillMaxSize(),
) {
// Progress indicator at top (only for initial loading) // Progress indicator at top (only for initial loading)
if (uiState.isLoading) { if (uiState.isLoading) {
LinearProgressIndicator( LinearProgressIndicator(
@@ -256,7 +212,8 @@ fun EditProfileScreen(
Modifier Modifier
.fillMaxSize() .fillMaxSize()
.verticalScroll(rememberScrollState()) .verticalScroll(rememberScrollState())
.padding(16.dp), .padding(16.dp)
.padding(bottom = bottomBarPadding),
verticalArrangement = Arrangement.spacedBy(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp),
) { ) {
// Basic Information Card // Basic Information Card
@@ -560,6 +517,47 @@ fun EditProfileScreen(
} }
} }
} }
AnimatedVisibility(
visible = uiState.hasChanges,
enter = fadeIn() + expandVertically(),
exit = fadeOut() + shrinkVertically(),
modifier = Modifier.align(Alignment.BottomCenter),
) {
Surface(
modifier = Modifier.fillMaxWidth(),
color = MaterialTheme.colorScheme.surface,
tonalElevation = 3.dp,
) {
Box(
modifier =
Modifier
.fillMaxWidth()
.windowInsetsPadding(WindowInsets.navigationBars)
.padding(16.dp),
) {
Button(
onClick = { viewModel.saveChanges() },
modifier = Modifier.fillMaxWidth(),
enabled = !uiState.isSaving && uiState.autoUpdateIntervalError == null,
) {
if (uiState.isSaving) {
CircularProgressIndicator(
modifier = Modifier.size(18.dp),
strokeWidth = 2.dp,
color = MaterialTheme.colorScheme.onPrimary,
)
} else {
Icon(
Icons.Default.Save,
contentDescription = null,
modifier = Modifier.size(18.dp),
)
Spacer(modifier = Modifier.width(8.dp))
Text(stringResource(R.string.save))
}
}
}
}
} }
} }
} }

View File

@@ -12,6 +12,7 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
@@ -44,7 +45,6 @@ import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
@@ -67,6 +67,7 @@ import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import io.nekohasekai.sfa.R import io.nekohasekai.sfa.R
import io.nekohasekai.sfa.compose.topbar.OverrideTopBar
import io.nekohasekai.sfa.compose.util.ProfileIcon import io.nekohasekai.sfa.compose.util.ProfileIcon
import io.nekohasekai.sfa.compose.util.icons.IconCategory import io.nekohasekai.sfa.compose.util.icons.IconCategory
import io.nekohasekai.sfa.compose.util.icons.MaterialIconsLibrary import io.nekohasekai.sfa.compose.util.icons.MaterialIconsLibrary
@@ -99,113 +100,76 @@ fun IconSelectionScreen(
} }
} }
Scaffold( OverrideTopBar {
topBar = { TopAppBar(
TopAppBar( title = { Text(stringResource(R.string.select_icon)) },
title = { Text(stringResource(R.string.select_icon)) }, navigationIcon = {
navigationIcon = { IconButton(onClick = onNavigateBack) {
IconButton(onClick = onNavigateBack) { Icon(
Icon( Icons.AutoMirrored.Filled.ArrowBack,
Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.content_description_back),
contentDescription = stringResource(R.string.content_description_back), )
)
}
},
actions = {
IconButton(
onClick = {
isSearchActive = !isSearchActive
if (!isSearchActive) {
searchQuery = ""
viewMode = IconViewMode.CATEGORIES
selectedCategory = null
focusManager.clearFocus()
}
},
) {
Icon(
imageVector = Icons.Default.Search,
contentDescription =
if (isSearchActive) {
stringResource(R.string.close_search)
} else {
stringResource(
R.string.search_icons,
)
},
tint =
if (isSearchActive) {
MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme.onSurface
},
)
}
},
colors =
TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.surface,
),
)
},
bottomBar = {
// Footer with current selection info
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,
)
}
}
}
}
} }
} },
}, actions = {
) { paddingValues -> IconButton(
onClick = {
isSearchActive = !isSearchActive
if (!isSearchActive) {
searchQuery = ""
viewMode = IconViewMode.CATEGORIES
selectedCategory = null
focusManager.clearFocus()
}
},
) {
Icon(
imageVector = Icons.Default.Search,
contentDescription =
if (isSearchActive) {
stringResource(R.string.close_search)
} else {
stringResource(
R.string.search_icons,
)
},
tint =
if (isSearchActive) {
MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme.onSurface
},
)
}
},
colors =
TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.surface,
),
)
}
val currentIcon =
currentIconId?.let { id ->
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
}
Box(modifier = Modifier.fillMaxSize()) {
Column( Column(
modifier = modifier =
Modifier Modifier
.fillMaxSize() .fillMaxSize()
.padding(paddingValues), .padding(bottom = bottomBarPadding),
) { ) {
// Show search bar with animation // Show search bar with animation
AnimatedVisibility( AnimatedVisibility(
@@ -419,6 +383,55 @@ fun IconSelectionScreen(
} }
} }
} }
currentIcon?.let { (id, icon) ->
Card(
modifier =
Modifier
.fillMaxWidth()
.align(Alignment.BottomCenter)
.windowInsetsPadding(WindowInsets.navigationBars)
.padding(horizontal = 16.dp, vertical = 8.dp),
colors =
CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.3f),
),
) {
Row(
modifier =
Modifier
.fillMaxWidth()
.padding(12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
imageVector = icon,
contentDescription = null,
modifier = Modifier.size(24.dp),
tint = MaterialTheme.colorScheme.primary,
)
Spacer(modifier = Modifier.width(12.dp))
Column {
val iconInfo = MaterialIconsLibrary.getAllIcons().find { it.id == id }
Text(
text =
stringResource(
R.string.current_icon_format,
iconInfo?.label ?: id,
),
style = MaterialTheme.typography.bodyMedium,
)
MaterialIconsLibrary.getCategoryForIcon(id)?.let { category ->
Text(
text = category,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
}
} }
} }

View File

@@ -1,4 +1,4 @@
package io.nekohasekai.sfa.ui.profile package io.nekohasekai.sfa.compose.screen.qrscan
import kotlin.math.max import kotlin.math.max
import kotlin.math.min import kotlin.math.min

View File

@@ -16,8 +16,6 @@ import androidx.lifecycle.LifecycleOwner
import io.nekohasekai.libbox.Libbox import io.nekohasekai.libbox.Libbox
import io.nekohasekai.sfa.qrs.QRSDecoder import io.nekohasekai.sfa.qrs.QRSDecoder
import io.nekohasekai.sfa.qrs.readIntLE import io.nekohasekai.sfa.qrs.readIntLE
import io.nekohasekai.sfa.ui.profile.QRCodeCropArea
import io.nekohasekai.sfa.ui.profile.ZxingQRCodeAnalyzer
import io.nekohasekai.sfa.vendor.Vendor import io.nekohasekai.sfa.vendor.Vendor
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow

View File

@@ -1,4 +1,4 @@
package io.nekohasekai.sfa.ui.profile package io.nekohasekai.sfa.compose.screen.qrscan
import androidx.camera.core.ImageAnalysis import androidx.camera.core.ImageAnalysis
import androidx.camera.core.ImageProxy import androidx.camera.core.ImageProxy

View File

@@ -20,6 +20,7 @@ import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.outlined.AdminPanelSettings import androidx.compose.material.icons.outlined.AdminPanelSettings
import androidx.compose.material.icons.outlined.Autorenew import androidx.compose.material.icons.outlined.Autorenew
import androidx.compose.material.icons.outlined.Download import androidx.compose.material.icons.outlined.Download
@@ -36,14 +37,17 @@ import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.ListItem import androidx.compose.material3.ListItem
import androidx.compose.material3.ListItemDefaults import androidx.compose.material3.ListItemDefaults
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.RadioButton import androidx.compose.material3.RadioButton
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
@@ -62,11 +66,14 @@ import androidx.navigation.NavController
import io.nekohasekai.sfa.BuildConfig import io.nekohasekai.sfa.BuildConfig
import io.nekohasekai.sfa.R import io.nekohasekai.sfa.R
import io.nekohasekai.sfa.database.Settings import io.nekohasekai.sfa.database.Settings
import io.nekohasekai.sfa.compose.topbar.OverrideTopBar
import io.nekohasekai.sfa.compose.component.UpdateAvailableDialog import io.nekohasekai.sfa.compose.component.UpdateAvailableDialog
import io.nekohasekai.sfa.update.UpdateCheckException import io.nekohasekai.sfa.update.UpdateCheckException
import io.nekohasekai.sfa.update.UpdateState import io.nekohasekai.sfa.update.UpdateState
import io.nekohasekai.sfa.update.UpdateTrack import io.nekohasekai.sfa.update.UpdateTrack
import io.nekohasekai.sfa.vendor.Vendor import io.nekohasekai.sfa.vendor.Vendor
import io.nekohasekai.sfa.utils.HookStatusClient
import io.nekohasekai.sfa.xposed.XposedActivation
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -75,6 +82,20 @@ import kotlinx.coroutines.withContext
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun AppSettingsScreen(navController: NavController) { fun AppSettingsScreen(navController: NavController) {
OverrideTopBar {
TopAppBar(
title = { Text(stringResource(R.string.title_app_settings)) },
navigationIcon = {
IconButton(onClick = { navController.navigateUp() }) {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = stringResource(R.string.content_description_back),
)
}
},
)
}
val context = LocalContext.current val context = LocalContext.current
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val hasUpdate by UpdateState.hasUpdate val hasUpdate by UpdateState.hasUpdate
@@ -87,6 +108,8 @@ fun AppSettingsScreen(navController: NavController) {
var silentInstallEnabled by remember { mutableStateOf(Settings.silentInstallEnabled) } var silentInstallEnabled by remember { mutableStateOf(Settings.silentInstallEnabled) }
var silentInstallMethod by remember { mutableStateOf(Settings.silentInstallMethod) } var silentInstallMethod by remember { mutableStateOf(Settings.silentInstallMethod) }
val systemHookStatus by HookStatusClient.status.collectAsState()
val xposedActivated = systemHookStatus?.active == true || XposedActivation.isActivated(context)
var isMethodAvailable by remember { mutableStateOf(true) } var isMethodAvailable by remember { mutableStateOf(true) }
var autoUpdateEnabled by remember { mutableStateOf(Settings.autoUpdateEnabled) } var autoUpdateEnabled by remember { mutableStateOf(Settings.autoUpdateEnabled) }
var showInstallMethodMenu by remember { mutableStateOf(false) } var showInstallMethodMenu by remember { mutableStateOf(false) }
@@ -98,8 +121,13 @@ fun AppSettingsScreen(navController: NavController) {
var downloadError by remember { mutableStateOf<String?>(null) } var downloadError by remember { mutableStateOf<String?>(null) }
var showUpdateAvailableDialog by remember { mutableStateOf(false) } var showUpdateAvailableDialog by remember { mutableStateOf(false) }
LaunchedEffect(Unit) {
HookStatusClient.refresh()
}
// Re-check method availability when returning from background (e.g., after granting permission) // Re-check method availability when returning from background (e.g., after granting permission)
LifecycleEventEffect(Lifecycle.Event.ON_RESUME) { LifecycleEventEffect(Lifecycle.Event.ON_RESUME) {
HookStatusClient.refresh()
if (silentInstallEnabled) { if (silentInstallEnabled) {
scope.launch { scope.launch {
val success = withContext(Dispatchers.IO) { val success = withContext(Dispatchers.IO) {
@@ -216,14 +244,10 @@ fun AppSettingsScreen(navController: NavController) {
downloadError = null downloadError = null
downloadJob = scope.launch { downloadJob = scope.launch {
try { try {
val result = withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
Vendor.downloadAndInstall(context, updateInfo!!.downloadUrl) Vendor.downloadAndInstall(context, updateInfo!!.downloadUrl)
} }
if (result.isFailure) { showDownloadDialog = false
downloadError = result.exceptionOrNull()?.message
} else {
showDownloadDialog = false
}
} catch (e: Exception) { } catch (e: Exception) {
downloadError = e.message downloadError = e.message
} }
@@ -473,7 +497,7 @@ fun AppSettingsScreen(navController: NavController) {
), ),
) )
if (silentInstallEnabled) { if (silentInstallEnabled && !xposedActivated) {
ListItem( ListItem(
headlineContent = { headlineContent = {
Text( Text(
@@ -707,6 +731,76 @@ fun AppSettingsScreen(navController: NavController) {
), ),
) )
if (BuildConfig.DEBUG && Vendor.supportsTrackSelection()) {
var isForceDownloading by remember { mutableStateOf(false) }
ListItem(
headlineContent = {
Text(
stringResource(R.string.force_download_install),
style = MaterialTheme.typography.bodyLarge,
)
},
leadingContent = {
Icon(
imageVector = Icons.Outlined.SystemUpdateAlt,
contentDescription = null,
tint = MaterialTheme.colorScheme.error,
)
},
trailingContent = {
if (isForceDownloading) {
CircularProgressIndicator(
modifier = Modifier.size(24.dp),
strokeWidth = 2.dp,
)
}
},
modifier =
Modifier
.clip(
if (hasUpdate) {
RoundedCornerShape(0.dp)
} else {
RoundedCornerShape(bottomStart = 12.dp, bottomEnd = 12.dp)
},
)
.clickable(enabled = !isForceDownloading) {
isForceDownloading = true
scope.launch {
try {
val latestUpdate = withContext(Dispatchers.IO) {
Vendor.forceGetLatestUpdate()
}
if (latestUpdate != null) {
showDownloadDialog = true
downloadError = null
downloadJob = scope.launch {
try {
withContext(Dispatchers.IO) {
Vendor.downloadAndInstall(context, latestUpdate.downloadUrl)
}
showDownloadDialog = false
} catch (e: Exception) {
downloadError = e.message
}
}
} else {
showErrorDialog = R.string.no_updates_available
}
} catch (_: UpdateCheckException.TrackNotSupported) {
showErrorDialog = R.string.update_track_not_supported
} catch (_: Exception) {
}
isForceDownloading = false
}
},
colors =
ListItemDefaults.colors(
containerColor = Color.Transparent,
),
)
}
if (hasUpdate && updateInfo != null) { if (hasUpdate && updateInfo != null) {
ListItem( ListItem(
headlineContent = { headlineContent = {

View File

@@ -17,6 +17,7 @@ import android.content.Intent
import android.provider.DocumentsContract import android.provider.DocumentsContract
import android.widget.Toast import android.widget.Toast
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.outlined.DeleteForever import androidx.compose.material.icons.outlined.DeleteForever
import androidx.compose.material.icons.outlined.FolderOpen import androidx.compose.material.icons.outlined.FolderOpen
import androidx.compose.material.icons.outlined.Info import androidx.compose.material.icons.outlined.Info
@@ -24,12 +25,15 @@ import androidx.compose.material.icons.outlined.Storage
import androidx.compose.material.icons.outlined.WarningAmber import androidx.compose.material.icons.outlined.WarningAmber
import androidx.compose.material3.Card import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.ListItem import androidx.compose.material3.ListItem
import androidx.compose.material3.ListItemDefaults import androidx.compose.material3.ListItemDefaults
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Switch import androidx.compose.material3.Switch
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
@@ -46,13 +50,29 @@ import androidx.compose.ui.unit.dp
import androidx.navigation.NavController import androidx.navigation.NavController
import io.nekohasekai.libbox.Libbox import io.nekohasekai.libbox.Libbox
import io.nekohasekai.sfa.R import io.nekohasekai.sfa.R
import io.nekohasekai.sfa.compose.topbar.OverrideTopBar
import io.nekohasekai.sfa.database.Settings import io.nekohasekai.sfa.database.Settings
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
@OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun CoreSettingsScreen(navController: NavController) { fun CoreSettingsScreen(navController: NavController) {
OverrideTopBar {
TopAppBar(
title = { Text(stringResource(R.string.core)) },
navigationIcon = {
IconButton(onClick = { navController.navigateUp() }) {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = stringResource(R.string.content_description_back),
)
}
},
)
}
val context = LocalContext.current val context = LocalContext.current
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
var dataSize by remember { mutableStateOf("") } var dataSize by remember { mutableStateOf("") }

View File

@@ -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))
}
},
)
}

View File

@@ -16,6 +16,7 @@ import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.automirrored.outlined.KeyboardArrowRight import androidx.compose.material.icons.automirrored.outlined.KeyboardArrowRight
import androidx.compose.material.icons.outlined.AppShortcut import androidx.compose.material.icons.outlined.AppShortcut
import androidx.compose.material.icons.outlined.FilterList import androidx.compose.material.icons.outlined.FilterList
@@ -26,7 +27,9 @@ import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Card import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.ListItem import androidx.compose.material3.ListItem
import androidx.compose.material3.ListItemDefaults import androidx.compose.material3.ListItemDefaults
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
@@ -34,6 +37,7 @@ import androidx.compose.material3.RadioButton
import androidx.compose.material3.Switch import androidx.compose.material3.Switch
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
@@ -47,12 +51,16 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalLifecycleOwner
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.navigation.NavController import androidx.navigation.NavController
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import io.nekohasekai.sfa.R import io.nekohasekai.sfa.R
import io.nekohasekai.sfa.compose.topbar.OverrideTopBar
import io.nekohasekai.sfa.database.Settings import io.nekohasekai.sfa.database.Settings
import io.nekohasekai.sfa.ui.profileoverride.PerAppProxyActivity import io.nekohasekai.sfa.compose.screen.profileoverride.PerAppProxyScanner
import io.nekohasekai.sfa.vendor.PackageQueryManager import io.nekohasekai.sfa.vendor.PackageQueryManager
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async import kotlinx.coroutines.async
@@ -60,8 +68,23 @@ import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
@OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun ProfileOverrideScreen(navController: NavController) { fun ProfileOverrideScreen(navController: NavController) {
OverrideTopBar {
TopAppBar(
title = { Text(stringResource(R.string.profile_override)) },
navigationIcon = {
IconButton(onClick = { navController.navigateUp() }) {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = stringResource(R.string.content_description_back),
)
}
},
)
}
val context = LocalContext.current val context = LocalContext.current
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
@@ -73,7 +96,7 @@ fun ProfileOverrideScreen(navController: NavController) {
var showRootDialog by remember { mutableStateOf(false) } var showRootDialog by remember { mutableStateOf(false) }
var showModeDialog by remember { mutableStateOf(false) } var showModeDialog by remember { mutableStateOf(false) }
val needsPrivilegedQuery = PackageQueryManager.needsPrivilegedQuery val showModeSelector = PackageQueryManager.showModeSelector
var packageQueryMode by remember { mutableStateOf(Settings.perAppProxyPackageQueryMode) } var packageQueryMode by remember { mutableStateOf(Settings.perAppProxyPackageQueryMode) }
val useRootMode = packageQueryMode == Settings.PACKAGE_QUERY_MODE_ROOT val useRootMode = packageQueryMode == Settings.PACKAGE_QUERY_MODE_ROOT
@@ -82,20 +105,34 @@ fun ProfileOverrideScreen(navController: NavController) {
val isShizukuPermissionGranted by PackageQueryManager.shizukuPermissionGranted.collectAsState() val isShizukuPermissionGranted by PackageQueryManager.shizukuPermissionGranted.collectAsState()
val isShizukuAvailable = isShizukuBinderReady && isShizukuPermissionGranted val isShizukuAvailable = isShizukuBinderReady && isShizukuPermissionGranted
DisposableEffect(needsPrivilegedQuery) { DisposableEffect(showModeSelector) {
if (needsPrivilegedQuery) { if (showModeSelector) {
PackageQueryManager.registerListeners() PackageQueryManager.registerListeners()
} }
onDispose { onDispose {
if (needsPrivilegedQuery) { if (showModeSelector) {
PackageQueryManager.unregisterListeners() PackageQueryManager.unregisterListeners()
} }
} }
} }
val lifecycleOwner = LocalLifecycleOwner.current
DisposableEffect(lifecycleOwner, showModeSelector) {
if (!showModeSelector) return@DisposableEffect onDispose { }
val observer = LifecycleEventObserver { _, event ->
if (event == Lifecycle.Event.ON_RESUME) {
PackageQueryManager.refreshShizukuState()
}
}
lifecycleOwner.lifecycle.addObserver(observer)
onDispose {
lifecycleOwner.lifecycle.removeObserver(observer)
}
}
// Auto-disable per-app proxy if Shizuku authorization is revoked (only when using Shizuku mode) // Auto-disable per-app proxy if Shizuku authorization is revoked (only when using Shizuku mode)
LaunchedEffect(isShizukuAvailable, useRootMode) { LaunchedEffect(isShizukuAvailable, useRootMode) {
if (needsPrivilegedQuery && !useRootMode && !isShizukuAvailable && perAppProxyEnabled) { if (showModeSelector && !useRootMode && !isShizukuAvailable && perAppProxyEnabled) {
perAppProxyEnabled = false perAppProxyEnabled = false
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
Settings.perAppProxyEnabled = false Settings.perAppProxyEnabled = false
@@ -105,7 +142,7 @@ fun ProfileOverrideScreen(navController: NavController) {
// Auto-close dialog and enable feature when Shizuku becomes available // Auto-close dialog and enable feature when Shizuku becomes available
LaunchedEffect(isShizukuAvailable) { LaunchedEffect(isShizukuAvailable) {
if (needsPrivilegedQuery && isShizukuAvailable && showShizukuDialog) { if (showModeSelector && isShizukuAvailable && showShizukuDialog) {
showShizukuDialog = false showShizukuDialog = false
perAppProxyEnabled = true perAppProxyEnabled = true
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
@@ -212,7 +249,7 @@ fun ProfileOverrideScreen(navController: NavController) {
} }
// Section: Per-App Proxy // Section: Per-App Proxy
val canUsePerAppProxy = if (needsPrivilegedQuery) { val canUsePerAppProxy = if (showModeSelector) {
if (useRootMode) true else isShizukuAvailable if (useRootMode) true else isShizukuAvailable
} else { } else {
true true
@@ -237,7 +274,7 @@ fun ProfileOverrideScreen(navController: NavController) {
) { ) {
Column { Column {
// Mode selector (only when privileged query is needed) // Mode selector (only when privileged query is needed)
if (needsPrivilegedQuery) { if (showModeSelector) {
val modeEnabled = !perAppProxyEnabled val modeEnabled = !perAppProxyEnabled
val disabledAlpha = 0.38f val disabledAlpha = 0.38f
ListItem( ListItem(
@@ -301,7 +338,7 @@ fun ProfileOverrideScreen(navController: NavController) {
Switch( Switch(
checked = perAppProxyEnabled, checked = perAppProxyEnabled,
onCheckedChange = { checked -> onCheckedChange = { checked ->
if (checked && needsPrivilegedQuery) { if (checked && showModeSelector) {
if (useRootMode) { if (useRootMode) {
showRootDialog = true showRootDialog = true
} else { } else {
@@ -329,7 +366,7 @@ fun ProfileOverrideScreen(navController: NavController) {
}, },
modifier = modifier =
Modifier.clip( Modifier.clip(
if (needsPrivilegedQuery) { if (showModeSelector) {
RoundedCornerShape(0.dp) RoundedCornerShape(0.dp)
} else if (perAppProxyEnabled && canUsePerAppProxy) { } else if (perAppProxyEnabled && canUsePerAppProxy) {
RoundedCornerShape(topStart = 12.dp, topEnd = 12.dp) RoundedCornerShape(topStart = 12.dp, topEnd = 12.dp)
@@ -383,8 +420,7 @@ fun ProfileOverrideScreen(navController: NavController) {
}, },
modifier = modifier =
Modifier.clickable(enabled = manageEnabled) { Modifier.clickable(enabled = manageEnabled) {
val intent = Intent(context, PerAppProxyActivity::class.java) navController.navigate("settings/profile_override/manage")
context.startActivity(intent)
}, },
colors = colors =
ListItemDefaults.colors( ListItemDefaults.colors(
@@ -674,7 +710,7 @@ private suspend fun scanAllChinaApps(): Set<String> = withContext(Dispatchers.De
val chinaApps = mutableSetOf<String>() val chinaApps = mutableSetOf<String>()
installedPackages.map { packageInfo -> installedPackages.map { packageInfo ->
async { async {
if (PerAppProxyActivity.scanChinaPackage(packageInfo)) { if (PerAppProxyScanner.scanChinaPackage(packageInfo)) {
synchronized(chinaApps) { synchronized(chinaApps) {
chinaApps.add(packageInfo.packageName) chinaApps.add(packageInfo.packageName)
} }

View File

@@ -19,18 +19,22 @@ import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.outlined.BatteryChargingFull import androidx.compose.material.icons.outlined.BatteryChargingFull
import androidx.compose.material.icons.outlined.Memory import androidx.compose.material.icons.outlined.Memory
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.Card import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.ListItem import androidx.compose.material3.ListItem
import androidx.compose.material3.ListItemDefaults import androidx.compose.material3.ListItemDefaults
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Switch import androidx.compose.material3.Switch
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
@@ -51,16 +55,32 @@ import io.nekohasekai.sfa.R
import io.nekohasekai.sfa.bg.ServiceConnection import io.nekohasekai.sfa.bg.ServiceConnection
import io.nekohasekai.sfa.compose.base.GlobalEventBus import io.nekohasekai.sfa.compose.base.GlobalEventBus
import io.nekohasekai.sfa.compose.base.UiEvent import io.nekohasekai.sfa.compose.base.UiEvent
import io.nekohasekai.sfa.compose.topbar.OverrideTopBar
import io.nekohasekai.sfa.database.Settings import io.nekohasekai.sfa.database.Settings
import io.nekohasekai.sfa.ktx.launchCustomTab import io.nekohasekai.sfa.ktx.launchCustomTab
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun ServiceSettingsScreen( fun ServiceSettingsScreen(
navController: NavController, navController: NavController,
serviceConnection: ServiceConnection? = null, serviceConnection: ServiceConnection? = null,
) { ) {
OverrideTopBar {
TopAppBar(
title = { Text(stringResource(R.string.service)) },
navigationIcon = {
IconButton(onClick = { navController.navigateUp() }) {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = stringResource(R.string.content_description_back),
)
}
},
)
}
val context = LocalContext.current val context = LocalContext.current
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
// Check battery optimization status // Check battery optimization status

View File

@@ -23,6 +23,7 @@ import androidx.compose.material.icons.outlined.Info
import androidx.compose.material.icons.outlined.Settings import androidx.compose.material.icons.outlined.Settings
import androidx.compose.material.icons.outlined.SwapHoriz import androidx.compose.material.icons.outlined.SwapHoriz
import androidx.compose.material.icons.outlined.Tune import androidx.compose.material.icons.outlined.Tune
import androidx.compose.material.icons.outlined.AdminPanelSettings
import androidx.compose.material3.Badge import androidx.compose.material3.Badge
import androidx.compose.material3.Card import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults import androidx.compose.material3.CardDefaults
@@ -32,8 +33,10 @@ import androidx.compose.material3.ListItem
import androidx.compose.material3.ListItemDefaults import androidx.compose.material3.ListItemDefaults
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
@@ -48,20 +51,33 @@ import androidx.compose.ui.unit.dp
import androidx.navigation.NavController import androidx.navigation.NavController
import io.nekohasekai.sfa.BuildConfig import io.nekohasekai.sfa.BuildConfig
import io.nekohasekai.sfa.R import io.nekohasekai.sfa.R
import io.nekohasekai.sfa.compose.topbar.OverrideTopBar
import io.nekohasekai.sfa.database.Settings import io.nekohasekai.sfa.database.Settings
import io.nekohasekai.sfa.update.UpdateState import io.nekohasekai.sfa.update.UpdateState
import io.nekohasekai.sfa.utils.HookModuleUpdateNotifier
import io.nekohasekai.sfa.utils.HookStatusClient
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun SettingsScreen(navController: NavController) { fun SettingsScreen(navController: NavController) {
OverrideTopBar {
TopAppBar(
title = { Text(stringResource(R.string.title_settings)) },
)
}
val context = LocalContext.current val context = LocalContext.current
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val hasUpdate by UpdateState.hasUpdate val hasUpdate by UpdateState.hasUpdate
val hookStatus by HookStatusClient.status.collectAsState()
val hasPendingPrivilegeDowngrade = HookModuleUpdateNotifier.isDowngrade(hookStatus)
val hasPendingPrivilegeUpdate = HookModuleUpdateNotifier.isUpgrade(hookStatus)
var isBatteryOptimizationIgnored by remember { mutableStateOf(true) } var isBatteryOptimizationIgnored by remember { mutableStateOf(true) }
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
HookStatusClient.refresh()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
val pm = context.getSystemService(PowerManager::class.java) val pm = context.getSystemService(PowerManager::class.java)
isBatteryOptimizationIgnored = isBatteryOptimizationIgnored =
@@ -183,13 +199,43 @@ fun SettingsScreen(navController: NavController) {
}, },
modifier = modifier =
Modifier Modifier
.clip(RoundedCornerShape(bottomStart = 12.dp, bottomEnd = 12.dp))
.clickable { navController.navigate("settings/profile_override") }, .clickable { navController.navigate("settings/profile_override") },
colors = colors =
ListItemDefaults.colors( ListItemDefaults.colors(
containerColor = Color.Transparent, containerColor = Color.Transparent,
), ),
) )
ListItem(
headlineContent = {
Text(
stringResource(R.string.privilege_settings),
style = MaterialTheme.typography.bodyLarge,
)
},
leadingContent = {
Icon(
imageVector = Icons.Outlined.AdminPanelSettings,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
)
},
trailingContent = {
if (hasPendingPrivilegeDowngrade) {
Badge(containerColor = MaterialTheme.colorScheme.error)
} else if (hasPendingPrivilegeUpdate) {
Badge(containerColor = Color(0xFFFFC107))
}
},
modifier =
Modifier
.clip(RoundedCornerShape(bottomStart = 12.dp, bottomEnd = 12.dp))
.clickable { navController.navigate("settings/privilege") },
colors =
ListItemDefaults.colors(
containerColor = Color.Transparent,
),
)
} }
} }

View File

@@ -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),
)
},
)
}
}
}
}
}

View File

@@ -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) }
}
}

View File

@@ -20,7 +20,7 @@ object AnsiColorUtils {
private val logWhite = Color(0xFFECF0F1) private val logWhite = Color(0xFFECF0F1)
fun ansiToAnnotatedString(text: String): AnnotatedString { fun ansiToAnnotatedString(text: String): AnnotatedString {
val cleanText = text.replace(ansiRegex, "") val cleanText = stripAnsi(text)
val matches = ansiRegex.findAll(text).toList() val matches = ansiRegex.findAll(text).toList()
if (matches.isEmpty()) { if (matches.isEmpty()) {
@@ -65,6 +65,8 @@ object AnsiColorUtils {
} }
} }
fun stripAnsi(text: String): String = text.replace(ansiRegex, "")
private fun parseAnsiCode(code: String): SpanStyle? { private fun parseAnsiCode(code: String): SpanStyle? {
val colorCodes = code.substringAfter('[').substringBefore('m').split(';') val colorCodes = code.substringAfter('[').substringBefore('m').split(';')

View File

@@ -23,6 +23,11 @@ object SettingsKey {
const val SYSTEM_PROXY_ENABLED = "system_proxy_enabled" const val SYSTEM_PROXY_ENABLED = "system_proxy_enabled"
const val PRIVILEGE_SETTINGS_ENABLED = "hide_settings_enabled"
const val PRIVILEGE_SETTINGS_LIST = "hide_settings_list"
const val PRIVILEGE_SETTINGS_INTERFACE_RENAME_ENABLED = "hide_settings_interface_rename_enabled"
const val PRIVILEGE_SETTINGS_INTERFACE_PREFIX = "hide_settings_interface_prefix"
// dashboard // dashboard
const val DASHBOARD_ITEM_ORDER = "dashboard_item_order" const val DASHBOARD_ITEM_ORDER = "dashboard_item_order"
const val DASHBOARD_DISABLED_ITEMS = "dashboard_disabled_items" const val DASHBOARD_DISABLED_ITEMS = "dashboard_disabled_items"

View File

@@ -92,6 +92,13 @@ object Settings {
var systemProxyEnabled by dataStore.boolean(SettingsKey.SYSTEM_PROXY_ENABLED) { true } var systemProxyEnabled by dataStore.boolean(SettingsKey.SYSTEM_PROXY_ENABLED) { true }
var privilegeSettingsEnabled by dataStore.boolean(SettingsKey.PRIVILEGE_SETTINGS_ENABLED) { false }
var privilegeSettingsList by dataStore.stringSet(SettingsKey.PRIVILEGE_SETTINGS_LIST) { emptySet() }
var privilegeSettingsInterfaceRenameEnabled by dataStore.boolean(
SettingsKey.PRIVILEGE_SETTINGS_INTERFACE_RENAME_ENABLED
) { false }
var privilegeSettingsInterfacePrefix by dataStore.string(SettingsKey.PRIVILEGE_SETTINGS_INTERFACE_PREFIX) { "wlan" }
var dashboardItemOrder by dataStore.string(SettingsKey.DASHBOARD_ITEM_ORDER) { "" } var dashboardItemOrder by dataStore.string(SettingsKey.DASHBOARD_ITEM_ORDER) { "" }
var dashboardDisabledItems by dataStore.stringSet(SettingsKey.DASHBOARD_DISABLED_ITEMS) { emptySet() } var dashboardDisabledItems by dataStore.stringSet(SettingsKey.DASHBOARD_DISABLED_ITEMS) { emptySet() }

View File

@@ -1,26 +1,52 @@
package io.nekohasekai.sfa.ktx package io.nekohasekai.sfa.ktx
import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context import android.content.Context
import androidx.annotation.StringRes import androidx.annotation.StringRes
import android.widget.ScrollView
import android.widget.TextView
import android.widget.Toast
import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.dialog.MaterialAlertDialogBuilder
import io.nekohasekai.sfa.R import io.nekohasekai.sfa.R
fun Context.errorDialogBuilder( fun Context.errorDialogBuilder(
@StringRes messageId: Int, @StringRes messageId: Int,
): MaterialAlertDialogBuilder { ): MaterialAlertDialogBuilder {
return MaterialAlertDialogBuilder(this) return errorDialogBuilder(getString(messageId))
.setTitle(R.string.error_title)
.setMessage(messageId)
.setPositiveButton(android.R.string.ok, null)
} }
fun Context.errorDialogBuilder(message: String): MaterialAlertDialogBuilder { fun Context.errorDialogBuilder(message: String): MaterialAlertDialogBuilder {
val contentView = buildSelectableMessageView(message)
return MaterialAlertDialogBuilder(this) return MaterialAlertDialogBuilder(this)
.setTitle(R.string.error_title) .setTitle(R.string.error_title)
.setMessage(message) .setView(contentView)
.setNeutralButton(R.string.per_app_proxy_action_copy) { _, _ ->
copyToClipboard(message)
}
.setPositiveButton(android.R.string.ok, null) .setPositiveButton(android.R.string.ok, null)
} }
fun Context.errorDialogBuilder(exception: Throwable): MaterialAlertDialogBuilder { fun Context.errorDialogBuilder(exception: Throwable): MaterialAlertDialogBuilder {
return errorDialogBuilder(exception.localizedMessage ?: exception.toString()) return errorDialogBuilder(exception.localizedMessage ?: exception.toString())
} }
private fun Context.buildSelectableMessageView(message: String): ScrollView {
val density = resources.displayMetrics.density
val padding = (16 * density).toInt()
val textView =
TextView(this).apply {
text = message
setTextIsSelectable(true)
setPadding(padding, padding, padding, padding)
}
return ScrollView(this).apply {
addView(textView)
}
}
private fun Context.copyToClipboard(text: String) {
val clipboard = getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
clipboard.setPrimaryClip(ClipData.newPlainText(getString(R.string.error_title), text))
Toast.makeText(this, getString(R.string.copied_to_clipboard), Toast.LENGTH_SHORT).show()
}

View File

@@ -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
}
}
}

View File

@@ -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
}
}

View File

@@ -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()
}
}
}

View File

@@ -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)
}
}
}

View File

@@ -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,
),
)
}
}
}

View File

@@ -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(),
)
}
}
}

View File

@@ -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
}
}

View 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
}
}
}

View 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()
}

View 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()
}
}

View 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
}
}

View File

@@ -2,7 +2,7 @@ package io.nekohasekai.sfa.vendor
import android.app.Activity import android.app.Activity
import androidx.camera.core.ImageAnalysis import androidx.camera.core.ImageAnalysis
import io.nekohasekai.sfa.ui.profile.QRCodeCropArea import io.nekohasekai.sfa.compose.screen.qrscan.QRCodeCropArea
import io.nekohasekai.sfa.update.UpdateInfo import io.nekohasekai.sfa.update.UpdateInfo
interface VendorInterface { interface VendorInterface {
@@ -35,6 +35,12 @@ interface VendorInterface {
*/ */
fun checkUpdateAsync(): UpdateInfo? = null fun checkUpdateAsync(): UpdateInfo? = null
/**
* Force get latest update (ignores version check)
* @return UpdateInfo of the latest release, null if unavailable
*/
fun forceGetLatestUpdate(): UpdateInfo? = null
/** /**
* Check if silent install feature is available * Check if silent install feature is available
* @return true if silent install is supported (Other flavor only) * @return true if silent install is supported (Other flavor only)
@@ -63,8 +69,8 @@ interface VendorInterface {
* Download and install an APK update * Download and install an APK update
* @param context The context * @param context The context
* @param downloadUrl The URL to download the APK from * @param downloadUrl The URL to download the APK from
* @return Result indicating success or failure * @throws Exception if download or install fails
*/ */
suspend fun downloadAndInstall(context: android.content.Context, downloadUrl: String): Result<Unit> = suspend fun downloadAndInstall(context: android.content.Context, downloadUrl: String): Unit =
Result.failure(UnsupportedOperationException("Not supported in this flavor")) throw UnsupportedOperationException("Not supported in this flavor")
} }

View File

@@ -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()
}
}
}

View File

@@ -0,0 +1,5 @@
package io.nekohasekai.sfa.xposed
object HookModuleVersion {
const val CURRENT = 2
}

View File

@@ -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
}

View File

@@ -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,
)
}

View 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
}
}
}
}

View File

@@ -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
}
}
}

View 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()
}
}
}

View File

@@ -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()
}
}

View 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()
}
}
}

View File

@@ -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()
}
}

View 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
}
}
}

View File

@@ -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