diff --git a/.gitignore b/.gitignore index 30679fb..94ba292 100644 --- a/.gitignore +++ b/.gitignore @@ -3,7 +3,7 @@ /local.properties /.idea/ .DS_Store -/build +build/ /captures .externalNativeBuild .cxx diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 4a2a1da..5678dd9 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -1,3 +1,5 @@ +import org.gradle.api.file.DuplicatesStrategy +import org.gradle.api.tasks.Sync import org.jetbrains.kotlin.gradle.dsl.JvmTarget import org.jetbrains.kotlin.gradle.tasks.KotlinCompile import org.jlleitschuh.gradle.ktlint.reporter.ReporterType @@ -125,6 +127,7 @@ android { } getByName("otherLegacy") { java.srcDirs("src/minApi21/java", "src/github/java") + aidl.srcDirs("src/minApi23/aidl") } } @@ -138,8 +141,8 @@ android { } compileOptions { - sourceCompatibility = JavaVersion.VERSION_1_8 - targetCompatibility = JavaVersion.VERSION_1_8 + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 } buildFeatures { @@ -246,10 +249,8 @@ dependencies { val shizukuVersion = "12.2.0" "playImplementation"("dev.rikka.shizuku:api:$shizukuVersion") "playImplementation"("dev.rikka.shizuku:provider:$shizukuVersion") - "playImplementation"("org.lsposed.hiddenapibypass:hiddenapibypass:4.3") "otherImplementation"("dev.rikka.shizuku:api:$shizukuVersion") "otherImplementation"("dev.rikka.shizuku:provider:$shizukuVersion") - "otherImplementation"("org.lsposed.hiddenapibypass:hiddenapibypass:4.3") // libsu for ROOT package query (all flavors) val libsuVersion = "6.0.0" @@ -309,6 +310,10 @@ dependencies { implementation("sh.calvin.reorderable:reorderable:3.0.0") implementation("com.github.jeziellago:compose-markdown:0.5.4") implementation("org.kodein.emoji:emoji-kt:2.3.0") + + // Xposed API for self-hooking VPN hide module + compileOnly("de.robv.android.xposed:api:82") + compileOnly(project(":libxposed-api")) } val playCredentialsJSON = rootProject.file("service-account-credentials.json") @@ -329,7 +334,7 @@ if (playCredentialsJSON.exists()) { tasks.withType().configureEach { compilerOptions { - jvmTarget.set(JvmTarget.JVM_1_8) + jvmTarget.set(JvmTarget.JVM_17) } } diff --git a/app/src/github/java/io/nekohasekai/sfa/vendor/GitHubUpdateChecker.kt b/app/src/github/java/io/nekohasekai/sfa/vendor/GitHubUpdateChecker.kt index 6a0c818..ca2d16b 100644 --- a/app/src/github/java/io/nekohasekai/sfa/vendor/GitHubUpdateChecker.kt +++ b/app/src/github/java/io/nekohasekai/sfa/vendor/GitHubUpdateChecker.kt @@ -27,6 +27,14 @@ class GitHubUpdateChecker : Closeable { private val json = Json { ignoreUnknownKeys = true } fun checkUpdate(track: UpdateTrack): UpdateInfo? { + return getLatestUpdate(track, checkVersion = true) + } + + fun forceGetLatestUpdate(track: UpdateTrack): UpdateInfo? { + return getLatestUpdate(track, checkVersion = false) + } + + private fun getLatestUpdate(track: UpdateTrack, checkVersion: Boolean): UpdateInfo? { val includePrerelease = track == UpdateTrack.BETA val release = getLatestRelease(includePrerelease) ?: return null @@ -36,7 +44,7 @@ class GitHubUpdateChecker : Closeable { val metadata = downloadMetadata(release)!! - if (metadata.versionCode <= BuildConfig.VERSION_CODE) { + if (checkVersion && metadata.versionCode <= BuildConfig.VERSION_CODE) { return null } diff --git a/app/src/github/java/io/nekohasekai/sfa/vendor/RootInstaller.kt b/app/src/github/java/io/nekohasekai/sfa/vendor/RootInstaller.kt index 9860fc0..4b9c760 100644 --- a/app/src/github/java/io/nekohasekai/sfa/vendor/RootInstaller.kt +++ b/app/src/github/java/io/nekohasekai/sfa/vendor/RootInstaller.kt @@ -1,12 +1,18 @@ package io.nekohasekai.sfa.vendor +import android.content.Intent +import android.content.ServiceConnection +import android.os.IBinder +import android.os.ParcelFileDescriptor +import com.topjohnwu.superuser.ipc.RootService +import io.nekohasekai.sfa.Application +import io.nekohasekai.sfa.bg.IRootService import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.coroutines.withContext -import java.io.BufferedReader -import java.io.BufferedWriter import java.io.File -import java.io.InputStreamReader -import java.io.OutputStreamWriter +import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException object RootInstaller { @@ -20,20 +26,63 @@ object RootInstaller { } } - suspend fun install(apkFile: File): Result = withContext(Dispatchers.IO) { - try { - val process = Runtime.getRuntime().exec(arrayOf("su", "-c", "pm install -r \"${apkFile.absolutePath}\"")) - val reader = BufferedReader(InputStreamReader(process.inputStream)) - val output = reader.readText() - val exitCode = process.waitFor() - - if (exitCode == 0 && output.contains("Success")) { - Result.success(Unit) - } else { - Result.failure(Exception("Installation failed: $output")) + suspend fun install(apkFile: File) { + withContext(Dispatchers.IO) { + bindRootService().use { handle -> + ParcelFileDescriptor.open(apkFile, ParcelFileDescriptor.MODE_READ_ONLY).use { pfd -> + handle.service.installPackage( + pfd, + apkFile.length(), + android.os.Process.myUserHandle().hashCode() + ) + } } - } 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) } } } diff --git a/app/src/github/java/io/nekohasekai/sfa/vendor/SystemPackageInstaller.kt b/app/src/github/java/io/nekohasekai/sfa/vendor/SystemPackageInstaller.kt index bf62dce..fe632b6 100644 --- a/app/src/github/java/io/nekohasekai/sfa/vendor/SystemPackageInstaller.kt +++ b/app/src/github/java/io/nekohasekai/sfa/vendor/SystemPackageInstaller.kt @@ -14,39 +14,34 @@ object SystemPackageInstaller { return Build.VERSION.SDK_INT >= Build.VERSION_CODES.S } - fun install(context: Context, apkFile: File): Result { - return try { - val packageInstaller = context.packageManager.packageInstaller - val params = AndroidPackageInstaller.SessionParams(AndroidPackageInstaller.SessionParams.MODE_FULL_INSTALL) - params.setAppPackageName(context.packageName) - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { - params.setRequireUserAction(AndroidPackageInstaller.SessionParams.USER_ACTION_NOT_REQUIRED) + fun install(context: Context, apkFile: File) { + val packageInstaller = context.packageManager.packageInstaller + val params = AndroidPackageInstaller.SessionParams(AndroidPackageInstaller.SessionParams.MODE_FULL_INSTALL) + params.setAppPackageName(context.packageName) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + 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) - 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 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) + val intent = Intent(context, InstallResultReceiver::class.java).apply { + action = InstallResultReceiver.ACTION_INSTALL_COMPLETE } - Result.success(Unit) - } catch (e: Exception) { - Result.failure(e) + val pendingIntent = PendingIntent.getBroadcast( + context, + sessionId, + intent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_MUTABLE + ) + + session.commit(pendingIntent.intentSender) } } } diff --git a/app/src/github/java/io/nekohasekai/sfa/vendor/UpdateWorker.kt b/app/src/github/java/io/nekohasekai/sfa/vendor/UpdateWorker.kt index 0b5dc01..68f11d1 100644 --- a/app/src/github/java/io/nekohasekai/sfa/vendor/UpdateWorker.kt +++ b/app/src/github/java/io/nekohasekai/sfa/vendor/UpdateWorker.kt @@ -77,13 +77,8 @@ class UpdateWorker( val apkFile = ApkDownloader().use { it.download(updateInfo.downloadUrl) } Log.d(TAG, "Installing update...") - val result = ApkInstaller.install(appContext, apkFile) - - if (result.isSuccess) { - Log.d(TAG, "Update installed successfully") - } else { - Log.e(TAG, "Update installation failed", result.exceptionOrNull()) - } + ApkInstaller.install(appContext, apkFile) + Log.d(TAG, "Update installed successfully") } else { Log.d(TAG, "Silent install not available, update will be shown on next app launch") } diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 2d0a015..de27715 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -33,6 +33,7 @@ android:name=".Application" android:allowBackup="true" android:dataExtractionRules="@xml/data_extraction_rules" + android:description="@string/xposed_description" android:fullBackupContent="@xml/backup_rules" android:icon="@mipmap/ic_launcher" android:label="@string/app_name" @@ -41,17 +42,22 @@ tools:targetApi="31"> - + android:launchMode="singleTask" + android:theme="@style/AppTheme"> + + + + + @@ -90,32 +96,6 @@ - - - - - - - - - - + + 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; +} diff --git a/app/src/main/aidl/io/nekohasekai/sfa/bg/IRootService.aidl b/app/src/main/aidl/io/nekohasekai/sfa/bg/IRootService.aidl new file mode 100644 index 0000000..fc58161 --- /dev/null +++ b/app/src/main/aidl/io/nekohasekai/sfa/bg/IRootService.aidl @@ -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; +} diff --git a/app/src/main/aidl/io/nekohasekai/sfa/bg/IShizukuService.aidl b/app/src/main/aidl/io/nekohasekai/sfa/bg/IShizukuService.aidl new file mode 100644 index 0000000..8241f56 --- /dev/null +++ b/app/src/main/aidl/io/nekohasekai/sfa/bg/IShizukuService.aidl @@ -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; +} diff --git a/app/src/main/aidl/io/nekohasekai/sfa/bg/LogEntry.aidl b/app/src/main/aidl/io/nekohasekai/sfa/bg/LogEntry.aidl new file mode 100644 index 0000000..62ca376 --- /dev/null +++ b/app/src/main/aidl/io/nekohasekai/sfa/bg/LogEntry.aidl @@ -0,0 +1,3 @@ +package io.nekohasekai.sfa.bg; + +parcelable LogEntry; diff --git a/app/src/main/aidl/io/nekohasekai/sfa/bg/PackageEntry.aidl b/app/src/main/aidl/io/nekohasekai/sfa/bg/PackageEntry.aidl new file mode 100644 index 0000000..db569f7 --- /dev/null +++ b/app/src/main/aidl/io/nekohasekai/sfa/bg/PackageEntry.aidl @@ -0,0 +1,3 @@ +package io.nekohasekai.sfa.bg; + +parcelable PackageEntry; diff --git a/app/src/main/aidl/io/nekohasekai/sfa/bg/ParceledListSlice.aidl b/app/src/main/aidl/io/nekohasekai/sfa/bg/ParceledListSlice.aidl new file mode 100644 index 0000000..4eaec8b --- /dev/null +++ b/app/src/main/aidl/io/nekohasekai/sfa/bg/ParceledListSlice.aidl @@ -0,0 +1,3 @@ +package io.nekohasekai.sfa.bg; + +parcelable ParceledListSlice; diff --git a/app/src/minApi23/java/android/content/IIntentReceiver.java b/app/src/main/java/android/content/IIntentReceiver.java similarity index 100% rename from app/src/minApi23/java/android/content/IIntentReceiver.java rename to app/src/main/java/android/content/IIntentReceiver.java diff --git a/app/src/minApi23/java/android/content/IIntentSender.java b/app/src/main/java/android/content/IIntentSender.java similarity index 100% rename from app/src/minApi23/java/android/content/IIntentSender.java rename to app/src/main/java/android/content/IIntentSender.java diff --git a/app/src/minApi23/java/android/content/pm/IPackageInstaller.java b/app/src/main/java/android/content/pm/IPackageInstaller.java similarity index 100% rename from app/src/minApi23/java/android/content/pm/IPackageInstaller.java rename to app/src/main/java/android/content/pm/IPackageInstaller.java diff --git a/app/src/minApi23/java/android/content/pm/IPackageInstallerSession.java b/app/src/main/java/android/content/pm/IPackageInstallerSession.java similarity index 100% rename from app/src/minApi23/java/android/content/pm/IPackageInstallerSession.java rename to app/src/main/java/android/content/pm/IPackageInstallerSession.java diff --git a/app/src/main/java/io/github/libxposed/service/RemotePreferences.java b/app/src/main/java/io/github/libxposed/service/RemotePreferences.java new file mode 100644 index 0000000..dbff984 --- /dev/null +++ b/app/src/main/java/io/github/libxposed/service/RemotePreferences.java @@ -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 mMap = new ConcurrentHashMap<>(); + private final Map 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) output.getSerializable("map")); + } + return prefs; + } + + void setDeleted() { + this.isDeleted = true; + } + + @Override + public Map 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 getStringSet(String key, @Nullable Set defValues) { + return (Set) 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 mDelete = new HashSet<>(); + private final HashMap 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 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 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)); + } + } +} diff --git a/app/src/main/java/io/github/libxposed/service/XposedProvider.java b/app/src/main/java/io/github/libxposed/service/XposedProvider.java new file mode 100644 index 0000000..98f56d5 --- /dev/null +++ b/app/src/main/java/io/github/libxposed/service/XposedProvider.java @@ -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; + } +} diff --git a/app/src/main/java/io/github/libxposed/service/XposedService.java b/app/src/main/java/io/github/libxposed/service/XposedService.java new file mode 100644 index 0000000..4f15994 --- /dev/null +++ b/app/src/main/java/io/github/libxposed/service/XposedService.java @@ -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 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 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 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); + } + } +} diff --git a/app/src/main/java/io/github/libxposed/service/XposedServiceHelper.java b/app/src/main/java/io/github/libxposed/service/XposedServiceHelper.java new file mode 100644 index 0000000..8b8c883 --- /dev/null +++ b/app/src/main/java/io/github/libxposed/service/XposedServiceHelper.java @@ -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.
+ * 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 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.
+ * 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 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(); + } + } + } +} diff --git a/app/src/main/java/io/nekohasekai/sfa/Application.kt b/app/src/main/java/io/nekohasekai/sfa/Application.kt index b80d701..78c0edf 100644 --- a/app/src/main/java/io/nekohasekai/sfa/Application.kt +++ b/app/src/main/java/io/nekohasekai/sfa/Application.kt @@ -16,6 +16,9 @@ import io.nekohasekai.libbox.SetupOptions import io.nekohasekai.sfa.bg.AppChangeReceiver import io.nekohasekai.sfa.bg.UpdateProfileWork import io.nekohasekai.sfa.constant.Bugs +import io.nekohasekai.sfa.utils.HookModuleUpdateNotifier +import io.nekohasekai.sfa.utils.PrivilegeSettingsClient +import io.nekohasekai.sfa.utils.HookStatusClient import io.nekohasekai.sfa.vendor.Vendor import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.GlobalScope @@ -35,11 +38,14 @@ class Application : Application() { Seq.setContext(this) Libbox.setLocale(Locale.getDefault().toLanguageTag().replace("-", "_")) + HookStatusClient.register(this) + PrivilegeSettingsClient.register(this) @Suppress("OPT_IN_USAGE") GlobalScope.launch(Dispatchers.IO) { initialize() UpdateProfileWork.reconfigureUpdater() + HookModuleUpdateNotifier.sync(this@Application) } if (Vendor.isPerAppProxyAvailable()) { diff --git a/app/src/main/java/io/nekohasekai/sfa/LauncherActivity.kt b/app/src/main/java/io/nekohasekai/sfa/LauncherActivity.kt deleted file mode 100644 index 9c0a50d..0000000 --- a/app/src/main/java/io/nekohasekai/sfa/LauncherActivity.kt +++ /dev/null @@ -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() - } -} diff --git a/app/src/main/java/io/nekohasekai/sfa/bg/AppChangeReceiver.kt b/app/src/main/java/io/nekohasekai/sfa/bg/AppChangeReceiver.kt index 37d3189..693a34e 100644 --- a/app/src/main/java/io/nekohasekai/sfa/bg/AppChangeReceiver.kt +++ b/app/src/main/java/io/nekohasekai/sfa/bg/AppChangeReceiver.kt @@ -9,7 +9,7 @@ import android.util.Log import android.widget.Toast import io.nekohasekai.sfa.R import io.nekohasekai.sfa.database.Settings -import io.nekohasekai.sfa.ui.profileoverride.PerAppProxyActivity +import io.nekohasekai.sfa.compose.screen.profileoverride.PerAppProxyScanner import io.nekohasekai.sfa.vendor.PackageQueryManager import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -64,7 +64,7 @@ class AppChangeReceiver : BroadcastReceiver() { val installedPackages = PackageQueryManager.getInstalledPackages(packageManagerFlags) val chinaApps = mutableSetOf() for (packageInfo in installedPackages) { - if (PerAppProxyActivity.scanChinaPackage(packageInfo)) { + if (PerAppProxyScanner.scanChinaPackage(packageInfo)) { chinaApps.add(packageInfo.packageName) } } diff --git a/app/src/main/java/io/nekohasekai/sfa/bg/DebugInfoExporter.kt b/app/src/main/java/io/nekohasekai/sfa/bg/DebugInfoExporter.kt new file mode 100644 index 0000000..2904ec0 --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sfa/bg/DebugInfoExporter.kt @@ -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() + 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): 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): 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, + 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): 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, + 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, + ): 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, + command: List, + ): 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, + 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() + } +} diff --git a/app/src/main/java/io/nekohasekai/sfa/bg/LogEntry.java b/app/src/main/java/io/nekohasekai/sfa/bg/LogEntry.java new file mode 100644 index 0000000..d2840a3 --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sfa/bg/LogEntry.java @@ -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 CREATOR = new Creator<>() { + @Override + public LogEntry createFromParcel(Parcel in) { + return new LogEntry(in); + } + + @Override + public LogEntry[] newArray(int size) { + return new LogEntry[size]; + } + }; +} diff --git a/app/src/main/java/io/nekohasekai/sfa/bg/PackageEntry.java b/app/src/main/java/io/nekohasekai/sfa/bg/PackageEntry.java new file mode 100644 index 0000000..42735b9 --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sfa/bg/PackageEntry.java @@ -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 CREATOR = new Creator<>() { + @Override + public PackageEntry createFromParcel(Parcel in) { + return new PackageEntry(in); + } + + @Override + public PackageEntry[] newArray(int size) { + return new PackageEntry[size]; + } + }; +} diff --git a/app/src/main/java/io/nekohasekai/sfa/bg/ParceledListSlice.java b/app/src/main/java/io/nekohasekai/sfa/bg/ParceledListSlice.java new file mode 100644 index 0000000..d72419d --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sfa/bg/ParceledListSlice.java @@ -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 implements Parcelable { + private static final int MAX_IPC_SIZE = 64 * 1024; + + private final List mList; + + public ParceledListSlice(List 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 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 CREATOR = + new Parcelable.ClassLoaderCreator() { + @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]; + } + }; +} diff --git a/app/src/minApi23/java/io/nekohasekai/sfa/vendor/RootPackageManager.kt b/app/src/main/java/io/nekohasekai/sfa/bg/RootClient.kt similarity index 80% rename from app/src/minApi23/java/io/nekohasekai/sfa/vendor/RootPackageManager.kt rename to app/src/main/java/io/nekohasekai/sfa/bg/RootClient.kt index da995b2..53e099d 100644 --- a/app/src/minApi23/java/io/nekohasekai/sfa/vendor/RootPackageManager.kt +++ b/app/src/main/java/io/nekohasekai/sfa/bg/RootClient.kt @@ -1,10 +1,11 @@ -package io.nekohasekai.sfa.vendor +package io.nekohasekai.sfa.bg import android.content.ComponentName import android.content.Intent import android.content.ServiceConnection import android.content.pm.PackageInfo import android.os.IBinder +import android.os.RemoteException import com.topjohnwu.superuser.Shell import com.topjohnwu.superuser.ipc.RootService import io.nekohasekai.sfa.Application @@ -17,10 +18,8 @@ import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext import kotlin.coroutines.resume -import kotlin.coroutines.resumeWithException - -object RootPackageManager { +object RootClient { init { Shell.enableVerboseLogging = BuildConfig.DEBUG Shell.setDefaultBuilder( @@ -36,7 +35,7 @@ object RootPackageManager { private val _serviceConnected = MutableStateFlow(false) val serviceConnected: StateFlow = _serviceConnected - private var service: IRootPackageManager? = null + private var service: IRootService? = null private var connection: ServiceConnection? = null private val connectionMutex = Mutex() @@ -51,14 +50,14 @@ object RootPackageManager { } } - suspend fun bindService(): IRootPackageManager = connectionMutex.withLock { + suspend fun bindService(): IRootService = connectionMutex.withLock { service?.let { return it } return withContext(Dispatchers.Main) { suspendCancellableCoroutine { continuation -> val conn = object : ServiceConnection { override fun onServiceConnected(name: ComponentName?, binder: IBinder?) { - val svc = IRootPackageManager.Stub.asInterface(binder) + val svc = IRootService.Stub.asInterface(binder) service = svc connection = this _serviceConnected.value = true @@ -72,7 +71,7 @@ object RootPackageManager { } } - val intent = Intent(Application.application, RootPackageManagerService::class.java) + val intent = Intent(Application.application, RootServer::class.java) RootService.bind(intent, conn) continuation.invokeOnCancellation { @@ -91,18 +90,16 @@ object RootPackageManager { } } - private const val CHUNK_SIZE = 50 - suspend fun getInstalledPackages(flags: Int): List { + val userId = android.os.Process.myUserHandle().hashCode() val svc = bindService() - val result = mutableListOf() - var offset = 0 - while (true) { - val chunk = svc.getInstalledPackages(flags, offset, CHUNK_SIZE) - if (chunk.isEmpty()) break - result.addAll(chunk) - offset += chunk.size + return try { + val slice = svc.getInstalledPackages(flags, userId) + @Suppress("UNCHECKED_CAST") + val list = slice.list as List + list + } catch (e: RemoteException) { + throw e.rethrowFromSystemServer() } - return result } } diff --git a/app/src/main/java/io/nekohasekai/sfa/bg/RootServer.kt b/app/src/main/java/io/nekohasekai/sfa/bg/RootServer.kt new file mode 100644 index 0000000..1d95894 --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sfa/bg/RootServer.kt @@ -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 { + 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 + } +} diff --git a/app/src/main/java/io/nekohasekai/sfa/bg/ServiceNotification.kt b/app/src/main/java/io/nekohasekai/sfa/bg/ServiceNotification.kt index 4f07ca4..db10ede 100644 --- a/app/src/main/java/io/nekohasekai/sfa/bg/ServiceNotification.kt +++ b/app/src/main/java/io/nekohasekai/sfa/bg/ServiceNotification.kt @@ -16,7 +16,7 @@ import androidx.lifecycle.MutableLiveData import io.nekohasekai.libbox.Libbox import io.nekohasekai.libbox.StatusMessage import io.nekohasekai.sfa.Application -import io.nekohasekai.sfa.LauncherActivity +import io.nekohasekai.sfa.compose.MainActivity import io.nekohasekai.sfa.R import io.nekohasekai.sfa.constant.Action import io.nekohasekai.sfa.constant.Status @@ -61,7 +61,7 @@ class ServiceNotification( 0, Intent( service, - LauncherActivity::class.java, + MainActivity::class.java, ).setFlags(Intent.FLAG_ACTIVITY_REORDER_TO_FRONT), flags, ), diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/EditProfileActivity.kt b/app/src/main/java/io/nekohasekai/sfa/compose/EditProfileActivity.kt deleted file mode 100644 index 53f9678..0000000 --- a/app/src/main/java/io/nekohasekai/sfa/compose/EditProfileActivity.kt +++ /dev/null @@ -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, - ) - } - } - } - } - } - } -} diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/GroupsActivity.kt b/app/src/main/java/io/nekohasekai/sfa/compose/GroupsActivity.kt deleted file mode 100644 index 9058298..0000000 --- a/app/src/main/java/io/nekohasekai/sfa/compose/GroupsActivity.kt +++ /dev/null @@ -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() - } -} diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/MainActivity.kt b/app/src/main/java/io/nekohasekai/sfa/compose/MainActivity.kt index 45b6766..de9f4c6 100644 --- a/app/src/main/java/io/nekohasekai/sfa/compose/MainActivity.kt +++ b/app/src/main/java/io/nekohasekai/sfa/compose/MainActivity.kt @@ -23,13 +23,7 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.ui.text.font.FontWeight import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.ArrowBack -import androidx.compose.material.icons.filled.Close -import androidx.compose.material.icons.filled.ExpandLess -import androidx.compose.material.icons.filled.MoreVert -import androidx.compose.material.icons.filled.Pause import androidx.compose.material.icons.filled.PlayArrow -import androidx.compose.material.icons.filled.Search import androidx.compose.material.icons.filled.Stop import androidx.compose.material.icons.filled.UnfoldLess import androidx.compose.material.icons.filled.UnfoldMore @@ -64,10 +58,9 @@ import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Text import androidx.compose.material3.TextButton -import androidx.compose.material3.TopAppBar -import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue @@ -94,13 +87,19 @@ import io.nekohasekai.sfa.R import io.nekohasekai.sfa.bg.ServiceConnection import io.nekohasekai.sfa.bg.ServiceNotification import io.nekohasekai.sfa.compose.base.GlobalEventBus +import io.nekohasekai.sfa.compose.base.SelectableMessageDialog import io.nekohasekai.sfa.compose.base.UiEvent import io.nekohasekai.sfa.compose.component.ServiceStatusBar import io.nekohasekai.sfa.compose.component.UptimeText import io.nekohasekai.sfa.compose.component.UpdateAvailableDialog +import io.nekohasekai.sfa.compose.navigation.NewProfileArgs +import io.nekohasekai.sfa.compose.navigation.ProfileRoutes import io.nekohasekai.sfa.compose.navigation.SFANavHost import io.nekohasekai.sfa.compose.navigation.Screen import io.nekohasekai.sfa.compose.navigation.bottomNavigationScreens +import io.nekohasekai.sfa.compose.topbar.LocalTopBarController +import io.nekohasekai.sfa.compose.topbar.TopBarEntry +import io.nekohasekai.sfa.compose.topbar.TopBarController import io.nekohasekai.sfa.compose.screen.dashboard.CardGroup import io.nekohasekai.sfa.compose.screen.dashboard.DashboardViewModel import io.nekohasekai.sfa.compose.screen.dashboard.GroupsCard @@ -131,6 +130,7 @@ class MainActivity : ComponentActivity(), ServiceConnection.Callback { private var showBackgroundLocationDialog by mutableStateOf(false) private var showImportProfileDialog by mutableStateOf(false) private var pendingImportProfile by mutableStateOf?>(null) + private var newProfileArgs by mutableStateOf(NewProfileArgs()) private val notificationPermissionLauncher = registerForActivityResult( @@ -171,6 +171,7 @@ class MainActivity : ComponentActivity(), ServiceConnection.Callback { onServiceAlert(Alert.RequestVPNPermission, null) } } + private val pendingNavigationRoute = mutableStateOf(null) override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -204,7 +205,13 @@ class MainActivity : ComponentActivity(), ServiceConnection.Callback { } private fun handleIntent(intent: Intent?) { - val uri = intent?.data ?: return + if (intent == null) { + return + } + if (intent.categories?.contains("de.robv.android.xposed.category.MODULE_SETTINGS") == true) { + pendingNavigationRoute.value = "settings/privilege" + } + val uri = intent.data ?: return if (uri.scheme == "sing-box" && uri.host == "import-remote-profile") { try { val profile = Libbox.parseRemoteProfileImportLink(uri.toString()) @@ -286,6 +293,15 @@ class MainActivity : ComponentActivity(), ServiceConnection.Callback { // Error dialog state for UiEvent.ShowError var showErrorDialog by remember { mutableStateOf(false) } var errorMessage by remember { mutableStateOf("") } + val topBarState = remember { mutableStateOf(emptyList()) } + val topBarController = remember { TopBarController(topBarState) } + val topBarOverride = topBarState.value.lastOrNull()?.content + val openNewProfile: (NewProfileArgs) -> Unit = { args -> + newProfileArgs = args + navController.navigate(ProfileRoutes.NewProfile) { + launchSingleTop = true + } + } // Handle service alerts currentAlert?.let { (alertType, message) -> @@ -298,15 +314,10 @@ class MainActivity : ComponentActivity(), ServiceConnection.Callback { // Handle UiEvent.ShowError dialog if (showErrorDialog) { - AlertDialog( - onDismissRequest = { showErrorDialog = false }, - title = { Text(stringResource(R.string.error_title)) }, - text = { Text(errorMessage) }, - confirmButton = { - TextButton(onClick = { showErrorDialog = false }) { - Text(stringResource(R.string.ok)) - } - }, + SelectableMessageDialog( + title = stringResource(R.string.error_title), + message = errorMessage, + onDismiss = { showErrorDialog = false }, ) } @@ -341,11 +352,11 @@ class MainActivity : ComponentActivity(), ServiceConnection.Callback { text = { Text(stringResource(R.string.import_remote_profile_message, name, host)) }, confirmButton = { TextButton(onClick = { - startActivity( - Intent(this@MainActivity, NewProfileActivity::class.java).apply { - putExtra(NewProfileActivity.EXTRA_IMPORT_NAME, name) - putExtra(NewProfileActivity.EXTRA_IMPORT_URL, url) - }, + openNewProfile( + NewProfileArgs( + importName = name, + importUrl = url, + ), ) showImportProfileDialog = false pendingImportProfile = null @@ -432,17 +443,13 @@ class MainActivity : ComponentActivity(), ServiceConnection.Callback { downloadError = null downloadJob = scope.launch { try { - val result = withContext(Dispatchers.IO) { + withContext(Dispatchers.IO) { Vendor.downloadAndInstall( this@MainActivity, updateInfo!!.downloadUrl, ) } - if (result.isFailure) { - downloadError = result.exceptionOrNull()?.message - } else { - showDownloadDialog = false - } + showDownloadDialog = false } catch (e: Exception) { downloadError = e.message } @@ -496,40 +503,23 @@ class MainActivity : ComponentActivity(), ServiceConnection.Callback { val isSettingsSubScreen = currentRoute?.startsWith("settings/") == true val isConnectionsDetail = currentRoute?.startsWith("connections/detail") == true + val isProfileRoute = currentRoute?.startsWith("profile/") == true val currentRootRoute = when { isSettingsSubScreen -> Screen.Settings.route currentRoute?.startsWith(Screen.Connections.route) == true -> Screen.Connections.route currentRoute?.startsWith(Screen.Log.route) == true -> Screen.Log.route + isProfileRoute -> Screen.Dashboard.route else -> currentRoute } val isConnectionsRoute = currentRootRoute == Screen.Connections.route val isGroupsRoute = currentRootRoute == Screen.Groups.route + val isLogRoute = currentRootRoute == Screen.Log.route - // Determine current screen title - val currentScreen = - when (currentRootRoute) { - Screen.Dashboard.route -> Screen.Dashboard - Screen.Groups.route -> Screen.Groups - Screen.Connections.route -> Screen.Connections - Screen.Log.route -> Screen.Log - Screen.Settings.route -> Screen.Settings - else -> Screen.Dashboard - } - - val isSubScreen = isSettingsSubScreen || isConnectionsDetail - val settingsScreenTitle = - when (currentRoute) { - "settings/app" -> stringResource(R.string.title_app_settings) - "settings/core" -> stringResource(R.string.core) - "settings/service" -> stringResource(R.string.service) - "settings/profile_override" -> stringResource(R.string.profile_override) - else -> null - } - + val isSubScreen = isSettingsSubScreen || isConnectionsDetail || isProfileRoute // Get LogViewModel instance if we're on the Log screen val logViewModel: LogViewModel? = - if (currentScreen == Screen.Log) { + if (isLogRoute) { viewModel() } else { null @@ -593,6 +583,16 @@ class MainActivity : ComponentActivity(), ServiceConnection.Callback { } } + val pendingRoute = pendingNavigationRoute.value + LaunchedEffect(pendingRoute) { + if (pendingRoute != null) { + navController.navigate(pendingRoute) { + launchSingleTop = true + } + pendingNavigationRoute.value = null + } + } + LaunchedEffect(allowedRoutes, currentRootRoute, useNavigationRail) { if (currentRootRoute != null && !allowedRoutes.contains(currentRootRoute)) { navController.navigate(Screen.Dashboard.route) { @@ -627,10 +627,9 @@ class MainActivity : ComponentActivity(), ServiceConnection.Callback { } is UiEvent.EditProfile -> { - val intent = - Intent(this@MainActivity, EditProfileActivity::class.java) - intent.putExtra("profile_id", event.profileId) - startActivity(intent) + navController.navigate(ProfileRoutes.editProfile(event.profileId)) { + launchSingleTop = true + } } is UiEvent.RestartToTakeEffect -> { @@ -656,133 +655,7 @@ class MainActivity : ComponentActivity(), ServiceConnection.Callback { } val topBarContent: @Composable () -> Unit = { - TopAppBar( - title = { - Text( - when { - isSettingsSubScreen && settingsScreenTitle != null -> settingsScreenTitle - isConnectionsDetail -> stringResource(R.string.connection_details) - else -> stringResource(currentScreen.titleRes) - }, - ) - }, - navigationIcon = { - if (isSubScreen) { - IconButton(onClick = { navController.navigateUp() }) { - Icon( - imageVector = Icons.AutoMirrored.Default.ArrowBack, - contentDescription = stringResource(R.string.content_description_back), - ) - } - } - }, - actions = { - // Show Others menu for Dashboard screen (but not in settings sub-screens) - if (currentScreen == Screen.Dashboard && !isSettingsSubScreen) { - // More options button - IconButton(onClick = { dashboardViewModel.toggleCardSettingsDialog() }) { - Icon( - imageVector = Icons.Default.MoreVert, - contentDescription = stringResource(R.string.title_others), - tint = MaterialTheme.colorScheme.onSurface, - ) - } - } - - if (currentScreen == Screen.Groups && groupsViewModel != null) { - val groupsUiState by groupsViewModel.uiState.collectAsState() - val allCollapsed = groupsUiState.expandedGroups.isEmpty() - if (groupsUiState.groups.isNotEmpty()) { - IconButton(onClick = { groupsViewModel.toggleAllGroups() }) { - Icon( - imageVector = if (allCollapsed) Icons.Default.UnfoldMore else Icons.Default.UnfoldLess, - contentDescription = - if (allCollapsed) { - stringResource(R.string.expand_all) - } else { - stringResource(R.string.collapse_all) - }, - ) - } - } - } - - if (isConnectionsDetail && connectionsViewModel != null) { - val connectionsUiState by connectionsViewModel.uiState.collectAsState() - val connectionId = navBackStackEntry?.arguments?.getString("connectionId") - val detailConnection = - connectionsUiState.allConnections.find { it.id == connectionId } - ?: connectionsUiState.connections.find { it.id == connectionId } - if (detailConnection?.isActive == true) { - IconButton(onClick = { connectionsViewModel.closeConnection(detailConnection.id) }) { - Icon( - imageVector = Icons.Default.Close, - contentDescription = stringResource(R.string.connection_close), - ) - } - } - } - - if (currentScreen == Screen.Log && logViewModel != null) { - val logUiState by logViewModel.uiState.collectAsState() - - if (!logUiState.isSelectionMode) { - IconButton(onClick = { logViewModel.togglePause() }) { - Icon( - imageVector = - if (logUiState.isPaused) { - Icons.Default.PlayArrow - } else { - Icons.Default.Pause - }, - contentDescription = - if (logUiState.isPaused) { - stringResource( - R.string.content_description_resume_logs, - ) - } else { - stringResource(R.string.content_description_pause_logs) - }, - ) - } - - IconButton(onClick = { logViewModel.toggleSearch() }) { - Icon( - imageVector = - if (logUiState.isSearchActive) { - Icons.Default.ExpandLess - } else { - Icons.Default.Search - }, - contentDescription = - if (logUiState.isSearchActive) { - stringResource( - R.string.content_description_collapse_search, - ) - } else { - stringResource(R.string.content_description_search_logs) - }, - tint = - if (logUiState.isSearchActive) { - MaterialTheme.colorScheme.primary - } else { - MaterialTheme.colorScheme.onSurface - }, - ) - } - - IconButton(onClick = { logViewModel.toggleOptionsMenu() }) { - Icon( - imageVector = Icons.Default.MoreVert, - contentDescription = stringResource(R.string.more_options), - tint = MaterialTheme.colorScheme.onSurface, - ) - } - } - } - }, - colors = TopAppBarDefaults.topAppBarColors(), - ) + topBarOverride?.invoke() } val scaffoldContent: @Composable (PaddingValues) -> Unit = { paddingValues -> @@ -802,6 +675,9 @@ class MainActivity : ComponentActivity(), ServiceConnection.Callback { serviceStatus = currentServiceStatus, showStartFab = showStartFab, showStatusBar = showStatusBar, + newProfileArgs = newProfileArgs, + onClearNewProfileArgs = { newProfileArgs = NewProfileArgs() }, + onOpenNewProfile = openNewProfile, dashboardViewModel = dashboardViewModel, logViewModel = logViewModel, groupsViewModel = groupsViewModel, @@ -816,7 +692,7 @@ class MainActivity : ComponentActivity(), ServiceConnection.Callback { groupsCount = dashboardUiState.groupsCount, hasGroups = dashboardUiState.hasGroups, onGroupsClick = { showGroupsSheet = true }, - connectionsCount = dashboardUiState.connectionsOut.toIntOrNull() ?: 0, + connectionsCount = dashboardUiState.connectionsCount, onConnectionsClick = { showConnectionsSheet = true }, onStopClick = { dashboardViewModel.toggleService() }, modifier = Modifier.align(Alignment.BottomCenter), @@ -936,61 +812,18 @@ class MainActivity : ComponentActivity(), ServiceConnection.Callback { } } - if (useNavigationRail) { - Row(modifier = Modifier.fillMaxSize()) { - Surface(tonalElevation = 1.dp) { - NavigationRail( - modifier = Modifier.fillMaxHeight(), - ) { - val hasUpdate by UpdateState.hasUpdate - railScreens.forEach { screen -> - val selected = currentRootRoute == screen.route + CompositionLocalProvider(LocalTopBarController provides topBarController) { + if (useNavigationRail) { + Row(modifier = Modifier.fillMaxSize()) { + Surface(tonalElevation = 1.dp) { + NavigationRail( + modifier = Modifier.fillMaxHeight(), + ) { + val hasUpdate by UpdateState.hasUpdate + railScreens.forEach { screen -> + val selected = currentRootRoute == screen.route - 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( + NavigationRailItem( icon = { if (screen == Screen.Settings && hasUpdate) { BadgedBox(badge = { Badge(containerColor = MaterialTheme.colorScheme.primary) }) { @@ -1000,20 +833,14 @@ class MainActivity : ComponentActivity(), ServiceConnection.Callback { Icon(screen.icon, contentDescription = null) } }, - selected = - currentDestination?.hierarchy?.any { - it.route == screen.route - } == true, + label = { Text(stringResource(screen.titleRes)) }, + selected = selected, 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 } }, @@ -1021,9 +848,60 @@ class MainActivity : ComponentActivity(), ServiceConnection.Callback { } } } - }, - ) { paddingValues -> - scaffoldContent(paddingValues) + + 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 = { + 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) + } } } diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/NewProfileActivity.kt b/app/src/main/java/io/nekohasekai/sfa/compose/NewProfileActivity.kt deleted file mode 100644 index c2f736e..0000000 --- a/app/src/main/java/io/nekohasekai/sfa/compose/NewProfileActivity.kt +++ /dev/null @@ -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() - }, - ) - } - } - } - } -} diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/base/SelectableMessageDialog.kt b/app/src/main/java/io/nekohasekai/sfa/compose/base/SelectableMessageDialog.kt new file mode 100644 index 0000000..1a6ae37 --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sfa/compose/base/SelectableMessageDialog.kt @@ -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)) + } + }, + ) +} diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/component/qr/QRScanSheet.kt b/app/src/main/java/io/nekohasekai/sfa/compose/component/qr/QRScanSheet.kt index 6de9561..281f461 100644 --- a/app/src/main/java/io/nekohasekai/sfa/compose/component/qr/QRScanSheet.kt +++ b/app/src/main/java/io/nekohasekai/sfa/compose/component/qr/QRScanSheet.kt @@ -56,7 +56,7 @@ import androidx.lifecycle.viewmodel.compose.viewModel import io.nekohasekai.sfa.R import io.nekohasekai.sfa.compose.screen.qrscan.QRScanResult import io.nekohasekai.sfa.compose.screen.qrscan.QRScanViewModel -import io.nekohasekai.sfa.ui.profile.QRCodeCropArea +import io.nekohasekai.sfa.compose.screen.qrscan.QRCodeCropArea import kotlin.math.max @OptIn(ExperimentalMaterial3Api::class) diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/navigation/ProfileRoutes.kt b/app/src/main/java/io/nekohasekai/sfa/compose/navigation/ProfileRoutes.kt new file mode 100644 index 0000000..91ea094 --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sfa/compose/navigation/ProfileRoutes.kt @@ -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" +} diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/navigation/SFANavigation.kt b/app/src/main/java/io/nekohasekai/sfa/compose/navigation/SFANavigation.kt index 7e9458f..d9b8ef5 100644 --- a/app/src/main/java/io/nekohasekai/sfa/compose/navigation/SFANavigation.kt +++ b/app/src/main/java/io/nekohasekai/sfa/compose/navigation/SFANavigation.kt @@ -5,25 +5,50 @@ import androidx.compose.animation.AnimatedContentTransitionScope import androidx.compose.animation.core.tween import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect import androidx.compose.ui.Modifier import androidx.navigation.NavHostController +import androidx.navigation.NavType import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable +import androidx.navigation.navArgument import io.nekohasekai.sfa.compose.screen.dashboard.DashboardScreen import io.nekohasekai.sfa.compose.screen.dashboard.DashboardViewModel import io.nekohasekai.sfa.compose.screen.dashboard.GroupsCard import io.nekohasekai.sfa.compose.screen.dashboard.groups.GroupsViewModel import io.nekohasekai.sfa.compose.screen.connections.ConnectionDetailsRoute import io.nekohasekai.sfa.compose.screen.connections.ConnectionsPage +import io.nekohasekai.sfa.compose.screen.log.HookLogScreen import io.nekohasekai.sfa.compose.screen.log.LogScreen import io.nekohasekai.sfa.compose.screen.log.LogViewModel import io.nekohasekai.sfa.compose.screen.connections.ConnectionsViewModel +import io.nekohasekai.sfa.compose.screen.configuration.NewProfileScreen +import io.nekohasekai.sfa.compose.screen.profile.EditProfileRoute import io.nekohasekai.sfa.compose.screen.settings.AppSettingsScreen import io.nekohasekai.sfa.compose.screen.settings.CoreSettingsScreen +import io.nekohasekai.sfa.compose.screen.settings.PrivilegeSettingsScreen import io.nekohasekai.sfa.compose.screen.settings.ProfileOverrideScreen import io.nekohasekai.sfa.compose.screen.settings.ServiceSettingsScreen import io.nekohasekai.sfa.compose.screen.settings.SettingsScreen import io.nekohasekai.sfa.constant.Status +import io.nekohasekai.sfa.compose.screen.privilegesettings.PrivilegeSettingsManageScreen +import io.nekohasekai.sfa.compose.screen.profileoverride.PerAppProxyScreen + +private val slideInFromRight: AnimatedContentTransitionScope<*>.() -> androidx.compose.animation.EnterTransition = { + slideIntoContainer(AnimatedContentTransitionScope.SlideDirection.Left, animationSpec = tween(300)) +} + +private val slideOutToRight: AnimatedContentTransitionScope<*>.() -> androidx.compose.animation.ExitTransition = { + slideOutOfContainer(AnimatedContentTransitionScope.SlideDirection.Right, animationSpec = tween(300)) +} + +private val slideInFromLeft: AnimatedContentTransitionScope<*>.() -> androidx.compose.animation.EnterTransition = { + slideIntoContainer(AnimatedContentTransitionScope.SlideDirection.Right, animationSpec = tween(300)) +} + +private val slideOutToLeft: AnimatedContentTransitionScope<*>.() -> androidx.compose.animation.ExitTransition = { + slideOutOfContainer(AnimatedContentTransitionScope.SlideDirection.Left, animationSpec = tween(300)) +} @Composable fun SFANavHost( @@ -31,6 +56,9 @@ fun SFANavHost( serviceStatus: Status = Status.Stopped, showStartFab: Boolean = false, showStatusBar: Boolean = false, + newProfileArgs: NewProfileArgs = NewProfileArgs(), + onClearNewProfileArgs: () -> Unit = {}, + onOpenNewProfile: (NewProfileArgs) -> Unit = {}, dashboardViewModel: DashboardViewModel? = null, logViewModel: LogViewModel? = null, groupsViewModel: GroupsViewModel? = null, @@ -48,6 +76,7 @@ fun SFANavHost( serviceStatus = serviceStatus, showStartFab = showStartFab, showStatusBar = showStatusBar, + onOpenNewProfile = onOpenNewProfile, viewModel = dashboardViewModel, ) } else { @@ -55,6 +84,7 @@ fun SFANavHost( serviceStatus = serviceStatus, showStartFab = showStartFab, showStatusBar = showStatusBar, + onOpenNewProfile = onOpenNewProfile, ) } } @@ -81,11 +111,13 @@ fun SFANavHost( GroupsCard( serviceStatus = serviceStatus, viewModel = groupsViewModel, + showTopBar = true, modifier = Modifier.fillMaxSize(), ) } else { GroupsCard( serviceStatus = serviceStatus, + showTopBar = true, modifier = Modifier.fillMaxSize(), ) } @@ -97,6 +129,7 @@ fun SFANavHost( serviceStatus = serviceStatus, viewModel = connectionsViewModel, showTitle = false, + showTopBar = true, onConnectionClick = { connectionId -> navController.navigate("connections/detail/${Uri.encode(connectionId)}") }, @@ -106,6 +139,7 @@ fun SFANavHost( ConnectionsPage( serviceStatus = serviceStatus, showTitle = false, + showTopBar = true, onConnectionClick = { connectionId -> navController.navigate("connections/detail/${Uri.encode(connectionId)}") }, @@ -114,6 +148,45 @@ fun SFANavHost( } } + composable(ProfileRoutes.NewProfile) { + DisposableEffect(Unit) { + onDispose { onClearNewProfileArgs() } + } + NewProfileScreen( + importName = newProfileArgs.importName, + importUrl = newProfileArgs.importUrl, + qrsData = newProfileArgs.qrsData, + onNavigateBack = { + onClearNewProfileArgs() + navController.navigateUp() + }, + onProfileCreated = { profileId -> + onClearNewProfileArgs() + navController.navigate(ProfileRoutes.editProfile(profileId)) { + popUpTo(ProfileRoutes.NewProfile) { + inclusive = true + } + } + }, + ) + } + + composable( + route = ProfileRoutes.EditProfile, + arguments = listOf( + navArgument("profileId") { + type = NavType.LongType + }, + ), + ) { backStackEntry -> + val profileId = backStackEntry.arguments?.getLong("profileId") ?: -1L + EditProfileRoute( + profileId = profileId, + onNavigateBack = { navController.navigateUp() }, + modifier = Modifier.fillMaxSize(), + ) + } + composable("connections/detail/{connectionId}") { backStackEntry -> val connectionId = backStackEntry.arguments?.getString("connectionId") if (connectionId != null) { @@ -143,122 +216,82 @@ fun SFANavHost( // Settings subscreens with slide animations composable( route = "settings/app", - enterTransition = { - slideIntoContainer( - AnimatedContentTransitionScope.SlideDirection.Left, - animationSpec = tween(300), - ) - }, - exitTransition = { - slideOutOfContainer( - AnimatedContentTransitionScope.SlideDirection.Left, - animationSpec = tween(300), - ) - }, - popEnterTransition = { - slideIntoContainer( - AnimatedContentTransitionScope.SlideDirection.Right, - animationSpec = tween(300), - ) - }, - popExitTransition = { - slideOutOfContainer( - AnimatedContentTransitionScope.SlideDirection.Right, - animationSpec = tween(300), - ) - }, + enterTransition = slideInFromRight, + exitTransition = slideOutToLeft, + popEnterTransition = slideInFromLeft, + popExitTransition = slideOutToRight, ) { AppSettingsScreen(navController = navController) } composable( route = "settings/core", - enterTransition = { - slideIntoContainer( - AnimatedContentTransitionScope.SlideDirection.Left, - animationSpec = tween(300), - ) - }, - exitTransition = { - slideOutOfContainer( - AnimatedContentTransitionScope.SlideDirection.Right, - animationSpec = tween(300), - ) - }, - popEnterTransition = { - slideIntoContainer( - AnimatedContentTransitionScope.SlideDirection.Left, - animationSpec = tween(300), - ) - }, - popExitTransition = { - slideOutOfContainer( - AnimatedContentTransitionScope.SlideDirection.Right, - animationSpec = tween(300), - ) - }, + enterTransition = slideInFromRight, + exitTransition = slideOutToRight, + popEnterTransition = slideInFromRight, + popExitTransition = slideOutToRight, ) { CoreSettingsScreen(navController = navController) } composable( route = "settings/service", - enterTransition = { - slideIntoContainer( - AnimatedContentTransitionScope.SlideDirection.Left, - animationSpec = tween(300), - ) - }, - exitTransition = { - slideOutOfContainer( - AnimatedContentTransitionScope.SlideDirection.Left, - animationSpec = tween(300), - ) - }, - popEnterTransition = { - slideIntoContainer( - AnimatedContentTransitionScope.SlideDirection.Right, - animationSpec = tween(300), - ) - }, - popExitTransition = { - slideOutOfContainer( - AnimatedContentTransitionScope.SlideDirection.Right, - animationSpec = tween(300), - ) - }, + enterTransition = slideInFromRight, + exitTransition = slideOutToLeft, + popEnterTransition = slideInFromLeft, + popExitTransition = slideOutToRight, ) { ServiceSettingsScreen(navController = navController) } composable( route = "settings/profile_override", - enterTransition = { - slideIntoContainer( - AnimatedContentTransitionScope.SlideDirection.Left, - animationSpec = tween(300), - ) - }, - exitTransition = { - slideOutOfContainer( - AnimatedContentTransitionScope.SlideDirection.Left, - animationSpec = tween(300), - ) - }, - popEnterTransition = { - slideIntoContainer( - AnimatedContentTransitionScope.SlideDirection.Right, - animationSpec = tween(300), - ) - }, - popExitTransition = { - slideOutOfContainer( - AnimatedContentTransitionScope.SlideDirection.Right, - animationSpec = tween(300), - ) - }, + enterTransition = slideInFromRight, + exitTransition = slideOutToLeft, + popEnterTransition = slideInFromLeft, + popExitTransition = slideOutToRight, ) { ProfileOverrideScreen(navController = navController) } + + composable( + route = "settings/profile_override/manage", + enterTransition = slideInFromRight, + exitTransition = slideOutToLeft, + popEnterTransition = slideInFromLeft, + popExitTransition = slideOutToRight, + ) { + PerAppProxyScreen(onBack = { navController.navigateUp() }) + } + + composable( + route = "settings/privilege", + enterTransition = slideInFromRight, + exitTransition = slideOutToLeft, + popEnterTransition = slideInFromLeft, + popExitTransition = slideOutToRight, + ) { + PrivilegeSettingsScreen(navController = navController, serviceStatus = serviceStatus) + } + + composable( + route = "settings/privilege/manage", + enterTransition = slideInFromRight, + exitTransition = slideOutToLeft, + popEnterTransition = slideInFromLeft, + popExitTransition = slideOutToRight, + ) { + PrivilegeSettingsManageScreen(onBack = { navController.navigateUp() }) + } + + composable( + route = "settings/privilege/logs", + enterTransition = slideInFromRight, + exitTransition = slideOutToLeft, + popEnterTransition = slideInFromLeft, + popExitTransition = slideOutToRight, + ) { + HookLogScreen(onBack = { navController.navigateUp() }) + } } } diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/screen/configuration/NewProfileScreen.kt b/app/src/main/java/io/nekohasekai/sfa/compose/screen/configuration/NewProfileScreen.kt index 0b67243..a32b239 100644 --- a/app/src/main/java/io/nekohasekai/sfa/compose/screen/configuration/NewProfileScreen.kt +++ b/app/src/main/java/io/nekohasekai/sfa/compose/screen/configuration/NewProfileScreen.kt @@ -15,6 +15,7 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.ui.platform.LocalDensity import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.navigationBars @@ -32,7 +33,6 @@ import androidx.compose.material.icons.filled.CloudDownload import androidx.compose.material.icons.filled.CreateNewFolder import androidx.compose.material.icons.filled.FileUpload import androidx.compose.material.icons.filled.Save -import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.Card @@ -46,11 +46,9 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedCard import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.Scaffold import androidx.compose.material3.Surface import androidx.compose.material3.Switch import androidx.compose.material3.Text -import androidx.compose.material3.TextButton import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable @@ -68,6 +66,8 @@ import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel import io.nekohasekai.sfa.R +import io.nekohasekai.sfa.compose.base.SelectableMessageDialog +import io.nekohasekai.sfa.compose.topbar.OverrideTopBar @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -114,7 +114,6 @@ fun NewProfileScreen( if (uiState.isSuccess) { uiState.createdProfile?.let { profile -> onProfileCreated(profile.id) - onNavigateBack() } } } @@ -128,89 +127,48 @@ fun NewProfileScreen( // Error dialog if (showErrorDialog) { - AlertDialog( - onDismissRequest = { + SelectableMessageDialog( + title = stringResource(R.string.error_title), + message = uiState.errorMessage ?: "", + onDismiss = { showErrorDialog = false viewModel.clearError() }, - title = { Text(stringResource(R.string.error_title)) }, - text = { Text(uiState.errorMessage ?: "") }, - confirmButton = { - TextButton( - onClick = { - showErrorDialog = false - viewModel.clearError() - }, - ) { - Text(stringResource(R.string.ok)) - } - }, ) } - Scaffold( - topBar = { - TopAppBar( - title = { Text(stringResource(R.string.title_new_profile)) }, - navigationIcon = { - IconButton(onClick = onNavigateBack) { - Icon( - Icons.AutoMirrored.Filled.ArrowBack, - 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)) - } - } + OverrideTopBar { + TopAppBar( + title = { Text(stringResource(R.string.title_new_profile)) }, + navigationIcon = { + IconButton(onClick = onNavigateBack) { + Icon( + Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = stringResource(R.string.content_description_back), + ) } - } - }, - ) { paddingValues -> + }, + colors = + 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( modifier = Modifier .fillMaxSize() - .padding(paddingValues) .verticalScroll(rememberScrollState()) - .padding(16.dp), + .padding(16.dp) + .padding(bottom = bottomBarPadding), verticalArrangement = Arrangement.spacedBy(16.dp), ) { // Profile Name @@ -589,5 +547,44 @@ fun NewProfileScreen( } } } + + Surface( + modifier = + Modifier + .fillMaxWidth() + .align(Alignment.BottomCenter), + color = MaterialTheme.colorScheme.surface, + tonalElevation = 3.dp, + ) { + Box( + modifier = + Modifier + .fillMaxWidth() + .windowInsetsPadding(WindowInsets.navigationBars) + .padding(16.dp), + ) { + Button( + onClick = { viewModel.validateAndCreateProfile() }, + modifier = Modifier.fillMaxWidth(), + enabled = !uiState.isSaving, + ) { + if (uiState.isSaving) { + CircularProgressIndicator( + modifier = Modifier.size(18.dp), + strokeWidth = 2.dp, + color = MaterialTheme.colorScheme.onPrimary, + ) + } else { + Icon( + Icons.Default.Save, + contentDescription = null, + modifier = Modifier.size(18.dp), + ) + Spacer(modifier = Modifier.width(8.dp)) + Text(stringResource(R.string.profile_create)) + } + } + } + } } } diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/screen/connections/ConnectionsScreen.kt b/app/src/main/java/io/nekohasekai/sfa/compose/screen/connections/ConnectionsScreen.kt index 85b84c3..6593864 100644 --- a/app/src/main/java/io/nekohasekai/sfa/compose/screen/connections/ConnectionsScreen.kt +++ b/app/src/main/java/io/nekohasekai/sfa/compose/screen/connections/ConnectionsScreen.kt @@ -24,6 +24,7 @@ import androidx.compose.ui.input.nestedscroll.NestedScrollSource import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.unit.Velocity import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.filled.Check import androidx.compose.material.icons.filled.Clear import androidx.compose.material.icons.filled.Close @@ -33,12 +34,14 @@ import androidx.compose.material.icons.filled.SwapVert import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.FilterChip import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState @@ -54,16 +57,19 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel import io.nekohasekai.sfa.R +import io.nekohasekai.sfa.compose.topbar.OverrideTopBar import io.nekohasekai.sfa.compose.model.ConnectionSort import io.nekohasekai.sfa.compose.model.ConnectionStateFilter import io.nekohasekai.sfa.constant.Status import io.nekohasekai.sfa.compose.model.Connection +@OptIn(ExperimentalMaterial3Api::class) @Composable fun ConnectionsPage( serviceStatus: Status, viewModel: ConnectionsViewModel = viewModel(), showTitle: Boolean = true, + showTopBar: Boolean = false, onConnectionClick: (String) -> Unit = {}, modifier: Modifier = Modifier, ) { @@ -72,6 +78,14 @@ fun ConnectionsPage( var showSortMenu by remember { mutableStateOf(false) } var showConnectionsMenu by remember { mutableStateOf(false) } + if (showTopBar) { + OverrideTopBar { + TopAppBar( + title = { Text(stringResource(R.string.title_connections)) }, + ) + } + } + Column( modifier = modifier.fillMaxSize(), ) { @@ -253,6 +267,7 @@ fun ConnectionsPage( } } +@OptIn(ExperimentalMaterial3Api::class) @Composable fun ConnectionDetailsRoute( connectionId: String, @@ -266,6 +281,30 @@ fun ConnectionDetailsRoute( uiState.allConnections.find { it.id == connectionId } ?: uiState.connections.find { it.id == connectionId } + OverrideTopBar { + TopAppBar( + title = { Text(stringResource(R.string.connection_details)) }, + navigationIcon = { + IconButton(onClick = onBack) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = stringResource(R.string.content_description_back), + ) + } + }, + actions = { + if (connection?.isActive == true) { + IconButton(onClick = { viewModel.closeConnection(connectionId) }) { + Icon( + imageVector = Icons.Default.Close, + contentDescription = stringResource(R.string.connection_close), + ) + } + } + }, + ) + } + LaunchedEffect(serviceStatus) { viewModel.updateServiceStatus(serviceStatus) } diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/screen/dashboard/DashboardCardRenderer.kt b/app/src/main/java/io/nekohasekai/sfa/compose/screen/dashboard/DashboardCardRenderer.kt index 19cd64e..5c57f20 100644 --- a/app/src/main/java/io/nekohasekai/sfa/compose/screen/dashboard/DashboardCardRenderer.kt +++ b/app/src/main/java/io/nekohasekai/sfa/compose/screen/dashboard/DashboardCardRenderer.kt @@ -2,6 +2,7 @@ package io.nekohasekai.sfa.compose.screen.dashboard import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier +import io.nekohasekai.sfa.compose.navigation.NewProfileArgs import io.nekohasekai.sfa.constant.Status import io.nekohasekai.sfa.database.Profile import io.nekohasekai.sfa.utils.CommandClient @@ -33,6 +34,7 @@ fun DashboardCardRenderer( onHideAddProfileSheet: () -> Unit = {}, onShowProfilePickerSheet: () -> Unit = {}, onHideProfilePickerSheet: () -> Unit = {}, + onOpenNewProfile: (NewProfileArgs) -> Unit = {}, commandClient: CommandClient? = null, modifier: Modifier = Modifier, ) { @@ -121,9 +123,7 @@ fun DashboardCardRenderer( onHideAddProfileSheet = onHideAddProfileSheet, onShowProfilePickerSheet = onShowProfilePickerSheet, onHideProfilePickerSheet = onHideProfilePickerSheet, - onImportFromFile = { /* Handled in ProfilesCard */ }, - onScanQrCode = { /* Handled in ProfilesCard */ }, - onCreateManually = { /* Handled in ProfilesCard */ }, + onOpenNewProfile = onOpenNewProfile, ) } } diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/screen/dashboard/DashboardScreen.kt b/app/src/main/java/io/nekohasekai/sfa/compose/screen/dashboard/DashboardScreen.kt index 9b50c4f..b61066c 100644 --- a/app/src/main/java/io/nekohasekai/sfa/compose/screen/dashboard/DashboardScreen.kt +++ b/app/src/main/java/io/nekohasekai/sfa/compose/screen/dashboard/DashboardScreen.kt @@ -9,10 +9,15 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.MoreVert import androidx.compose.material3.AlertDialog import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton import androidx.compose.material3.Text import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -27,6 +32,8 @@ import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel import io.nekohasekai.sfa.R import io.nekohasekai.sfa.compose.base.UiEvent +import io.nekohasekai.sfa.compose.navigation.NewProfileArgs +import io.nekohasekai.sfa.compose.topbar.OverrideTopBar import io.nekohasekai.sfa.constant.Status import kotlinx.coroutines.launch @@ -41,10 +48,25 @@ fun DashboardScreen( serviceStatus: Status = Status.Stopped, showStartFab: Boolean = false, showStatusBar: Boolean = false, + onOpenNewProfile: (NewProfileArgs) -> Unit = {}, viewModel: DashboardViewModel = viewModel(), ) { val uiState by viewModel.uiState.collectAsState() + OverrideTopBar { + TopAppBar( + title = { Text(stringResource(R.string.title_dashboard)) }, + actions = { + IconButton(onClick = { viewModel.toggleCardSettingsDialog() }) { + Icon( + imageVector = Icons.Default.MoreVert, + contentDescription = stringResource(R.string.title_others), + ) + } + }, + ) + } + // Update service status in ViewModel LaunchedEffect(serviceStatus) { viewModel.updateServiceStatus(serviceStatus) @@ -174,6 +196,7 @@ fun DashboardScreen( onHideAddProfileSheet = viewModel::hideAddProfileSheet, onShowProfilePickerSheet = viewModel::showProfilePickerSheet, onHideProfilePickerSheet = viewModel::hideProfilePickerSheet, + onOpenNewProfile = onOpenNewProfile, commandClient = viewModel.commandClient, modifier = Modifier @@ -213,6 +236,7 @@ fun DashboardScreen( onHideAddProfileSheet = viewModel::hideAddProfileSheet, onShowProfilePickerSheet = viewModel::showProfilePickerSheet, onHideProfilePickerSheet = viewModel::hideProfilePickerSheet, + onOpenNewProfile = onOpenNewProfile, commandClient = viewModel.commandClient, ) } diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/screen/dashboard/DashboardSettingsBottomSheet.kt b/app/src/main/java/io/nekohasekai/sfa/compose/screen/dashboard/DashboardSettingsBottomSheet.kt index 13b6799..ff8b937 100644 --- a/app/src/main/java/io/nekohasekai/sfa/compose/screen/dashboard/DashboardSettingsBottomSheet.kt +++ b/app/src/main/java/io/nekohasekai/sfa/compose/screen/dashboard/DashboardSettingsBottomSheet.kt @@ -64,7 +64,6 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.zIndex -import io.nekohasekai.sfa.BuildConfig import io.nekohasekai.sfa.R @OptIn(ExperimentalMaterial3Api::class) @@ -78,17 +77,13 @@ fun DashboardSettingsBottomSheet( onResetOrder: () -> Unit, onDismiss: () -> Unit, ) { - val filteredCardOrder = - if (BuildConfig.DEBUG) cardOrder else cardOrder.filter { it != CardGroup.Debug } - val filteredVisibleCards = - if (BuildConfig.DEBUG) visibleCards else visibleCards.filter { it != CardGroup.Debug }.toSet() - var reorderedList by remember(filteredCardOrder) { mutableStateOf(filteredCardOrder) } - var currentVisibleCards by remember(filteredVisibleCards) { mutableStateOf(filteredVisibleCards) } + var reorderedList by remember(cardOrder) { mutableStateOf(cardOrder) } + var currentVisibleCards by remember(visibleCards) { mutableStateOf(visibleCards) } // Update local state when props change (e.g., after reset) - LaunchedEffect(filteredCardOrder, filteredVisibleCards) { - reorderedList = filteredCardOrder - currentVisibleCards = filteredVisibleCards + LaunchedEffect(cardOrder, visibleCards) { + reorderedList = cardOrder + currentVisibleCards = visibleCards } val hapticFeedback = LocalHapticFeedback.current @@ -166,7 +161,7 @@ fun DashboardSettingsBottomSheet( listOfNotNull( CardGroup.UploadTraffic, CardGroup.DownloadTraffic, - if (BuildConfig.DEBUG) CardGroup.Debug else null, + CardGroup.Debug, CardGroup.Connections, CardGroup.SystemProxy, CardGroup.ClashMode, @@ -177,7 +172,7 @@ fun DashboardSettingsBottomSheet( CardGroup.ClashMode, CardGroup.UploadTraffic, CardGroup.DownloadTraffic, - if (BuildConfig.DEBUG) CardGroup.Debug else null, + CardGroup.Debug, CardGroup.Connections, CardGroup.SystemProxy, CardGroup.Profiles, diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/screen/dashboard/DashboardViewModel.kt b/app/src/main/java/io/nekohasekai/sfa/compose/screen/dashboard/DashboardViewModel.kt index beaee52..cbb8f24 100644 --- a/app/src/main/java/io/nekohasekai/sfa/compose/screen/dashboard/DashboardViewModel.kt +++ b/app/src/main/java/io/nekohasekai/sfa/compose/screen/dashboard/DashboardViewModel.kt @@ -1,9 +1,11 @@ package io.nekohasekai.sfa.compose.screen.dashboard import androidx.lifecycle.viewModelScope +import io.nekohasekai.libbox.Connections import io.nekohasekai.libbox.Libbox import io.nekohasekai.libbox.OutboundGroup import io.nekohasekai.libbox.StatusMessage +import io.nekohasekai.sfa.ktx.toList import io.nekohasekai.sfa.bg.BoxService import io.nekohasekai.sfa.compose.base.BaseViewModel import io.nekohasekai.sfa.compose.base.UiEvent @@ -50,6 +52,7 @@ data class DashboardUiState( val isLoading: Boolean = false, val hasGroups: Boolean = false, val groupsCount: Int = 0, + val connectionsCount: Int = 0, val serviceStartTime: Long? = null, val deprecatedNotes: List = emptyList(), val showDeprecatedDialog: Boolean = false, @@ -630,6 +633,13 @@ class DashboardViewModel : BaseViewModel(), CommandCl } } + override fun updateConnections(connections: Connections) { + viewModelScope.launch(Dispatchers.Main) { + val count = connections.iterator().toList().count { it.outboundType != "dns" } + updateState { copy(connectionsCount = count) } + } + } + fun toggleCardSettingsDialog() { updateState { copy(showCardSettingsDialog = !showCardSettingsDialog) diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/screen/dashboard/GroupsCard.kt b/app/src/main/java/io/nekohasekai/sfa/compose/screen/dashboard/GroupsCard.kt index 4f7e8c5..015e85d 100644 --- a/app/src/main/java/io/nekohasekai/sfa/compose/screen/dashboard/GroupsCard.kt +++ b/app/src/main/java/io/nekohasekai/sfa/compose/screen/dashboard/GroupsCard.kt @@ -26,6 +26,8 @@ import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.UnfoldLess +import androidx.compose.material.icons.filled.UnfoldMore import androidx.compose.material.icons.filled.ExpandMore import androidx.compose.material.icons.filled.Speed import androidx.compose.material3.Card @@ -40,6 +42,7 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Surface import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState @@ -63,17 +66,20 @@ import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.viewmodel.compose.viewModel import io.nekohasekai.libbox.Libbox import io.nekohasekai.sfa.R +import io.nekohasekai.sfa.compose.topbar.OverrideTopBar import io.nekohasekai.sfa.compose.screen.dashboard.groups.GroupsViewModel import io.nekohasekai.sfa.constant.Status import io.nekohasekai.sfa.compose.model.Group import io.nekohasekai.sfa.compose.model.GroupItem import io.nekohasekai.sfa.utils.CommandClient +@OptIn(ExperimentalMaterial3Api::class) @Composable fun GroupsCard( serviceStatus: Status, commandClient: CommandClient? = null, viewModel: GroupsViewModel? = null, + showTopBar: Boolean = false, modifier: Modifier = Modifier, ) { val actualViewModel: GroupsViewModel = viewModel ?: viewModel( @@ -88,6 +94,35 @@ fun GroupsCard( val snackbarHostState = remember { SnackbarHostState() } val uiState by actualViewModel.uiState.collectAsState() + if (showTopBar) { + val allCollapsed = uiState.expandedGroups.isEmpty() + OverrideTopBar { + TopAppBar( + title = { Text(stringResource(R.string.title_groups)) }, + actions = { + if (uiState.groups.isNotEmpty()) { + IconButton(onClick = { actualViewModel.toggleAllGroups() }) { + Icon( + imageVector = + if (allCollapsed) { + Icons.Default.UnfoldMore + } else { + Icons.Default.UnfoldLess + }, + contentDescription = + if (allCollapsed) { + stringResource(R.string.expand_all) + } else { + stringResource(R.string.collapse_all) + }, + ) + } + } + }, + ) + } + } + // Stable callbacks to prevent recomposition - use remember with viewModel as key val onToggleExpanded = remember(actualViewModel) { diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/screen/dashboard/ProfilesCard.kt b/app/src/main/java/io/nekohasekai/sfa/compose/screen/dashboard/ProfilesCard.kt index 780fe70..3cba0ef 100644 --- a/app/src/main/java/io/nekohasekai/sfa/compose/screen/dashboard/ProfilesCard.kt +++ b/app/src/main/java/io/nekohasekai/sfa/compose/screen/dashboard/ProfilesCard.kt @@ -1,6 +1,5 @@ package io.nekohasekai.sfa.compose.screen.dashboard -import android.content.Intent import android.net.Uri import android.widget.Toast import androidx.activity.compose.rememberLauncherForActivityResult @@ -64,10 +63,10 @@ import androidx.compose.ui.unit.dp import io.nekohasekai.libbox.Libbox import io.nekohasekai.libbox.ProfileContent import io.nekohasekai.sfa.R -import io.nekohasekai.sfa.compose.NewProfileActivity import io.nekohasekai.sfa.compose.component.qr.QRCodeDialog import io.nekohasekai.sfa.compose.component.qr.QRScanSheet import io.nekohasekai.sfa.compose.component.qr.QRSDialog +import io.nekohasekai.sfa.compose.navigation.NewProfileArgs import io.nekohasekai.sfa.compose.screen.qrscan.QRScanResult import io.nekohasekai.sfa.compose.screen.configuration.ProfileImportHandler import io.nekohasekai.sfa.compose.util.QRCodeGenerator @@ -103,9 +102,7 @@ fun ProfilesCard( onHideAddProfileSheet: () -> Unit, onShowProfilePickerSheet: () -> Unit, onHideProfilePickerSheet: () -> Unit, - onImportFromFile: () -> Unit, - onScanQrCode: () -> Unit, - onCreateManually: () -> Unit, + onOpenNewProfile: (NewProfileArgs) -> Unit, ) { val context = LocalContext.current val coroutineScope = rememberCoroutineScope() @@ -126,28 +123,6 @@ fun ProfilesCard( var showQRScanSheet by remember { mutableStateOf(false) } - val newProfileLauncher = - rememberLauncherForActivityResult( - ActivityResultContracts.StartActivityForResult(), - ) { result -> - if (result.resultCode == android.app.Activity.RESULT_OK) { - val profileId = result.data?.getLongExtra(NewProfileActivity.EXTRA_PROFILE_ID, -1L) - if (profileId != null && profileId != -1L) { - coroutineScope.launch { - val profile = - withContext(Dispatchers.IO) { - io.nekohasekai.sfa.database.ProfileManager.get(profileId) - } - profile?.let { - withContext(Dispatchers.Main) { - onProfileEdit(it) - } - } - } - } - } - } - val importFromFileLauncher = rememberLauncherForActivityResult( ActivityResultContracts.GetContent(), @@ -238,9 +213,6 @@ fun ProfilesCard( } } - LaunchedEffect(onImportFromFile, onScanQrCode) { - } - val selectedProfile = profiles.find { it.id == selectedProfileId } Card( @@ -458,8 +430,7 @@ fun ProfilesCard( ListItem( modifier = Modifier.clickable { onHideAddProfileSheet() - val intent = Intent(context, NewProfileActivity::class.java) - newProfileLauncher.launch(intent) + onOpenNewProfile(NewProfileArgs()) }, leadingContent = { Icon( @@ -608,12 +579,12 @@ fun ProfilesCard( when (val parseResult = importHandler.parseQRCode(result.uri.toString())) { is ProfileImportHandler.QRCodeParseResult.RemoteProfile -> { withContext(Dispatchers.Main) { - val newProfileIntent = - Intent(context, NewProfileActivity::class.java).apply { - putExtra(NewProfileActivity.EXTRA_IMPORT_NAME, parseResult.name) - putExtra(NewProfileActivity.EXTRA_IMPORT_URL, parseResult.url) - } - newProfileLauncher.launch(newProfileIntent) + onOpenNewProfile( + NewProfileArgs( + importName = parseResult.name, + importUrl = parseResult.url, + ), + ) } } is ProfileImportHandler.QRCodeParseResult.LocalProfile -> { diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/screen/log/BaseLogViewModel.kt b/app/src/main/java/io/nekohasekai/sfa/compose/screen/log/BaseLogViewModel.kt new file mode 100644 index 0000000..cb15553 --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sfa/compose/screen/log/BaseLogViewModel.kt @@ -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 = _uiState.asStateFlow() + + protected val _autoScrollEnabled = MutableStateFlow(true) + override val isAtBottom: StateFlow = _autoScrollEnabled.asStateFlow() + + protected val _scrollToBottomTrigger = MutableStateFlow(0) + override val scrollToBottomTrigger: StateFlow = _scrollToBottomTrigger.asStateFlow() + + protected val _searchQueryInternal = MutableStateFlow("") + protected val logIdGenerator = AtomicLong(0) + protected val allLogs = LinkedList() + + 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() + } else { + _uiState.value.selectedLogIndices + } + + _uiState.update { it.copy(logs = logsToDisplay, selectedLogIndices = selectionCleared) } + } +} diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/screen/log/HookLogScreen.kt b/app/src/main/java/io/nekohasekai/sfa/compose/screen/log/HookLogScreen.kt new file mode 100644 index 0000000..c5ed95e --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sfa/compose/screen/log/HookLogScreen.kt @@ -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, + ) +} diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/screen/log/HookLogViewModel.kt b/app/src/main/java/io/nekohasekai/sfa/compose/screen/log/HookLogViewModel.kt new file mode 100644 index 0000000..777bf50 --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sfa/compose/screen/log/HookLogViewModel.kt @@ -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() { + } +} diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/screen/log/LogModels.kt b/app/src/main/java/io/nekohasekai/sfa/compose/screen/log/LogModels.kt new file mode 100644 index 0000000..2fe584d --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sfa/compose/screen/log/LogModels.kt @@ -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 = 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 = emptySet(), + val errorTitle: String? = null, + val errorMessage: String? = null, +) diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/screen/log/LogScreen.kt b/app/src/main/java/io/nekohasekai/sfa/compose/screen/log/LogScreen.kt index 89f0d28..0f2e978 100644 --- a/app/src/main/java/io/nekohasekai/sfa/compose/screen/log/LogScreen.kt +++ b/app/src/main/java/io/nekohasekai/sfa/compose/screen/log/LogScreen.kt @@ -35,6 +35,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.filled.CheckBox import androidx.compose.material.icons.filled.CheckBoxOutlineBlank import androidx.compose.material.icons.filled.Close @@ -44,6 +45,9 @@ import androidx.compose.material.icons.filled.ExpandLess import androidx.compose.material.icons.filled.ExpandMore import androidx.compose.material.icons.filled.FilterList import androidx.compose.material.icons.filled.KeyboardArrowDown +import androidx.compose.material.icons.filled.MoreVert +import androidx.compose.material.icons.filled.Pause +import androidx.compose.material.icons.filled.PlayArrow import androidx.compose.material.icons.filled.RadioButtonChecked import androidx.compose.material.icons.filled.RadioButtonUnchecked import androidx.compose.material.icons.filled.Save @@ -63,6 +67,7 @@ import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState @@ -88,6 +93,7 @@ import androidx.core.content.FileProvider import androidx.lifecycle.viewmodel.compose.viewModel import io.nekohasekai.sfa.Application import io.nekohasekai.sfa.R +import io.nekohasekai.sfa.compose.topbar.OverrideTopBar import io.nekohasekai.sfa.constant.Status import java.io.File import java.text.SimpleDateFormat @@ -100,18 +106,97 @@ fun LogScreen( serviceStatus: Status = Status.Stopped, showStartFab: Boolean = false, showStatusBar: Boolean = false, - viewModel: LogViewModel = viewModel(), + title: String? = null, + viewModel: LogViewerViewModel? = null, + showPause: Boolean = true, + showClear: Boolean = true, + showStatusInfo: Boolean = true, + emptyMessage: String? = null, + saveFilePrefix: String = "logs", + onBack: (() -> Unit)? = null, ) { - val uiState by viewModel.uiState.collectAsState() + val resolvedViewModel = viewModel ?: viewModel() + val uiState by resolvedViewModel.uiState.collectAsState() val context = LocalContext.current val configuration = LocalConfiguration.current val isTablet = configuration.isLayoutSizeAtLeast(Configuration.SCREENLAYOUT_SIZE_LARGE) val listState = rememberLazyListState() val coroutineScope = rememberCoroutineScope() + val resolvedTitle = title ?: stringResource(R.string.title_log) + val emptyStateMessage = emptyMessage ?: stringResource(R.string.privilege_settings_hook_logs_empty) + + OverrideTopBar { + TopAppBar( + title = { Text(resolvedTitle) }, + navigationIcon = { + if (onBack != null) { + IconButton(onClick = onBack) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = stringResource(R.string.content_description_back), + ) + } + } + }, + actions = { + if (!uiState.isSelectionMode) { + if (showPause) { + IconButton(onClick = { resolvedViewModel.togglePause() }) { + Icon( + imageVector = + if (uiState.isPaused) { + Icons.Default.PlayArrow + } else { + Icons.Default.Pause + }, + contentDescription = + if (uiState.isPaused) { + stringResource(R.string.content_description_resume_logs) + } else { + stringResource(R.string.content_description_pause_logs) + }, + ) + } + } + + IconButton(onClick = { resolvedViewModel.toggleSearch() }) { + Icon( + imageVector = + if (uiState.isSearchActive) { + Icons.Default.ExpandLess + } else { + Icons.Default.Search + }, + contentDescription = + if (uiState.isSearchActive) { + stringResource(R.string.content_description_collapse_search) + } else { + stringResource(R.string.content_description_search_logs) + }, + tint = + if (uiState.isSearchActive) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSurface + }, + ) + } + + IconButton(onClick = { resolvedViewModel.toggleOptionsMenu() }) { + Icon( + imageVector = Icons.Default.MoreVert, + contentDescription = stringResource(R.string.more_options), + tint = MaterialTheme.colorScheme.onSurface, + ) + } + } + }, + ) + } // Handle back press in selection mode androidx.activity.compose.BackHandler(enabled = uiState.isSelectionMode) { - viewModel.clearSelection() + resolvedViewModel.clearSelection() } // Track if user is at the bottom of the list @@ -126,7 +211,7 @@ fun LogScreen( // Re-enable auto-scroll when user reaches bottom LaunchedEffect(isAtBottom) { if (isAtBottom) { - viewModel.setAutoScrollEnabled(true) + resolvedViewModel.setAutoScrollEnabled(true) } } @@ -154,7 +239,7 @@ fun LogScreen( } if (scrolledUp) { - viewModel.setAutoScrollEnabled(false) + resolvedViewModel.setAutoScrollEnabled(false) } dragStartIndex = null @@ -166,7 +251,7 @@ fun LogScreen( } // Handle scroll to bottom requests from ViewModel - val scrollToBottomTrigger by viewModel.scrollToBottomTrigger.collectAsState() + val scrollToBottomTrigger by resolvedViewModel.scrollToBottomTrigger.collectAsState() LaunchedEffect(scrollToBottomTrigger) { if (scrollToBottomTrigger > 0 && uiState.logs.isNotEmpty()) { listState.animateScrollToItem(uiState.logs.size - 1) @@ -175,7 +260,9 @@ fun LogScreen( // Update service status in ViewModel LaunchedEffect(serviceStatus) { - viewModel.updateServiceStatus(serviceStatus) + if (showStatusInfo) { + resolvedViewModel.updateServiceStatus(serviceStatus) + } } Box( @@ -203,7 +290,7 @@ fun LogScreen( Row( verticalAlignment = Alignment.CenterVertically, ) { - IconButton(onClick = { viewModel.clearSelection() }) { + IconButton(onClick = { resolvedViewModel.clearSelection() }) { Icon( imageVector = Icons.Default.Close, contentDescription = stringResource(R.string.content_description_exit_selection_mode), @@ -222,9 +309,9 @@ fun LogScreen( Row { IconButton( onClick = { - val selectedText = viewModel.getSelectedLogsText() + val selectedText = resolvedViewModel.getSelectedLogsText() if (selectedText.isNotEmpty()) { - val clipLabel = context.getString(R.string.title_log) + val clipLabel = resolvedTitle val clip = ClipData.newPlainText(clipLabel, selectedText) Application.clipboard.setPrimaryClip(clip) Toast.makeText( @@ -232,7 +319,7 @@ fun LogScreen( context.getString(R.string.copied_to_clipboard), Toast.LENGTH_SHORT, ).show() - viewModel.clearSelection() + resolvedViewModel.clearSelection() } }, enabled = uiState.selectedLogIndices.isNotEmpty(), @@ -271,7 +358,7 @@ fun LogScreen( style = MaterialTheme.typography.bodySmall, ) TextButton( - onClick = { viewModel.setLogLevel(LogLevel.Default) }, + onClick = { resolvedViewModel.setLogLevel(LogLevel.Default) }, contentPadding = PaddingValues(horizontal = 8.dp, vertical = 0.dp), modifier = Modifier.height(24.dp), ) { @@ -316,7 +403,7 @@ fun LogScreen( OutlinedTextField( value = uiState.searchQuery, - onValueChange = { viewModel.updateSearchQuery(it) }, + onValueChange = { resolvedViewModel.updateSearchQuery(it) }, modifier = Modifier .fillMaxWidth() @@ -331,7 +418,7 @@ fun LogScreen( }, trailingIcon = { if (uiState.searchQuery.isNotEmpty()) { - IconButton(onClick = { viewModel.updateSearchQuery("") }) { + IconButton(onClick = { resolvedViewModel.updateSearchQuery("") }) { Icon( imageVector = Icons.Default.Delete, contentDescription = stringResource(R.string.content_description_clear_search), @@ -351,8 +438,7 @@ fun LogScreen( } } - if (uiState.logs.isEmpty()) { - // Empty state + if (uiState.errorMessage != null) { Box( modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center, @@ -362,13 +448,37 @@ fun LogScreen( verticalArrangement = Arrangement.spacedBy(8.dp), ) { Text( - text = + text = uiState.errorTitle ?: "Error", + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.error, + ) + Text( + text = uiState.errorMessage ?: "", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } else if (uiState.logs.isEmpty()) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center, + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text( + text = if (showStatusInfo) { when (serviceStatus) { Status.Started -> stringResource(R.string.status_started) Status.Starting -> stringResource(R.string.status_starting) Status.Stopping -> stringResource(R.string.status_stopping) else -> stringResource(R.string.status_default) - }, + } + } else { + emptyStateMessage + }, style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -404,13 +514,13 @@ fun LogScreen( isSelectionMode = uiState.isSelectionMode, onLongClick = { if (!uiState.isSelectionMode) { - viewModel.toggleSelectionMode() - viewModel.toggleLogSelection(index) + resolvedViewModel.toggleSelectionMode() + resolvedViewModel.toggleLogSelection(index) } }, onClick = { if (uiState.isSelectionMode) { - viewModel.toggleLogSelection(index) + resolvedViewModel.toggleLogSelection(index) } }, ) @@ -437,7 +547,7 @@ fun LogScreen( uri?.let { try { context.contentResolver.openOutputStream(it)?.use { outputStream -> - val logsText = viewModel.getAllLogsText() + val logsText = resolvedViewModel.getAllLogsText() outputStream.write(logsText.toByteArray()) outputStream.flush() Toast.makeText( @@ -460,7 +570,7 @@ fun LogScreen( DropdownMenu( expanded = uiState.isOptionsMenuOpen, onDismissRequest = { - viewModel.toggleOptionsMenu() + resolvedViewModel.toggleOptionsMenu() expandedLogLevel = false expandedSave = false }, @@ -503,8 +613,8 @@ fun LogScreen( Text(text = level.label) }, onClick = { - viewModel.setLogLevel(level) - viewModel.toggleOptionsMenu() + resolvedViewModel.setLogLevel(level) + resolvedViewModel.toggleOptionsMenu() expandedLogLevel = false }, leadingIcon = { @@ -573,13 +683,10 @@ fun LogScreen( Text(text = stringResource(R.string.save_to_clipboard)) }, onClick = { - val logsText = viewModel.getAllLogsText() + val logsText = resolvedViewModel.getAllLogsText() if (logsText.isNotEmpty()) { val clip = - ClipData.newPlainText( - context.getString(R.string.title_log), - logsText, - ) + ClipData.newPlainText(resolvedTitle, logsText) Application.clipboard.setPrimaryClip(clip) Toast.makeText( context, @@ -593,7 +700,7 @@ fun LogScreen( Toast.LENGTH_SHORT, ).show() } - viewModel.toggleOptionsMenu() + resolvedViewModel.toggleOptionsMenu() expandedSave = false }, leadingIcon = { @@ -617,8 +724,8 @@ fun LogScreen( "yyyyMMdd_HHmmss", Locale.getDefault(), ).format(Date()) - saveFileLauncher.launch("logs_$timestamp.txt") - viewModel.toggleOptionsMenu() + saveFileLauncher.launch("${saveFilePrefix}_$timestamp.txt") + resolvedViewModel.toggleOptionsMenu() expandedSave = false }, leadingIcon = { @@ -637,7 +744,7 @@ fun LogScreen( Text(text = stringResource(R.string.menu_share)) }, onClick = { - val logsText = viewModel.getAllLogsText() + val logsText = resolvedViewModel.getAllLogsText() if (logsText.isNotEmpty()) { try { val logsDir = @@ -647,7 +754,7 @@ fun LogScreen( "yyyyMMdd_HHmmss", Locale.getDefault(), ).format(Date()) - val logFile = File(logsDir, "logs_$timestamp.txt") + val logFile = File(logsDir, "${saveFilePrefix}_$timestamp.txt") logFile.writeText(logsText) val uri = @@ -682,7 +789,7 @@ fun LogScreen( Toast.LENGTH_SHORT, ).show() } - viewModel.toggleOptionsMenu() + resolvedViewModel.toggleOptionsMenu() expandedSave = false }, leadingIcon = { @@ -698,27 +805,28 @@ fun LogScreen( HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp)) } - // Clear logs option - DropdownMenuItem( - text = { - Text( - text = stringResource(R.string.clear_logs), - style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.error, - ) - }, - onClick = { - viewModel.requestClearLogs() - viewModel.toggleOptionsMenu() - }, - leadingIcon = { - Icon( - imageVector = Icons.Default.Delete, - contentDescription = null, - tint = MaterialTheme.colorScheme.error, - ) - }, - ) + if (showClear) { + DropdownMenuItem( + text = { + Text( + text = stringResource(R.string.clear_logs), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.error, + ) + }, + onClick = { + resolvedViewModel.requestClearLogs() + resolvedViewModel.toggleOptionsMenu() + }, + leadingIcon = { + Icon( + imageVector = Icons.Default.Delete, + contentDescription = null, + tint = MaterialTheme.colorScheme.error, + ) + }, + ) + } } } @@ -746,7 +854,7 @@ fun LogScreen( exit = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) scaleOut() else fadeOut(), ) { FloatingActionButton( - onClick = { viewModel.scrollToBottom() }, + onClick = { resolvedViewModel.scrollToBottom() }, containerColor = MaterialTheme.colorScheme.primaryContainer, contentColor = MaterialTheme.colorScheme.onPrimaryContainer, ) { diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/screen/log/LogViewModel.kt b/app/src/main/java/io/nekohasekai/sfa/compose/screen/log/LogViewModel.kt index 656726a..dd5e76d 100644 --- a/app/src/main/java/io/nekohasekai/sfa/compose/screen/log/LogViewModel.kt +++ b/app/src/main/java/io/nekohasekai/sfa/compose/screen/log/LogViewModel.kt @@ -1,7 +1,5 @@ package io.nekohasekai.sfa.compose.screen.log -import androidx.compose.ui.text.AnnotatedString -import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import io.nekohasekai.libbox.Libbox import io.nekohasekai.libbox.LogEntry @@ -9,67 +7,16 @@ import io.nekohasekai.sfa.compose.util.AnsiColorUtils import io.nekohasekai.sfa.constant.Status import io.nekohasekai.sfa.utils.CommandClient import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.debounce -import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import java.util.LinkedList -import java.util.concurrent.atomic.AtomicLong -data class ProcessedLogEntry( - val id: Long, - val originalEntry: LogEntry, - val annotatedString: AnnotatedString, -) - -enum class LogLevel(val label: String, val priority: Int) { - Default("Default", 7), - - PANIC("Panic", 0), - FATAL("Fatal", 1), - ERROR("Error", 2), - WARNING("Warn", 3), - INFO("Info", 4), - DEBUG("Debug", 5), - TRACE("Trace", 6), -} - -data class LogUiState( - val logs: List = 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 = emptySet(), -) - -class LogViewModel : ViewModel(), CommandClient.Handler { +class LogViewModel : BaseLogViewModel(), CommandClient.Handler { companion object { private val maxLines = 3000 } - private val _uiState = MutableStateFlow(LogUiState()) - val uiState: StateFlow = _uiState.asStateFlow() - - private val _autoScrollEnabled = MutableStateFlow(true) - val isAtBottom: StateFlow = _autoScrollEnabled.asStateFlow() - - private val _scrollToBottomTrigger = MutableStateFlow(0) - val scrollToBottomTrigger: StateFlow = _scrollToBottomTrigger.asStateFlow() - - private val _searchQueryInternal = MutableStateFlow("") - private val logIdGenerator = AtomicLong(0) - - private val allLogs = LinkedList() private val bufferedLogs = LinkedList() private val commandClient = CommandClient( @@ -78,26 +25,16 @@ class LogViewModel : ViewModel(), CommandClient.Handler { handler = this, ) - init { - viewModelScope.launch { - _searchQueryInternal - .debounce(300) - .distinctUntilChanged() - .collect { _ -> - updateDisplayedLogs() - } - } - } - private fun processLogEntry(entry: LogEntry): ProcessedLogEntry { + val level = LogLevel.entries.find { it.priority == entry.level } ?: LogLevel.Default return ProcessedLogEntry( id = logIdGenerator.incrementAndGet(), - originalEntry = entry, + entry = LogEntryData(level = level, message = entry.message), annotatedString = AnsiColorUtils.ansiToAnnotatedString(entry.message), ) } - fun updateServiceStatus(status: Status) { + override fun updateServiceStatus(status: Status) { _uiState.update { it.copy(serviceStatus = status) } when (status) { @@ -135,7 +72,7 @@ class LogViewModel : ViewModel(), CommandClient.Handler { updateDisplayedLogs() } - fun requestClearLogs() { + override fun requestClearLogs() { viewModelScope.launch { withContext(Dispatchers.IO) { runCatching { @@ -168,10 +105,9 @@ class LogViewModel : ViewModel(), CommandClient.Handler { } } - fun togglePause() { + override fun togglePause() { val currentState = _uiState.value if (currentState.isPaused && bufferedLogs.isNotEmpty()) { - // When resuming, add buffered logs val totalSize = allLogs.size + bufferedLogs.size val removeCount = (totalSize - maxLines).coerceAtLeast(0) @@ -189,121 +125,6 @@ class LogViewModel : ViewModel(), CommandClient.Handler { updateDisplayedLogs() } - fun toggleSearch() { - _uiState.update { - it.copy( - isSearchActive = !it.isSearchActive, - searchQuery = if (!it.isSearchActive) it.searchQuery else "", - ) - } - updateDisplayedLogs() - } - - fun updateSearchQuery(query: String) { - _uiState.update { it.copy(searchQuery = query) } - _searchQueryInternal.value = query - } - - fun setLogLevel(level: LogLevel) { - _uiState.update { it.copy(filterLogLevel = level) } - updateDisplayedLogs() - } - - fun toggleOptionsMenu() { - _uiState.update { it.copy(isOptionsMenuOpen = !it.isOptionsMenuOpen) } - } - - fun setAutoScrollEnabled(enabled: Boolean) { - _autoScrollEnabled.value = enabled - } - - fun scrollToBottom() { - _autoScrollEnabled.value = true - _scrollToBottomTrigger.value++ - } - - fun toggleSelectionMode() { - _uiState.update { - if (it.isSelectionMode) { - // Exit selection mode, clear selections, and resume if it was paused by selection mode - it.copy(isSelectionMode = false, selectedLogIndices = emptySet(), isPaused = false) - } else { - // Enter selection mode and pause log updates - it.copy(isSelectionMode = true, isPaused = true) - } - } - } - - fun toggleLogSelection(index: Int) { - _uiState.update { state -> - val newSelection = - if (state.selectedLogIndices.contains(index)) { - state.selectedLogIndices - index - } else { - state.selectedLogIndices + index - } - - // Exit selection mode and unpause if no items are selected - if (newSelection.isEmpty()) { - state.copy( - isSelectionMode = false, - selectedLogIndices = emptySet(), - isPaused = false, - ) - } else { - state.copy(selectedLogIndices = newSelection) - } - } - } - - fun clearSelection() { - _uiState.update { - it.copy(isSelectionMode = false, selectedLogIndices = emptySet(), isPaused = false) - } - } - - fun getSelectedLogsText(): String { - val state = _uiState.value - return state.selectedLogIndices - .sorted() - .mapNotNull { index -> - state.logs.getOrNull(index)?.originalEntry?.message - } - .joinToString("\n") - } - - fun getAllLogsText(): String { - return _uiState.value.logs.joinToString("\n") { it.originalEntry.message } - } - - private fun updateDisplayedLogs() { - val currentState = _uiState.value - val levelPriority = - if (currentState.filterLogLevel != LogLevel.Default) { - currentState.filterLogLevel.priority - } else { - currentState.defaultLogLevel.priority - } - val searchQuery = currentState.searchQuery - - val logsToDisplay = - allLogs.asSequence() - .filter { log -> log.originalEntry.level <= levelPriority } - .filter { log -> - searchQuery.isEmpty() || log.originalEntry.message.contains(searchQuery, ignoreCase = true) - } - .toList() - - val selectionCleared = - if (_uiState.value.isSelectionMode && _uiState.value.logs != logsToDisplay) { - emptySet() - } else { - _uiState.value.selectedLogIndices - } - - _uiState.update { it.copy(logs = logsToDisplay, selectedLogIndices = selectionCleared) } - } - override fun onCleared() { super.onCleared() commandClient.disconnect() diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/screen/log/LogViewerViewModel.kt b/app/src/main/java/io/nekohasekai/sfa/compose/screen/log/LogViewerViewModel.kt new file mode 100644 index 0000000..e6be04e --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sfa/compose/screen/log/LogViewerViewModel.kt @@ -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 + val scrollToBottomTrigger: StateFlow + val isAtBottom: StateFlow + + 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() +} diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/screen/privilegesettings/PrivilegeSettingsManageScreen.kt b/app/src/main/java/io/nekohasekai/sfa/compose/screen/privilegesettings/PrivilegeSettingsManageScreen.kt new file mode 100644 index 0000000..3aa6598 --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sfa/compose/screen/privilegesettings/PrivilegeSettingsManageScreen.kt @@ -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, + val selectedUids: Set, +) + +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>(emptyList()) } + var displayPackages by remember { mutableStateOf>(emptyList()) } + var currentPackages by remember { mutableStateOf>(emptyList()) } + var selectedUids by remember { mutableStateOf>(emptySet()) } + var isLoading by remember { mutableStateOf(true) } + + var isSearchActive by remember { mutableStateOf(false) } + var searchQuery by remember { mutableStateOf("") } + var riskyWarningMessage by remember { mutableStateOf(null) } + var syncErrorMessage by remember { mutableStateOf(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): Set { + 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) { + 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) { + 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(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, 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), + ) + }, + ) + } + } +} diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/screen/profile/EditProfileContentScreen.kt b/app/src/main/java/io/nekohasekai/sfa/compose/screen/profile/EditProfileContentScreen.kt index c7d32a3..dbada86 100644 --- a/app/src/main/java/io/nekohasekai/sfa/compose/screen/profile/EditProfileContentScreen.kt +++ b/app/src/main/java/io/nekohasekai/sfa/compose/screen/profile/EditProfileContentScreen.kt @@ -44,7 +44,6 @@ import androidx.compose.material3.IconButton import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.Scaffold import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TextButton @@ -62,6 +61,7 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clipToBounds import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.input.key.Key @@ -82,6 +82,7 @@ import androidx.compose.ui.viewinterop.AndroidView import androidx.lifecycle.viewmodel.compose.viewModel import com.blacksquircle.ui.language.json.JsonLanguage import io.nekohasekai.sfa.R +import io.nekohasekai.sfa.compose.topbar.OverrideTopBar import kotlinx.coroutines.delay import kotlinx.coroutines.launch @@ -137,7 +138,91 @@ fun EditProfileContentScreen( showUnsavedChangesDialog = true } - Scaffold( + OverrideTopBar { + TopAppBar( + title = { + Column { + Text( + if (uiState.isReadOnly) { + stringResource(R.string.view_configuration) + } else { + stringResource(R.string.title_edit_configuration) + }, + ) + if (uiState.profileName.isNotEmpty()) { + Text( + text = uiState.profileName, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + }, + navigationIcon = { + IconButton( + onClick = { + if (uiState.hasUnsavedChanges && !uiState.isReadOnly) { + showUnsavedChangesDialog = true + } else { + onNavigateBack() + } + }, + ) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = stringResource(R.string.content_description_back), + ) + } + }, + actions = { + // Search/Collapse button (Ctrl/Cmd+F) + IconButton( + onClick = { viewModel.toggleSearchBar() }, + ) { + Icon( + imageVector = if (uiState.showSearchBar) Icons.Default.ExpandLess else Icons.Default.Search, + contentDescription = + if (uiState.showSearchBar) { + stringResource(R.string.content_description_collapse_search) + } else { + stringResource(R.string.search) + }, + tint = + if (uiState.showSearchBar) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSurface + }, + ) + } + + // Save button (only show if not read-only) (Ctrl/Cmd+S) + if (!uiState.isReadOnly) { + IconButton( + onClick = { viewModel.saveConfiguration() }, + enabled = uiState.hasUnsavedChanges && !uiState.isLoading, + ) { + Icon( + imageVector = Icons.Default.Save, + contentDescription = stringResource(R.string.save), + tint = + if (uiState.hasUnsavedChanges) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f) + }, + ) + } + } + }, + colors = + TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.surface, + ), + ) + } + + Column( modifier = modifier .fillMaxSize() @@ -221,106 +306,17 @@ fun EditProfileContentScreen( false } }, - topBar = { - TopAppBar( - title = { - Column { - Text( - if (uiState.isReadOnly) { - stringResource(R.string.view_configuration) - } else { - stringResource(R.string.title_edit_configuration) - }, - ) - if (uiState.profileName.isNotEmpty()) { - Text( - text = uiState.profileName, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - } - }, - navigationIcon = { - IconButton( - onClick = { - if (uiState.hasUnsavedChanges && !uiState.isReadOnly) { - showUnsavedChangesDialog = true - } else { - onNavigateBack() - } - }, - ) { - Icon( - imageVector = Icons.AutoMirrored.Filled.ArrowBack, - contentDescription = stringResource(R.string.content_description_back), - ) - } - }, - actions = { - // Search/Collapse button (Ctrl/Cmd+F) - IconButton( - onClick = { viewModel.toggleSearchBar() }, - ) { - Icon( - imageVector = if (uiState.showSearchBar) Icons.Default.ExpandLess else Icons.Default.Search, - contentDescription = - if (uiState.showSearchBar) { - stringResource(R.string.content_description_collapse_search) - } else { - stringResource(R.string.search) - }, - tint = - if (uiState.showSearchBar) { - MaterialTheme.colorScheme.primary - } else { - MaterialTheme.colorScheme.onSurface - }, - ) - } - - // Save button (only show if not read-only) (Ctrl/Cmd+S) - if (!uiState.isReadOnly) { - IconButton( - onClick = { viewModel.saveConfiguration() }, - enabled = uiState.hasUnsavedChanges && !uiState.isLoading, - ) { - Icon( - imageVector = Icons.Default.Save, - contentDescription = stringResource(R.string.save), - tint = - if (uiState.hasUnsavedChanges) { - MaterialTheme.colorScheme.primary - } else { - MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f) - }, - ) - } - } - }, - colors = - TopAppBarDefaults.topAppBarColors( - containerColor = MaterialTheme.colorScheme.surfaceContainer, - ), - ) - }, - ) { paddingValues -> - Column( - modifier = - Modifier - .fillMaxSize() - .padding(paddingValues), - ) { + ) { // Search bar (appears at top when activated) AnimatedVisibility( visible = uiState.showSearchBar, - enter = slideInVertically(initialOffsetY = { -it }) + fadeIn() + expandVertically(), - exit = slideOutVertically(targetOffsetY = { -it }) + fadeOut() + shrinkVertically(), + enter = expandVertically(expandFrom = Alignment.Top) + fadeIn(), + exit = shrinkVertically(shrinkTowards = Alignment.Top) + fadeOut(), ) { Surface( modifier = Modifier.fillMaxWidth(), color = MaterialTheme.colorScheme.surfaceContainer, - shadowElevation = 4.dp, + tonalElevation = 2.dp, ) { Row( modifier = @@ -435,7 +431,8 @@ fun EditProfileContentScreen( Box( modifier = Modifier - .fillMaxSize() + .fillMaxSize() + .clipToBounds() .weight(1f), ) { // Editor @@ -829,8 +826,6 @@ fun EditProfileContentScreen( } } } - } - // Unsaved changes dialog if (showUnsavedChangesDialog) { AlertDialog( diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/screen/profile/EditProfileRoute.kt b/app/src/main/java/io/nekohasekai/sfa/compose/screen/profile/EditProfileRoute.kt new file mode 100644 index 0000000..2245c69 --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sfa/compose/screen/profile/EditProfileRoute.kt @@ -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, + ) + } + } +} diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/screen/profile/EditProfileScreen.kt b/app/src/main/java/io/nekohasekai/sfa/compose/screen/profile/EditProfileScreen.kt index aa11b10..567e4a5 100644 --- a/app/src/main/java/io/nekohasekai/sfa/compose/screen/profile/EditProfileScreen.kt +++ b/app/src/main/java/io/nekohasekai/sfa/compose/screen/profile/EditProfileScreen.kt @@ -13,6 +13,7 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.ui.platform.LocalDensity import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.navigationBars @@ -44,7 +45,6 @@ import androidx.compose.material3.IconButton import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.Scaffold import androidx.compose.material3.Surface import androidx.compose.material3.Switch import androidx.compose.material3.Text @@ -66,6 +66,8 @@ import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel import io.nekohasekai.sfa.R +import io.nekohasekai.sfa.compose.base.SelectableMessageDialog +import io.nekohasekai.sfa.compose.topbar.OverrideTopBar import io.nekohasekai.sfa.compose.util.ProfileIcons import io.nekohasekai.sfa.compose.util.RelativeTimeFormatter import io.nekohasekai.sfa.compose.util.icons.MaterialIconsLibrary @@ -112,23 +114,13 @@ fun EditProfileScreen( // Error dialog if (showErrorDialog) { - AlertDialog( - onDismissRequest = { + SelectableMessageDialog( + title = stringResource(R.string.error_title), + message = uiState.errorMessage ?: "", + onDismiss = { showErrorDialog = false viewModel.clearError() }, - title = { Text(stringResource(R.string.error_title)) }, - text = { Text(uiState.errorMessage ?: "") }, - confirmButton = { - TextButton( - onClick = { - showErrorDialog = false - viewModel.clearError() - }, - ) { - Text(stringResource(R.string.ok)) - } - }, ) } @@ -175,74 +167,38 @@ fun EditProfileScreen( showUnsavedChangesDialog = true } - Scaffold( - topBar = { - TopAppBar( - title = { Text(stringResource(R.string.title_edit_profile)) }, - navigationIcon = { - IconButton(onClick = handleBack) { - Icon( - Icons.AutoMirrored.Filled.ArrowBack, - 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)) - } - } - } + OverrideTopBar { + TopAppBar( + title = { Text(stringResource(R.string.title_edit_profile)) }, + navigationIcon = { + IconButton(onClick = handleBack) { + Icon( + Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = stringResource(R.string.content_description_back), + ) } - } - }, - ) { paddingValues -> - Box( - modifier = - Modifier - .fillMaxSize() - .padding(paddingValues), - ) { + }, + colors = + TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.surface, + ), + ) + } + + 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) if (uiState.isLoading) { LinearProgressIndicator( @@ -256,7 +212,8 @@ fun EditProfileScreen( Modifier .fillMaxSize() .verticalScroll(rememberScrollState()) - .padding(16.dp), + .padding(16.dp) + .padding(bottom = bottomBarPadding), verticalArrangement = Arrangement.spacedBy(16.dp), ) { // Basic Information Card @@ -560,6 +517,47 @@ fun EditProfileScreen( } } } + AnimatedVisibility( + visible = uiState.hasChanges, + enter = fadeIn() + expandVertically(), + exit = fadeOut() + shrinkVertically(), + modifier = Modifier.align(Alignment.BottomCenter), + ) { + Surface( + modifier = Modifier.fillMaxWidth(), + color = MaterialTheme.colorScheme.surface, + tonalElevation = 3.dp, + ) { + Box( + modifier = + Modifier + .fillMaxWidth() + .windowInsetsPadding(WindowInsets.navigationBars) + .padding(16.dp), + ) { + Button( + onClick = { viewModel.saveChanges() }, + modifier = Modifier.fillMaxWidth(), + enabled = !uiState.isSaving && uiState.autoUpdateIntervalError == null, + ) { + if (uiState.isSaving) { + CircularProgressIndicator( + modifier = Modifier.size(18.dp), + strokeWidth = 2.dp, + color = MaterialTheme.colorScheme.onPrimary, + ) + } else { + Icon( + Icons.Default.Save, + contentDescription = null, + modifier = Modifier.size(18.dp), + ) + Spacer(modifier = Modifier.width(8.dp)) + Text(stringResource(R.string.save)) + } + } + } + } } } } diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/screen/profile/IconSelectionScreen.kt b/app/src/main/java/io/nekohasekai/sfa/compose/screen/profile/IconSelectionScreen.kt index afc1c5c..061a5fa 100644 --- a/app/src/main/java/io/nekohasekai/sfa/compose/screen/profile/IconSelectionScreen.kt +++ b/app/src/main/java/io/nekohasekai/sfa/compose/screen/profile/IconSelectionScreen.kt @@ -12,6 +12,7 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.ui.platform.LocalDensity import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth @@ -44,7 +45,6 @@ import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.Scaffold import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TextButton @@ -67,6 +67,7 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import io.nekohasekai.sfa.R +import io.nekohasekai.sfa.compose.topbar.OverrideTopBar import io.nekohasekai.sfa.compose.util.ProfileIcon import io.nekohasekai.sfa.compose.util.icons.IconCategory import io.nekohasekai.sfa.compose.util.icons.MaterialIconsLibrary @@ -99,113 +100,76 @@ fun IconSelectionScreen( } } - Scaffold( - topBar = { - TopAppBar( - title = { Text(stringResource(R.string.select_icon)) }, - navigationIcon = { - IconButton(onClick = onNavigateBack) { - Icon( - Icons.AutoMirrored.Filled.ArrowBack, - 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, - ) - } - } - } - } + OverrideTopBar { + TopAppBar( + title = { Text(stringResource(R.string.select_icon)) }, + navigationIcon = { + IconButton(onClick = onNavigateBack) { + Icon( + Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = stringResource(R.string.content_description_back), + ) } - } - }, - ) { paddingValues -> + }, + 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, + ), + ) + } + + 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( modifier = Modifier .fillMaxSize() - .padding(paddingValues), + .padding(bottom = bottomBarPadding), ) { // Show search bar with animation AnimatedVisibility( @@ -419,6 +383,55 @@ fun IconSelectionScreen( } } } + + currentIcon?.let { (id, icon) -> + Card( + modifier = + Modifier + .fillMaxWidth() + .align(Alignment.BottomCenter) + .windowInsetsPadding(WindowInsets.navigationBars) + .padding(horizontal = 16.dp, vertical = 8.dp), + colors = + CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.3f), + ), + ) { + Row( + modifier = + Modifier + .fillMaxWidth() + .padding(12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = icon, + contentDescription = null, + modifier = Modifier.size(24.dp), + tint = MaterialTheme.colorScheme.primary, + ) + Spacer(modifier = Modifier.width(12.dp)) + Column { + val iconInfo = MaterialIconsLibrary.getAllIcons().find { it.id == id } + Text( + text = + stringResource( + R.string.current_icon_format, + iconInfo?.label ?: id, + ), + style = MaterialTheme.typography.bodyMedium, + ) + MaterialIconsLibrary.getCategoryForIcon(id)?.let { category -> + Text( + text = category, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + } + } } } diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/screen/profileoverride/PerAppProxyScreen.kt b/app/src/main/java/io/nekohasekai/sfa/compose/screen/profileoverride/PerAppProxyScreen.kt new file mode 100644 index 0000000..fbee093 --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sfa/compose/screen/profileoverride/PerAppProxyScreen.kt @@ -0,0 +1,1362 @@ +package io.nekohasekai.sfa.compose.screen.profileoverride + +import android.content.pm.PackageInfo +import android.content.pm.PackageManager +import android.os.Build +import android.util.Log +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.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.Clear +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.ContentCopy +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.RadioButtonChecked +import androidx.compose.material.icons.filled.RadioButtonUnchecked +import androidx.compose.material.icons.filled.Save +import androidx.compose.material.icons.filled.Search +import androidx.compose.material.icons.filled.SelectAll +import androidx.compose.material.icons.filled.Sort +import androidx.compose.material.icons.filled.Tune +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.Switch +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +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.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +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 androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import com.android.tools.smali.dexlib2.dexbacked.DexBackedDexFile +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.vendor.PackageQueryManager +import io.nekohasekai.sfa.vendor.PrivilegedAccessRequiredException +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import java.io.File +import java.util.concurrent.atomic.AtomicInteger +import java.util.zip.ZipFile + +private data class LoadResult( + val proxyMode: Int, + val packages: List, + val selectedUids: Set, +) + +private data class ScanProgress( + val current: Int, + val max: Int, +) + +private sealed class ScanResult { + data object Empty : ScanResult() + data class Found(val apps: Map) : ScanResult() +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun PerAppProxyScreen(onBack: () -> Unit) { + val context = LocalContext.current + val focusManager = LocalFocusManager.current + val coroutineScope = rememberCoroutineScope() + + var proxyMode by remember { mutableStateOf(Settings.perAppProxyMode) } + 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>(emptyList()) } + var displayPackages by remember { mutableStateOf>(emptyList()) } + var currentPackages by remember { mutableStateOf>(emptyList()) } + var selectedUids by remember { mutableStateOf>(emptySet()) } + var isLoading by remember { mutableStateOf(true) } + + var isSearchActive by remember { mutableStateOf(false) } + var searchQuery by remember { mutableStateOf("") } + + var scanProgress by remember { mutableStateOf(null) } + var scanResult by remember { mutableStateOf(null) } + + fun buildPackageList(newUids: Set): Set { + 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) { + coroutineScope.launch { + Settings.perAppProxyList = buildPackageList(newUids) + } + } + + fun postSaveSelectedApplications(newUids: Set) { + 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 + selectedUids = newSelected + saveSelectedApplications(newSelected) + } + + fun startScan() { + if (scanProgress != null) return + val scanPackages = currentPackages.toList() + if (scanPackages.isEmpty()) return + scanProgress = ScanProgress(0, scanPackages.size) + coroutineScope.launch { + val startTime = System.currentTimeMillis() + val foundApps = + withContext(Dispatchers.Default) { + mutableMapOf().also { found -> + val progressInt = AtomicInteger() + scanPackages.map { packageCache -> + async { + if (PerAppProxyScanner.scanChinaPackage(packageCache.info)) { + found[packageCache.packageName] = packageCache + } + val nextValue = progressInt.incrementAndGet() + withContext(Dispatchers.Main) { + scanProgress = ScanProgress(nextValue, scanPackages.size) + } + } + }.awaitAll() + } + } + Log.d( + "PerAppProxyScanner", + "Scan China apps took ${(System.currentTimeMillis() - startTime).toDouble() / 1000}s", + ) + scanProgress = null + scanResult = if (foundApps.isEmpty()) ScanResult.Empty else ScanResult.Found(foundApps) + } + } + + 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 mode = + if (Settings.perAppProxyMode == Settings.PER_APP_PROXY_INCLUDE) { + Settings.PER_APP_PROXY_INCLUDE + } else { + Settings.PER_APP_PROXY_EXCLUDE + } + val installedPackages = PackageQueryManager.getInstalledPackages(packageManagerFlags) + val packageManager = context.packageManager + val packageCaches = + installedPackages.mapNotNull { packageInfo -> + if (packageInfo.packageName == context.packageName) return@mapNotNull null + val appInfo = packageInfo.applicationInfo ?: return@mapNotNull null + PackageCache(packageInfo, appInfo, packageManager) + } + val selectedPackageNames = Settings.perAppProxyList.toMutableSet() + val selectedUidSet = + packageCaches.mapNotNull { packageCache -> + if (selectedPackageNames.contains(packageCache.packageName)) { + packageCache.uid + } else { + null + } + }.toSet() + LoadResult(mode, packageCaches, selectedUidSet) + } catch (_: PrivilegedAccessRequiredException) { + null + } + } + if (loadResult == null) { + Toast.makeText( + context, + R.string.privileged_access_required, + Toast.LENGTH_LONG, + ).show() + onBack() + return@LaunchedEffect + } + proxyMode = loadResult.proxyMode + packages = loadResult.packages + selectedUids = loadResult.selectedUids + applyFilter() + updateCurrentPackages(searchQuery) + isLoading = false + } + + OverrideTopBar { + TopAppBar( + title = { Text(stringResource(R.string.per_app_proxy)) }, + 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), + ) + } + PerAppProxyMenus( + proxyMode = proxyMode, + sortMode = sortMode, + sortReverse = sortReverse, + hideSystemApps = hideSystemApps, + hideOfflineApps = hideOfflineApps, + hideDisabledApps = hideDisabledApps, + onModeChange = { mode -> + proxyMode = mode + coroutineScope.launch { + Settings.perAppProxyMode = mode + } + }, + onSortModeChange = { mode -> + sortMode = mode + applyFilter() + }, + onSortReverseToggle = { + sortReverse = !sortReverse + applyFilter() + }, + onHideSystemAppsToggle = { + hideSystemApps = !hideSystemApps + applyFilter() + }, + onHideOfflineAppsToggle = { + hideOfflineApps = !hideOfflineApps + applyFilter() + }, + onHideDisabledAppsToggle = { + hideDisabledApps = !hideDisabledApps + applyFilter() + }, + 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() + }, + onScanChinaApps = { startScan() }, + ) + }, + 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 = + if (proxyMode == Settings.PER_APP_PROXY_INCLUDE) { + stringResource(R.string.per_app_proxy_mode_include_description) + } else { + stringResource(R.string.per_app_proxy_mode_exclude_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() }, + ) + } + } + } + + if (scanProgress != null) { + val progress = scanProgress + Dialog( + onDismissRequest = {}, + properties = DialogProperties(dismissOnBackPress = false, dismissOnClickOutside = false), + ) { + Surface( + shape = MaterialTheme.shapes.medium, + color = MaterialTheme.colorScheme.surface, + ) { + Column(modifier = Modifier.padding(24.dp)) { + Text( + text = stringResource(R.string.message_scanning), + style = MaterialTheme.typography.titleMedium, + ) + Spacer(modifier = Modifier.height(16.dp)) + LinearProgressIndicator( + progress = { + if (progress == null || progress.max == 0) { + 0f + } else { + progress.current.toFloat() / progress.max.toFloat() + } + }, + modifier = Modifier.fillMaxWidth(), + ) + Spacer(modifier = Modifier.height(8.dp)) + if (progress != null) { + Text( + text = "${progress.current}/${progress.max}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + } + } + + when (val result = scanResult) { + ScanResult.Empty -> { + Dialog( + onDismissRequest = { scanResult = null }, + ) { + Surface( + shape = MaterialTheme.shapes.medium, + color = MaterialTheme.colorScheme.surface, + ) { + Column(modifier = Modifier.padding(24.dp)) { + Text( + text = stringResource(R.string.title_scan_result), + style = MaterialTheme.typography.titleMedium, + ) + Spacer(modifier = Modifier.height(16.dp)) + Text( + text = stringResource(R.string.message_scan_app_no_apps_found), + style = MaterialTheme.typography.bodyMedium, + ) + Spacer(modifier = Modifier.height(24.dp)) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End, + ) { + TextButton(onClick = { scanResult = null }) { + Text(stringResource(R.string.ok)) + } + } + } + } + } + } + + is ScanResult.Found -> { + val dialogContent = + stringResource(R.string.message_scan_app_found) + "\n\n" + + result.apps.entries.joinToString("\n") { + "${it.value.applicationLabel} (${it.key})" + } + Dialog( + onDismissRequest = { scanResult = null }, + ) { + Surface( + shape = MaterialTheme.shapes.medium, + color = MaterialTheme.colorScheme.surface, + ) { + Column(modifier = Modifier.padding(24.dp)) { + Text( + text = stringResource(R.string.title_scan_result), + style = MaterialTheme.typography.titleMedium, + ) + Spacer(modifier = Modifier.height(16.dp)) + Box( + modifier = + Modifier + .fillMaxWidth() + .heightIn(max = 360.dp) + .verticalScroll(rememberScrollState()), + ) { + Text( + text = dialogContent, + style = MaterialTheme.typography.bodyMedium, + ) + } + Spacer(modifier = Modifier.height(24.dp)) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End, + ) { + TextButton(onClick = { scanResult = null }) { + Text(stringResource(android.R.string.cancel)) + } + Spacer(modifier = Modifier.size(8.dp)) + TextButton( + onClick = { + val newSelected = selectedUids.toMutableSet() + result.apps.values.forEach { + newSelected.remove(it.uid) + } + postSaveSelectedApplications(newSelected) + scanResult = null + }, + ) { + Text(stringResource(R.string.action_deselect)) + } + Spacer(modifier = Modifier.size(8.dp)) + TextButton( + onClick = { + val newSelected = selectedUids.toMutableSet() + result.apps.values.forEach { + newSelected.add(it.uid) + } + postSaveSelectedApplications(newSelected) + scanResult = null + }, + ) { + Text(stringResource(R.string.per_app_proxy_select)) + } + } + } + } + } + } + + null -> Unit + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun PerAppProxyMenus( + proxyMode: Int, + sortMode: SortMode, + sortReverse: Boolean, + hideSystemApps: Boolean, + hideOfflineApps: Boolean, + hideDisabledApps: Boolean, + onModeChange: (Int) -> Unit, + onSortModeChange: (SortMode) -> Unit, + onSortReverseToggle: () -> Unit, + onHideSystemAppsToggle: () -> Unit, + onHideOfflineAppsToggle: () -> Unit, + onHideDisabledAppsToggle: () -> Unit, + onSelectAll: () -> Unit, + onDeselectAll: () -> Unit, + onImport: () -> Unit, + onExport: () -> Unit, + onScanChinaApps: () -> Unit, +) { + var showMainMenu by remember { mutableStateOf(false) } + var showModeMenu by remember { mutableStateOf(false) } + var showSortMenu by remember { mutableStateOf(false) } + var showFilterMenu by remember { mutableStateOf(false) } + var showSelectMenu by remember { mutableStateOf(false) } + var showBackupMenu by remember { mutableStateOf(false) } + var showScanMenu by remember { mutableStateOf(false) } + + Box { + IconButton(onClick = { showMainMenu = true }) { + Icon(Icons.Default.MoreVert, contentDescription = null) + } + + DropdownMenu( + expanded = showMainMenu, + onDismissRequest = { + showMainMenu = false + showModeMenu = false + showSortMenu = false + showFilterMenu = false + showSelectMenu = false + showBackupMenu = false + showScanMenu = false + }, + ) { + DropdownMenuItem( + text = { Text(stringResource(R.string.per_app_proxy_mode)) }, + onClick = { showModeMenu = !showModeMenu }, + leadingIcon = { + Icon( + imageVector = Icons.Default.Tune, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + ) + }, + trailingIcon = { + Icon( + imageVector = + if (showModeMenu) { + Icons.Default.ExpandLess + } else { + Icons.Default.ExpandMore + }, + contentDescription = null, + ) + }, + ) + if (showModeMenu) { + DropdownMenuItem( + text = { Text(stringResource(R.string.per_app_proxy_mode_include)) }, + onClick = { + onModeChange(Settings.PER_APP_PROXY_INCLUDE) + showMainMenu = false + showModeMenu = false + }, + leadingIcon = { + Icon( + imageVector = + if (proxyMode == Settings.PER_APP_PROXY_INCLUDE) { + Icons.Default.RadioButtonChecked + } else { + Icons.Default.RadioButtonUnchecked + }, + contentDescription = null, + tint = + if (proxyMode == Settings.PER_APP_PROXY_INCLUDE) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, + modifier = Modifier.padding(start = 24.dp), + ) + }, + ) + DropdownMenuItem( + text = { Text(stringResource(R.string.per_app_proxy_mode_exclude)) }, + onClick = { + onModeChange(Settings.PER_APP_PROXY_EXCLUDE) + showMainMenu = false + showModeMenu = false + }, + leadingIcon = { + Icon( + imageVector = + if (proxyMode == Settings.PER_APP_PROXY_EXCLUDE) { + Icons.Default.RadioButtonChecked + } else { + Icons.Default.RadioButtonUnchecked + }, + contentDescription = null, + tint = + if (proxyMode == Settings.PER_APP_PROXY_EXCLUDE) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, + modifier = Modifier.padding(start = 24.dp), + ) + }, + ) + HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp)) + } + 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, + ) + }, + trailingIcon = { + Icon( + imageVector = + if (showSortMenu) { + Icons.Default.ExpandLess + } else { + Icons.Default.ExpandMore + }, + contentDescription = null, + ) + }, + ) + 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 = + if (sortMode == SortMode.NAME) { + Icons.Default.RadioButtonChecked + } else { + Icons.Default.RadioButtonUnchecked + }, + contentDescription = null, + tint = + if (sortMode == SortMode.NAME) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, + 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 = + if (sortMode == SortMode.PACKAGE_NAME) { + Icons.Default.RadioButtonChecked + } else { + Icons.Default.RadioButtonUnchecked + }, + contentDescription = null, + tint = + if (sortMode == SortMode.PACKAGE_NAME) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, + 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 = + if (sortMode == SortMode.UID) { + Icons.Default.RadioButtonChecked + } else { + Icons.Default.RadioButtonUnchecked + }, + contentDescription = null, + tint = + if (sortMode == SortMode.UID) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, + 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 = + if (sortMode == SortMode.INSTALL_TIME) { + Icons.Default.RadioButtonChecked + } else { + Icons.Default.RadioButtonUnchecked + }, + contentDescription = null, + tint = + if (sortMode == SortMode.INSTALL_TIME) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, + 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 = + if (sortMode == SortMode.UPDATE_TIME) { + Icons.Default.RadioButtonChecked + } else { + Icons.Default.RadioButtonUnchecked + }, + contentDescription = null, + tint = + if (sortMode == SortMode.UPDATE_TIME) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, + modifier = Modifier.padding(start = 24.dp), + ) + }, + ) + DropdownMenuItem( + text = { Text(stringResource(R.string.per_app_proxy_sort_mode_reverse)) }, + onClick = { + onSortReverseToggle() + showMainMenu = false + showSortMenu = false + }, + leadingIcon = { + Icon( + imageVector = + if (sortReverse) { + Icons.Default.Check + } else { + Icons.Default.RadioButtonUnchecked + }, + contentDescription = null, + tint = + if (sortReverse) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, + 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, + ) + }, + trailingIcon = { + Icon( + imageVector = + if (showFilterMenu) { + Icons.Default.ExpandLess + } else { + Icons.Default.ExpandMore + }, + contentDescription = null, + ) + }, + ) + if (showFilterMenu) { + DropdownMenuItem( + text = { Text(stringResource(R.string.per_app_proxy_hide_system_apps)) }, + onClick = { + onHideSystemAppsToggle() + showMainMenu = false + showFilterMenu = false + }, + leadingIcon = { + Icon( + imageVector = + if (hideSystemApps) { + Icons.Default.Check + } else { + Icons.Default.RadioButtonUnchecked + }, + contentDescription = null, + tint = + if (hideSystemApps) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, + 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 = + if (hideOfflineApps) { + Icons.Default.Check + } else { + Icons.Default.RadioButtonUnchecked + }, + contentDescription = null, + tint = + if (hideOfflineApps) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, + 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 = + if (hideDisabledApps) { + Icons.Default.Check + } else { + Icons.Default.RadioButtonUnchecked + }, + contentDescription = null, + tint = + if (hideDisabledApps) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, + 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, + ) + }, + trailingIcon = { + Icon( + imageVector = + if (showSelectMenu) { + Icons.Default.ExpandLess + } else { + Icons.Default.ExpandMore + }, + contentDescription = null, + ) + }, + ) + 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.per_app_proxy_select_none)) }, + onClick = { + onDeselectAll() + showMainMenu = false + showSelectMenu = false + }, + leadingIcon = { + Icon( + imageVector = Icons.Default.RadioButtonUnchecked, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + 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.Save, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + ) + }, + trailingIcon = { + Icon( + imageVector = + if (showBackupMenu) { + Icons.Default.ExpandLess + } else { + Icons.Default.ExpandMore + }, + contentDescription = null, + ) + }, + ) + 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.ContentCopy, + 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_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), + ) + }, + ) + } + } + } +} + +object PerAppProxyScanner { + 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("PerAppProxyScanner", "Match package name: $packageName") + return true + } + try { + val appInfo = packageInfo.applicationInfo ?: return false + packageInfo.services?.forEach { + if (it.name.matches(chinaAppRegex)) { + Log.d("PerAppProxyScanner", "Match service ${it.name} in $packageName") + return true + } + } + packageInfo.activities?.forEach { + if (it.name.matches(chinaAppRegex)) { + Log.d("PerAppProxyScanner", "Match activity ${it.name} in $packageName") + return true + } + } + packageInfo.receivers?.forEach { + if (it.name.matches(chinaAppRegex)) { + Log.d("PerAppProxyScanner", "Match receiver ${it.name} in $packageName") + return true + } + } + packageInfo.providers?.forEach { + if (it.name.matches(chinaAppRegex)) { + Log.d("PerAppProxyScanner", "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( + "PerAppProxyScanner", + "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("PerAppProxyScanner", "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("PerAppProxyScanner", "Match $clazzName in $packageName") + return true + } + } + } + } + } catch (e: Exception) { + Log.e("PerAppProxyScanner", "Error scanning package $packageName", e) + } + return false + } +} diff --git a/app/src/main/java/io/nekohasekai/sfa/ui/profile/QRCodeSmartCrop.kt b/app/src/main/java/io/nekohasekai/sfa/compose/screen/qrscan/QRCodeSmartCrop.kt similarity index 99% rename from app/src/main/java/io/nekohasekai/sfa/ui/profile/QRCodeSmartCrop.kt rename to app/src/main/java/io/nekohasekai/sfa/compose/screen/qrscan/QRCodeSmartCrop.kt index 662d2a4..45add0c 100644 --- a/app/src/main/java/io/nekohasekai/sfa/ui/profile/QRCodeSmartCrop.kt +++ b/app/src/main/java/io/nekohasekai/sfa/compose/screen/qrscan/QRCodeSmartCrop.kt @@ -1,4 +1,4 @@ -package io.nekohasekai.sfa.ui.profile +package io.nekohasekai.sfa.compose.screen.qrscan import kotlin.math.max import kotlin.math.min diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/screen/qrscan/QRScanViewModel.kt b/app/src/main/java/io/nekohasekai/sfa/compose/screen/qrscan/QRScanViewModel.kt index ab41f2b..9537934 100644 --- a/app/src/main/java/io/nekohasekai/sfa/compose/screen/qrscan/QRScanViewModel.kt +++ b/app/src/main/java/io/nekohasekai/sfa/compose/screen/qrscan/QRScanViewModel.kt @@ -16,8 +16,6 @@ import androidx.lifecycle.LifecycleOwner import io.nekohasekai.libbox.Libbox import io.nekohasekai.sfa.qrs.QRSDecoder import io.nekohasekai.sfa.qrs.readIntLE -import io.nekohasekai.sfa.ui.profile.QRCodeCropArea -import io.nekohasekai.sfa.ui.profile.ZxingQRCodeAnalyzer import io.nekohasekai.sfa.vendor.Vendor import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow diff --git a/app/src/main/java/io/nekohasekai/sfa/ui/profile/ZxingQRCodeAnalyzer.kt b/app/src/main/java/io/nekohasekai/sfa/compose/screen/qrscan/ZxingQRCodeAnalyzer.kt similarity index 98% rename from app/src/main/java/io/nekohasekai/sfa/ui/profile/ZxingQRCodeAnalyzer.kt rename to app/src/main/java/io/nekohasekai/sfa/compose/screen/qrscan/ZxingQRCodeAnalyzer.kt index 4014cdd..f7bb989 100644 --- a/app/src/main/java/io/nekohasekai/sfa/ui/profile/ZxingQRCodeAnalyzer.kt +++ b/app/src/main/java/io/nekohasekai/sfa/compose/screen/qrscan/ZxingQRCodeAnalyzer.kt @@ -1,4 +1,4 @@ -package io.nekohasekai.sfa.ui.profile +package io.nekohasekai.sfa.compose.screen.qrscan import androidx.camera.core.ImageAnalysis import androidx.camera.core.ImageProxy diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/screen/settings/AppSettingsScreen.kt b/app/src/main/java/io/nekohasekai/sfa/compose/screen/settings/AppSettingsScreen.kt index a7b35f3..843f0a5 100644 --- a/app/src/main/java/io/nekohasekai/sfa/compose/screen/settings/AppSettingsScreen.kt +++ b/app/src/main/java/io/nekohasekai/sfa/compose/screen/settings/AppSettingsScreen.kt @@ -20,6 +20,7 @@ import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.outlined.AdminPanelSettings import androidx.compose.material.icons.outlined.Autorenew import androidx.compose.material.icons.outlined.Download @@ -36,14 +37,17 @@ import androidx.compose.material3.CardDefaults import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton import androidx.compose.material3.ListItem import androidx.compose.material3.ListItemDefaults import androidx.compose.material3.MaterialTheme import androidx.compose.material3.RadioButton import androidx.compose.material3.Text import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -62,11 +66,14 @@ import androidx.navigation.NavController import io.nekohasekai.sfa.BuildConfig import io.nekohasekai.sfa.R import io.nekohasekai.sfa.database.Settings +import io.nekohasekai.sfa.compose.topbar.OverrideTopBar import io.nekohasekai.sfa.compose.component.UpdateAvailableDialog import io.nekohasekai.sfa.update.UpdateCheckException import io.nekohasekai.sfa.update.UpdateState import io.nekohasekai.sfa.update.UpdateTrack import io.nekohasekai.sfa.vendor.Vendor +import io.nekohasekai.sfa.utils.HookStatusClient +import io.nekohasekai.sfa.xposed.XposedActivation import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.launch @@ -75,6 +82,20 @@ import kotlinx.coroutines.withContext @OptIn(ExperimentalMaterial3Api::class) @Composable fun AppSettingsScreen(navController: NavController) { + OverrideTopBar { + TopAppBar( + title = { Text(stringResource(R.string.title_app_settings)) }, + navigationIcon = { + IconButton(onClick = { navController.navigateUp() }) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = stringResource(R.string.content_description_back), + ) + } + }, + ) + } + val context = LocalContext.current val scope = rememberCoroutineScope() val hasUpdate by UpdateState.hasUpdate @@ -87,6 +108,8 @@ fun AppSettingsScreen(navController: NavController) { var silentInstallEnabled by remember { mutableStateOf(Settings.silentInstallEnabled) } var silentInstallMethod by remember { mutableStateOf(Settings.silentInstallMethod) } + val systemHookStatus by HookStatusClient.status.collectAsState() + val xposedActivated = systemHookStatus?.active == true || XposedActivation.isActivated(context) var isMethodAvailable by remember { mutableStateOf(true) } var autoUpdateEnabled by remember { mutableStateOf(Settings.autoUpdateEnabled) } var showInstallMethodMenu by remember { mutableStateOf(false) } @@ -98,8 +121,13 @@ fun AppSettingsScreen(navController: NavController) { var downloadError by remember { mutableStateOf(null) } var showUpdateAvailableDialog by remember { mutableStateOf(false) } + LaunchedEffect(Unit) { + HookStatusClient.refresh() + } + // Re-check method availability when returning from background (e.g., after granting permission) LifecycleEventEffect(Lifecycle.Event.ON_RESUME) { + HookStatusClient.refresh() if (silentInstallEnabled) { scope.launch { val success = withContext(Dispatchers.IO) { @@ -216,14 +244,10 @@ fun AppSettingsScreen(navController: NavController) { downloadError = null downloadJob = scope.launch { try { - val result = withContext(Dispatchers.IO) { + withContext(Dispatchers.IO) { Vendor.downloadAndInstall(context, updateInfo!!.downloadUrl) } - if (result.isFailure) { - downloadError = result.exceptionOrNull()?.message - } else { - showDownloadDialog = false - } + showDownloadDialog = false } catch (e: Exception) { downloadError = e.message } @@ -473,7 +497,7 @@ fun AppSettingsScreen(navController: NavController) { ), ) - if (silentInstallEnabled) { + if (silentInstallEnabled && !xposedActivated) { ListItem( headlineContent = { Text( @@ -707,6 +731,76 @@ fun AppSettingsScreen(navController: NavController) { ), ) + if (BuildConfig.DEBUG && Vendor.supportsTrackSelection()) { + var isForceDownloading by remember { mutableStateOf(false) } + ListItem( + headlineContent = { + Text( + stringResource(R.string.force_download_install), + style = MaterialTheme.typography.bodyLarge, + ) + }, + leadingContent = { + Icon( + imageVector = Icons.Outlined.SystemUpdateAlt, + contentDescription = null, + tint = MaterialTheme.colorScheme.error, + ) + }, + trailingContent = { + if (isForceDownloading) { + CircularProgressIndicator( + modifier = Modifier.size(24.dp), + strokeWidth = 2.dp, + ) + } + }, + modifier = + Modifier + .clip( + if (hasUpdate) { + RoundedCornerShape(0.dp) + } else { + RoundedCornerShape(bottomStart = 12.dp, bottomEnd = 12.dp) + }, + ) + .clickable(enabled = !isForceDownloading) { + isForceDownloading = true + scope.launch { + try { + val latestUpdate = withContext(Dispatchers.IO) { + Vendor.forceGetLatestUpdate() + } + if (latestUpdate != null) { + showDownloadDialog = true + downloadError = null + downloadJob = scope.launch { + try { + withContext(Dispatchers.IO) { + Vendor.downloadAndInstall(context, latestUpdate.downloadUrl) + } + showDownloadDialog = false + } catch (e: Exception) { + downloadError = e.message + } + } + } else { + showErrorDialog = R.string.no_updates_available + } + } catch (_: UpdateCheckException.TrackNotSupported) { + showErrorDialog = R.string.update_track_not_supported + } catch (_: Exception) { + } + isForceDownloading = false + } + }, + colors = + ListItemDefaults.colors( + containerColor = Color.Transparent, + ), + ) + } + if (hasUpdate && updateInfo != null) { ListItem( headlineContent = { diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/screen/settings/CoreSettingsScreen.kt b/app/src/main/java/io/nekohasekai/sfa/compose/screen/settings/CoreSettingsScreen.kt index 10bade2..d0e4229 100644 --- a/app/src/main/java/io/nekohasekai/sfa/compose/screen/settings/CoreSettingsScreen.kt +++ b/app/src/main/java/io/nekohasekai/sfa/compose/screen/settings/CoreSettingsScreen.kt @@ -17,6 +17,7 @@ import android.content.Intent import android.provider.DocumentsContract import android.widget.Toast import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.outlined.DeleteForever import androidx.compose.material.icons.outlined.FolderOpen import androidx.compose.material.icons.outlined.Info @@ -24,12 +25,15 @@ import androidx.compose.material.icons.outlined.Storage import androidx.compose.material.icons.outlined.WarningAmber import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults +import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton import androidx.compose.material3.ListItem import androidx.compose.material3.ListItemDefaults import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Switch import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -46,13 +50,29 @@ import androidx.compose.ui.unit.dp import androidx.navigation.NavController import io.nekohasekai.libbox.Libbox import io.nekohasekai.sfa.R +import io.nekohasekai.sfa.compose.topbar.OverrideTopBar import io.nekohasekai.sfa.database.Settings import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +@OptIn(ExperimentalMaterial3Api::class) @Composable fun CoreSettingsScreen(navController: NavController) { + OverrideTopBar { + TopAppBar( + title = { Text(stringResource(R.string.core)) }, + navigationIcon = { + IconButton(onClick = { navController.navigateUp() }) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = stringResource(R.string.content_description_back), + ) + } + }, + ) + } + val context = LocalContext.current val scope = rememberCoroutineScope() var dataSize by remember { mutableStateOf("") } diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/screen/settings/PrivilegeSettingsScreen.kt b/app/src/main/java/io/nekohasekai/sfa/compose/screen/settings/PrivilegeSettingsScreen.kt new file mode 100644 index 0000000..b20cc79 --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sfa/compose/screen/settings/PrivilegeSettingsScreen.kt @@ -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(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(null) } + var showExportSuccessDialog by remember { mutableStateOf(false) } + var exportedFile by remember { mutableStateOf(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)) + } + }, + ) +} diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/screen/settings/ProfileOverrideScreen.kt b/app/src/main/java/io/nekohasekai/sfa/compose/screen/settings/ProfileOverrideScreen.kt index 94cefde..023ce80 100644 --- a/app/src/main/java/io/nekohasekai/sfa/compose/screen/settings/ProfileOverrideScreen.kt +++ b/app/src/main/java/io/nekohasekai/sfa/compose/screen/settings/ProfileOverrideScreen.kt @@ -16,6 +16,7 @@ import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.outlined.KeyboardArrowRight import androidx.compose.material.icons.outlined.AppShortcut import androidx.compose.material.icons.outlined.FilterList @@ -26,7 +27,9 @@ import androidx.compose.material3.AlertDialog import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton import androidx.compose.material3.ListItem import androidx.compose.material3.ListItemDefaults import androidx.compose.material3.MaterialTheme @@ -34,6 +37,7 @@ import androidx.compose.material3.RadioButton import androidx.compose.material3.Switch import androidx.compose.material3.Text import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect @@ -47,12 +51,16 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalLifecycleOwner import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.navigation.NavController +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver import io.nekohasekai.sfa.R +import io.nekohasekai.sfa.compose.topbar.OverrideTopBar import io.nekohasekai.sfa.database.Settings -import io.nekohasekai.sfa.ui.profileoverride.PerAppProxyActivity +import io.nekohasekai.sfa.compose.screen.profileoverride.PerAppProxyScanner import io.nekohasekai.sfa.vendor.PackageQueryManager import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.async @@ -60,8 +68,23 @@ import kotlinx.coroutines.awaitAll import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +@OptIn(ExperimentalMaterial3Api::class) @Composable fun ProfileOverrideScreen(navController: NavController) { + OverrideTopBar { + TopAppBar( + title = { Text(stringResource(R.string.profile_override)) }, + navigationIcon = { + IconButton(onClick = { navController.navigateUp() }) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = stringResource(R.string.content_description_back), + ) + } + }, + ) + } + val context = LocalContext.current val scope = rememberCoroutineScope() @@ -73,7 +96,7 @@ fun ProfileOverrideScreen(navController: NavController) { var showRootDialog by remember { mutableStateOf(false) } var showModeDialog by remember { mutableStateOf(false) } - val needsPrivilegedQuery = PackageQueryManager.needsPrivilegedQuery + val showModeSelector = PackageQueryManager.showModeSelector var packageQueryMode by remember { mutableStateOf(Settings.perAppProxyPackageQueryMode) } val useRootMode = packageQueryMode == Settings.PACKAGE_QUERY_MODE_ROOT @@ -82,20 +105,34 @@ fun ProfileOverrideScreen(navController: NavController) { val isShizukuPermissionGranted by PackageQueryManager.shizukuPermissionGranted.collectAsState() val isShizukuAvailable = isShizukuBinderReady && isShizukuPermissionGranted - DisposableEffect(needsPrivilegedQuery) { - if (needsPrivilegedQuery) { + DisposableEffect(showModeSelector) { + if (showModeSelector) { PackageQueryManager.registerListeners() } onDispose { - if (needsPrivilegedQuery) { + if (showModeSelector) { PackageQueryManager.unregisterListeners() } } } + val lifecycleOwner = LocalLifecycleOwner.current + DisposableEffect(lifecycleOwner, showModeSelector) { + if (!showModeSelector) return@DisposableEffect onDispose { } + val observer = LifecycleEventObserver { _, event -> + if (event == Lifecycle.Event.ON_RESUME) { + PackageQueryManager.refreshShizukuState() + } + } + lifecycleOwner.lifecycle.addObserver(observer) + onDispose { + lifecycleOwner.lifecycle.removeObserver(observer) + } + } + // Auto-disable per-app proxy if Shizuku authorization is revoked (only when using Shizuku mode) LaunchedEffect(isShizukuAvailable, useRootMode) { - if (needsPrivilegedQuery && !useRootMode && !isShizukuAvailable && perAppProxyEnabled) { + if (showModeSelector && !useRootMode && !isShizukuAvailable && perAppProxyEnabled) { perAppProxyEnabled = false withContext(Dispatchers.IO) { Settings.perAppProxyEnabled = false @@ -105,7 +142,7 @@ fun ProfileOverrideScreen(navController: NavController) { // Auto-close dialog and enable feature when Shizuku becomes available LaunchedEffect(isShizukuAvailable) { - if (needsPrivilegedQuery && isShizukuAvailable && showShizukuDialog) { + if (showModeSelector && isShizukuAvailable && showShizukuDialog) { showShizukuDialog = false perAppProxyEnabled = true withContext(Dispatchers.IO) { @@ -212,7 +249,7 @@ fun ProfileOverrideScreen(navController: NavController) { } // Section: Per-App Proxy - val canUsePerAppProxy = if (needsPrivilegedQuery) { + val canUsePerAppProxy = if (showModeSelector) { if (useRootMode) true else isShizukuAvailable } else { true @@ -237,7 +274,7 @@ fun ProfileOverrideScreen(navController: NavController) { ) { Column { // Mode selector (only when privileged query is needed) - if (needsPrivilegedQuery) { + if (showModeSelector) { val modeEnabled = !perAppProxyEnabled val disabledAlpha = 0.38f ListItem( @@ -301,7 +338,7 @@ fun ProfileOverrideScreen(navController: NavController) { Switch( checked = perAppProxyEnabled, onCheckedChange = { checked -> - if (checked && needsPrivilegedQuery) { + if (checked && showModeSelector) { if (useRootMode) { showRootDialog = true } else { @@ -329,7 +366,7 @@ fun ProfileOverrideScreen(navController: NavController) { }, modifier = Modifier.clip( - if (needsPrivilegedQuery) { + if (showModeSelector) { RoundedCornerShape(0.dp) } else if (perAppProxyEnabled && canUsePerAppProxy) { RoundedCornerShape(topStart = 12.dp, topEnd = 12.dp) @@ -383,8 +420,7 @@ fun ProfileOverrideScreen(navController: NavController) { }, modifier = Modifier.clickable(enabled = manageEnabled) { - val intent = Intent(context, PerAppProxyActivity::class.java) - context.startActivity(intent) + navController.navigate("settings/profile_override/manage") }, colors = ListItemDefaults.colors( @@ -674,7 +710,7 @@ private suspend fun scanAllChinaApps(): Set = withContext(Dispatchers.De val chinaApps = mutableSetOf() installedPackages.map { packageInfo -> async { - if (PerAppProxyActivity.scanChinaPackage(packageInfo)) { + if (PerAppProxyScanner.scanChinaPackage(packageInfo)) { synchronized(chinaApps) { chinaApps.add(packageInfo.packageName) } diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/screen/settings/ServiceSettingsScreen.kt b/app/src/main/java/io/nekohasekai/sfa/compose/screen/settings/ServiceSettingsScreen.kt index 46ace47..a2254f5 100644 --- a/app/src/main/java/io/nekohasekai/sfa/compose/screen/settings/ServiceSettingsScreen.kt +++ b/app/src/main/java/io/nekohasekai/sfa/compose/screen/settings/ServiceSettingsScreen.kt @@ -19,18 +19,22 @@ import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.outlined.BatteryChargingFull import androidx.compose.material.icons.outlined.Memory import androidx.compose.material3.Button import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults +import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton import androidx.compose.material3.ListItem import androidx.compose.material3.ListItemDefaults import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton import androidx.compose.material3.Switch import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -51,16 +55,32 @@ import io.nekohasekai.sfa.R import io.nekohasekai.sfa.bg.ServiceConnection import io.nekohasekai.sfa.compose.base.GlobalEventBus import io.nekohasekai.sfa.compose.base.UiEvent +import io.nekohasekai.sfa.compose.topbar.OverrideTopBar import io.nekohasekai.sfa.database.Settings import io.nekohasekai.sfa.ktx.launchCustomTab import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch +@OptIn(ExperimentalMaterial3Api::class) @Composable fun ServiceSettingsScreen( navController: NavController, serviceConnection: ServiceConnection? = null, ) { + OverrideTopBar { + TopAppBar( + title = { Text(stringResource(R.string.service)) }, + navigationIcon = { + IconButton(onClick = { navController.navigateUp() }) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = stringResource(R.string.content_description_back), + ) + } + }, + ) + } + val context = LocalContext.current val scope = rememberCoroutineScope() // Check battery optimization status diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/screen/settings/SettingsScreen.kt b/app/src/main/java/io/nekohasekai/sfa/compose/screen/settings/SettingsScreen.kt index a6382ec..570066d 100644 --- a/app/src/main/java/io/nekohasekai/sfa/compose/screen/settings/SettingsScreen.kt +++ b/app/src/main/java/io/nekohasekai/sfa/compose/screen/settings/SettingsScreen.kt @@ -23,6 +23,7 @@ import androidx.compose.material.icons.outlined.Info import androidx.compose.material.icons.outlined.Settings import androidx.compose.material.icons.outlined.SwapHoriz import androidx.compose.material.icons.outlined.Tune +import androidx.compose.material.icons.outlined.AdminPanelSettings import androidx.compose.material3.Badge import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults @@ -32,8 +33,10 @@ import androidx.compose.material3.ListItem import androidx.compose.material3.ListItemDefaults import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -48,20 +51,33 @@ import androidx.compose.ui.unit.dp import androidx.navigation.NavController import io.nekohasekai.sfa.BuildConfig import io.nekohasekai.sfa.R +import io.nekohasekai.sfa.compose.topbar.OverrideTopBar import io.nekohasekai.sfa.database.Settings import io.nekohasekai.sfa.update.UpdateState +import io.nekohasekai.sfa.utils.HookModuleUpdateNotifier +import io.nekohasekai.sfa.utils.HookStatusClient import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch @OptIn(ExperimentalMaterial3Api::class) @Composable fun SettingsScreen(navController: NavController) { + OverrideTopBar { + TopAppBar( + title = { Text(stringResource(R.string.title_settings)) }, + ) + } + val context = LocalContext.current val scope = rememberCoroutineScope() val hasUpdate by UpdateState.hasUpdate + val hookStatus by HookStatusClient.status.collectAsState() + val hasPendingPrivilegeDowngrade = HookModuleUpdateNotifier.isDowngrade(hookStatus) + val hasPendingPrivilegeUpdate = HookModuleUpdateNotifier.isUpgrade(hookStatus) var isBatteryOptimizationIgnored by remember { mutableStateOf(true) } LaunchedEffect(Unit) { + HookStatusClient.refresh() if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { val pm = context.getSystemService(PowerManager::class.java) isBatteryOptimizationIgnored = @@ -183,13 +199,43 @@ fun SettingsScreen(navController: NavController) { }, modifier = Modifier - .clip(RoundedCornerShape(bottomStart = 12.dp, bottomEnd = 12.dp)) .clickable { navController.navigate("settings/profile_override") }, colors = ListItemDefaults.colors( containerColor = Color.Transparent, ), ) + + ListItem( + headlineContent = { + Text( + stringResource(R.string.privilege_settings), + style = MaterialTheme.typography.bodyLarge, + ) + }, + leadingContent = { + Icon( + imageVector = Icons.Outlined.AdminPanelSettings, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + ) + }, + trailingContent = { + if (hasPendingPrivilegeDowngrade) { + Badge(containerColor = MaterialTheme.colorScheme.error) + } else if (hasPendingPrivilegeUpdate) { + Badge(containerColor = Color(0xFFFFC107)) + } + }, + modifier = + Modifier + .clip(RoundedCornerShape(bottomStart = 12.dp, bottomEnd = 12.dp)) + .clickable { navController.navigate("settings/privilege") }, + colors = + ListItemDefaults.colors( + containerColor = Color.Transparent, + ), + ) } } diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/shared/AppSelectionComponents.kt b/app/src/main/java/io/nekohasekai/sfa/compose/shared/AppSelectionComponents.kt new file mode 100644 index 0000000..21d1be3 --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sfa/compose/shared/AppSelectionComponents.kt @@ -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, + selectedUids: Set = emptySet(), + selectedFirst: Boolean = false, + hideSystemApps: Boolean, + hideOfflineApps: Boolean, + hideDisabledApps: Boolean, + sortMode: SortMode, + sortReverse: Boolean, +): List { + 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 { 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), + ) + }, + ) + } + } + } + } +} diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/topbar/TopBarController.kt b/app/src/main/java/io/nekohasekai/sfa/compose/topbar/TopBarController.kt new file mode 100644 index 0000000..a027c92 --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sfa/compose/topbar/TopBarController.kt @@ -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>, +) { + 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 { + 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) } + } +} diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/util/AnsiColorUtils.kt b/app/src/main/java/io/nekohasekai/sfa/compose/util/AnsiColorUtils.kt index cda24a1..5bd3c7e 100644 --- a/app/src/main/java/io/nekohasekai/sfa/compose/util/AnsiColorUtils.kt +++ b/app/src/main/java/io/nekohasekai/sfa/compose/util/AnsiColorUtils.kt @@ -20,7 +20,7 @@ object AnsiColorUtils { private val logWhite = Color(0xFFECF0F1) fun ansiToAnnotatedString(text: String): AnnotatedString { - val cleanText = text.replace(ansiRegex, "") + val cleanText = stripAnsi(text) val matches = ansiRegex.findAll(text).toList() if (matches.isEmpty()) { @@ -65,6 +65,8 @@ object AnsiColorUtils { } } + fun stripAnsi(text: String): String = text.replace(ansiRegex, "") + private fun parseAnsiCode(code: String): SpanStyle? { val colorCodes = code.substringAfter('[').substringBefore('m').split(';') diff --git a/app/src/main/java/io/nekohasekai/sfa/constant/SettingsKey.kt b/app/src/main/java/io/nekohasekai/sfa/constant/SettingsKey.kt index 8193377..88dbcb3 100644 --- a/app/src/main/java/io/nekohasekai/sfa/constant/SettingsKey.kt +++ b/app/src/main/java/io/nekohasekai/sfa/constant/SettingsKey.kt @@ -23,6 +23,11 @@ object SettingsKey { const val SYSTEM_PROXY_ENABLED = "system_proxy_enabled" + const val PRIVILEGE_SETTINGS_ENABLED = "hide_settings_enabled" + const val PRIVILEGE_SETTINGS_LIST = "hide_settings_list" + const val PRIVILEGE_SETTINGS_INTERFACE_RENAME_ENABLED = "hide_settings_interface_rename_enabled" + const val PRIVILEGE_SETTINGS_INTERFACE_PREFIX = "hide_settings_interface_prefix" + // dashboard const val DASHBOARD_ITEM_ORDER = "dashboard_item_order" const val DASHBOARD_DISABLED_ITEMS = "dashboard_disabled_items" diff --git a/app/src/main/java/io/nekohasekai/sfa/database/Settings.kt b/app/src/main/java/io/nekohasekai/sfa/database/Settings.kt index 572b0fb..efded71 100644 --- a/app/src/main/java/io/nekohasekai/sfa/database/Settings.kt +++ b/app/src/main/java/io/nekohasekai/sfa/database/Settings.kt @@ -92,6 +92,13 @@ object Settings { var systemProxyEnabled by dataStore.boolean(SettingsKey.SYSTEM_PROXY_ENABLED) { true } + var privilegeSettingsEnabled by dataStore.boolean(SettingsKey.PRIVILEGE_SETTINGS_ENABLED) { false } + var privilegeSettingsList by dataStore.stringSet(SettingsKey.PRIVILEGE_SETTINGS_LIST) { emptySet() } + var privilegeSettingsInterfaceRenameEnabled by dataStore.boolean( + SettingsKey.PRIVILEGE_SETTINGS_INTERFACE_RENAME_ENABLED + ) { false } + var privilegeSettingsInterfacePrefix by dataStore.string(SettingsKey.PRIVILEGE_SETTINGS_INTERFACE_PREFIX) { "wlan" } + var dashboardItemOrder by dataStore.string(SettingsKey.DASHBOARD_ITEM_ORDER) { "" } var dashboardDisabledItems by dataStore.stringSet(SettingsKey.DASHBOARD_DISABLED_ITEMS) { emptySet() } diff --git a/app/src/main/java/io/nekohasekai/sfa/ktx/Dialogs.kt b/app/src/main/java/io/nekohasekai/sfa/ktx/Dialogs.kt index d2ccc80..78c7c69 100644 --- a/app/src/main/java/io/nekohasekai/sfa/ktx/Dialogs.kt +++ b/app/src/main/java/io/nekohasekai/sfa/ktx/Dialogs.kt @@ -1,26 +1,52 @@ package io.nekohasekai.sfa.ktx +import android.content.ClipData +import android.content.ClipboardManager import android.content.Context import androidx.annotation.StringRes +import android.widget.ScrollView +import android.widget.TextView +import android.widget.Toast import com.google.android.material.dialog.MaterialAlertDialogBuilder import io.nekohasekai.sfa.R fun Context.errorDialogBuilder( @StringRes messageId: Int, ): MaterialAlertDialogBuilder { - return MaterialAlertDialogBuilder(this) - .setTitle(R.string.error_title) - .setMessage(messageId) - .setPositiveButton(android.R.string.ok, null) + return errorDialogBuilder(getString(messageId)) } fun Context.errorDialogBuilder(message: String): MaterialAlertDialogBuilder { + val contentView = buildSelectableMessageView(message) return MaterialAlertDialogBuilder(this) .setTitle(R.string.error_title) - .setMessage(message) + .setView(contentView) + .setNeutralButton(R.string.per_app_proxy_action_copy) { _, _ -> + copyToClipboard(message) + } .setPositiveButton(android.R.string.ok, null) } fun Context.errorDialogBuilder(exception: Throwable): MaterialAlertDialogBuilder { return errorDialogBuilder(exception.localizedMessage ?: exception.toString()) } + +private fun Context.buildSelectableMessageView(message: String): ScrollView { + val density = resources.displayMetrics.density + val padding = (16 * density).toInt() + val textView = + TextView(this).apply { + text = message + setTextIsSelectable(true) + setPadding(padding, padding, padding, padding) + } + return ScrollView(this).apply { + addView(textView) + } +} + +private fun Context.copyToClipboard(text: String) { + val clipboard = getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager + clipboard.setPrimaryClip(ClipData.newPlainText(getString(R.string.error_title), text)) + Toast.makeText(this, getString(R.string.copied_to_clipboard), Toast.LENGTH_SHORT).show() +} diff --git a/app/src/main/java/io/nekohasekai/sfa/ui/profileoverride/PerAppProxyActivity.kt b/app/src/main/java/io/nekohasekai/sfa/ui/profileoverride/PerAppProxyActivity.kt deleted file mode 100644 index 6519517..0000000 --- a/app/src/main/java/io/nekohasekai/sfa/ui/profileoverride/PerAppProxyActivity.kt +++ /dev/null @@ -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() { - 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() - private var displayPackages = listOf() - private var currentPackages = listOf() - private var selectedUIDs = mutableSetOf() - - 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() - 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() - 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 = this.selectedUIDs) { - val displayPackages = mutableListOf() - 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 { - !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) : - RecyclerView.Adapter() { - @SuppressLint("NotifyDataSetChanged") - fun setApplicationList(applicationList: List) { - 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, - ) { - 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() - 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() - 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() - 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().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) { - 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 - } - } -} diff --git a/app/src/main/java/io/nekohasekai/sfa/ui/shared/AbstractActivity.kt b/app/src/main/java/io/nekohasekai/sfa/ui/shared/AbstractActivity.kt deleted file mode 100644 index 519cc55..0000000 --- a/app/src/main/java/io/nekohasekai/sfa/ui/shared/AbstractActivity.kt +++ /dev/null @@ -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 : 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(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 - val method = vbClass.getMethod("inflate", LayoutInflater::class.java) - return method.invoke(null, inflater) as Binding - } -} diff --git a/app/src/main/java/io/nekohasekai/sfa/utils/ConnectivityBinderUtils.kt b/app/src/main/java/io/nekohasekai/sfa/utils/ConnectivityBinderUtils.kt new file mode 100644 index 0000000..38396f3 --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sfa/utils/ConnectivityBinderUtils.kt @@ -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 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() + } + } +} diff --git a/app/src/main/java/io/nekohasekai/sfa/utils/HookErrorClient.kt b/app/src/main/java/io/nekohasekai/sfa/utils/HookErrorClient.kt new file mode 100644 index 0000000..d58161f --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sfa/utils/HookErrorClient.kt @@ -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, + 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, hasWarnings = hasWarnings) + } + } +} diff --git a/app/src/main/java/io/nekohasekai/sfa/utils/HookModuleUpdateNotifier.kt b/app/src/main/java/io/nekohasekai/sfa/utils/HookModuleUpdateNotifier.kt new file mode 100644 index 0000000..90f0239 --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sfa/utils/HookModuleUpdateNotifier.kt @@ -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, + ), + ) + } + } +} diff --git a/app/src/main/java/io/nekohasekai/sfa/utils/HookStatusClient.kt b/app/src/main/java/io/nekohasekai/sfa/utils/HookStatusClient.kt new file mode 100644 index 0000000..da7a0da --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sfa/utils/HookStatusClient.kt @@ -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(null) + val status: StateFlow = 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(), + ) + } + } +} diff --git a/app/src/main/java/io/nekohasekai/sfa/utils/PrivilegeSettingsClient.kt b/app/src/main/java/io/nekohasekai/sfa/utils/PrivilegeSettingsClient.kt new file mode 100644 index 0000000..fee1744 --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sfa/utils/PrivilegeSettingsClient.kt @@ -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 + } +} diff --git a/app/src/main/java/io/nekohasekai/sfa/utils/VpnDetectionTest.kt b/app/src/main/java/io/nekohasekai/sfa/utils/VpnDetectionTest.kt new file mode 100644 index 0000000..f210ed1 --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sfa/utils/VpnDetectionTest.kt @@ -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, + val nativeDetected: Boolean, + val frameworkInterfaces: List, + val nativeInterfaces: List, + val httpProxy: String?, +) + +object VpnDetectionTest { + + fun runDetection(context: Context): DetectionResult { + val frameworkDetected = LinkedHashSet() + val frameworkInterfaces = LinkedHashSet() + + 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 { + val list = try { + Collections.list(NetworkInterface.getNetworkInterfaces()) + } catch (_: Throwable) { + return emptyList() + } + val matches = ArrayList() + 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 + } + } +} diff --git a/app/src/main/java/io/nekohasekai/sfa/vendor/PackageQueryStrategy.kt b/app/src/main/java/io/nekohasekai/sfa/vendor/PackageQueryStrategy.kt new file mode 100644 index 0000000..01e7ba2 --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sfa/vendor/PackageQueryStrategy.kt @@ -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() +} diff --git a/app/src/main/java/io/nekohasekai/sfa/vendor/PrivilegedServiceUtils.kt b/app/src/main/java/io/nekohasekai/sfa/vendor/PrivilegedServiceUtils.kt new file mode 100644 index 0000000..78ab528 --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sfa/vendor/PrivilegedServiceUtils.kt @@ -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 { + 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(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 { + if (parceledListSlice == null) return emptyList() + val getListMethod = parceledListSlice.javaClass.getMethod("getList") + val list = getListMethod.invoke(parceledListSlice) as? List<*> + return list?.filterIsInstance() ?: emptyList() + } +} diff --git a/app/src/main/java/io/nekohasekai/sfa/vendor/SystemServiceHelperCompat.kt b/app/src/main/java/io/nekohasekai/sfa/vendor/SystemServiceHelperCompat.kt new file mode 100644 index 0000000..f6a9db9 --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sfa/vendor/SystemServiceHelperCompat.kt @@ -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() + 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 + } +} diff --git a/app/src/main/java/io/nekohasekai/sfa/vendor/VendorInterface.kt b/app/src/main/java/io/nekohasekai/sfa/vendor/VendorInterface.kt index 81c1437..2c1b2d9 100644 --- a/app/src/main/java/io/nekohasekai/sfa/vendor/VendorInterface.kt +++ b/app/src/main/java/io/nekohasekai/sfa/vendor/VendorInterface.kt @@ -2,7 +2,7 @@ package io.nekohasekai.sfa.vendor import android.app.Activity import androidx.camera.core.ImageAnalysis -import io.nekohasekai.sfa.ui.profile.QRCodeCropArea +import io.nekohasekai.sfa.compose.screen.qrscan.QRCodeCropArea import io.nekohasekai.sfa.update.UpdateInfo interface VendorInterface { @@ -35,6 +35,12 @@ interface VendorInterface { */ fun checkUpdateAsync(): UpdateInfo? = null + /** + * Force get latest update (ignores version check) + * @return UpdateInfo of the latest release, null if unavailable + */ + fun forceGetLatestUpdate(): UpdateInfo? = null + /** * Check if silent install feature is available * @return true if silent install is supported (Other flavor only) @@ -63,8 +69,8 @@ interface VendorInterface { * Download and install an APK update * @param context The context * @param downloadUrl The URL to download the APK from - * @return Result indicating success or failure + * @throws Exception if download or install fails */ - suspend fun downloadAndInstall(context: android.content.Context, downloadUrl: String): Result = - Result.failure(UnsupportedOperationException("Not supported in this flavor")) + suspend fun downloadAndInstall(context: android.content.Context, downloadUrl: String): Unit = + throw UnsupportedOperationException("Not supported in this flavor") } diff --git a/app/src/main/java/io/nekohasekai/sfa/xposed/HookErrorStore.kt b/app/src/main/java/io/nekohasekai/sfa/xposed/HookErrorStore.kt new file mode 100644 index 0000000..50475c7 --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sfa/xposed/HookErrorStore.kt @@ -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() + + 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 { + synchronized(lock) { + return entries.toList() + } + } + + fun hasWarnings(): Boolean { + synchronized(lock) { + return entries.any { it.level >= LogEntry.LEVEL_WARN } + } + } + + fun clear() { + synchronized(lock) { + entries.clear() + } + } +} diff --git a/app/src/main/java/io/nekohasekai/sfa/xposed/HookModuleVersion.kt b/app/src/main/java/io/nekohasekai/sfa/xposed/HookModuleVersion.kt new file mode 100644 index 0000000..a72fdae --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sfa/xposed/HookModuleVersion.kt @@ -0,0 +1,5 @@ +package io.nekohasekai.sfa.xposed + +object HookModuleVersion { + const val CURRENT = 2 +} diff --git a/app/src/main/java/io/nekohasekai/sfa/xposed/HookStatusKeys.kt b/app/src/main/java/io/nekohasekai/sfa/xposed/HookStatusKeys.kt new file mode 100644 index 0000000..31c337e --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sfa/xposed/HookStatusKeys.kt @@ -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 +} diff --git a/app/src/main/java/io/nekohasekai/sfa/xposed/HookStatusStore.kt b/app/src/main/java/io/nekohasekai/sfa/xposed/HookStatusStore.kt new file mode 100644 index 0000000..fc0624a --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sfa/xposed/HookStatusStore.kt @@ -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, + ) +} diff --git a/app/src/main/java/io/nekohasekai/sfa/xposed/PrivilegeChecker.kt b/app/src/main/java/io/nekohasekai/sfa/xposed/PrivilegeChecker.kt new file mode 100644 index 0000000..bcf688f --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sfa/xposed/PrivilegeChecker.kt @@ -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() + private val exemptCache = ConcurrentHashMap() + private val privilegedCache = ConcurrentHashMap() + + 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 { + 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() + is List<*> -> result.filterIsInstance() + 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 + } + } + } +} diff --git a/app/src/main/java/io/nekohasekai/sfa/xposed/PrivilegeSettingsStore.kt b/app/src/main/java/io/nekohasekai/sfa/xposed/PrivilegeSettingsStore.kt new file mode 100644 index 0000000..6f3ebcd --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sfa/xposed/PrivilegeSettingsStore.kt @@ -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 = emptySet() + @Volatile + private var interfaceRenameEnabled = false + @Volatile + private var interfacePrefix = "en" + private val uidCache = ConcurrentHashMap() + + fun update( + enabled: Boolean, + packages: Set, + 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 { + 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() + is List<*> -> result.filterIsInstance() + 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 + } + } +} diff --git a/app/src/main/java/io/nekohasekai/sfa/xposed/VpnAppStore.kt b/app/src/main/java/io/nekohasekai/sfa/xposed/VpnAppStore.kt new file mode 100644 index 0000000..df90f2f --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sfa/xposed/VpnAppStore.kt @@ -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(val atMs: Long, val value: T) + + private val vpnPackagesByUser = ConcurrentHashMap>>() + private val uidVpnCache = ConcurrentHashMap>() + private val uidPackagesCache = ConcurrentHashMap>>() + + 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 { + 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() + try { + val method = pm.javaClass.getMethod("getPackagesForUid", Int::class.javaPrimitiveType) + when (val raw = method.invoke(pm, uid)) { + is Array<*> -> raw.filterIsInstance() + is List<*> -> raw.filterIsInstance() + 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 { + 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 { + 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() + 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 { + 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 binderLocalScope(block: () -> T): T { + val token = Binder.clearCallingIdentity() + return try { + block() + } finally { + Binder.restoreCallingIdentity(token) + } + } + + private fun unwrapParceledListSlice(raw: Any?): List { + if (raw == null) return emptyList() + if (raw is List<*>) { + return raw.filterIsInstance() + } + return try { + val method = raw.javaClass.getMethod("getList") + val list = method.invoke(raw) + if (list is List<*>) { + list.filterIsInstance() + } else { + emptyList() + } + } catch (_: Throwable) { + emptyList() + } + } +} diff --git a/app/src/main/java/io/nekohasekai/sfa/xposed/VpnHideContext.kt b/app/src/main/java/io/nekohasekai/sfa/xposed/VpnHideContext.kt new file mode 100644 index 0000000..50ffa96 --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sfa/xposed/VpnHideContext.kt @@ -0,0 +1,19 @@ +package io.nekohasekai.sfa.xposed + +object VpnHideContext { + private val targetUid = ThreadLocal() + + fun setTargetUid(uid: Int) { + targetUid.set(uid) + } + + fun consumeTargetUid(): Int? { + val value = targetUid.get() + targetUid.remove() + return value + } + + fun clear() { + targetUid.remove() + } +} diff --git a/app/src/main/java/io/nekohasekai/sfa/xposed/VpnSanitizer.kt b/app/src/main/java/io/nekohasekai/sfa/xposed/VpnSanitizer.kt new file mode 100644 index 0000000..fa861d5 --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sfa/xposed/VpnSanitizer.kt @@ -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 + 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 + ?: 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() + } + } +} diff --git a/app/src/main/java/io/nekohasekai/sfa/xposed/XposedActivation.kt b/app/src/main/java/io/nekohasekai/sfa/xposed/XposedActivation.kt new file mode 100644 index 0000000..ab4a2f0 --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sfa/xposed/XposedActivation.kt @@ -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) { + 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() + } +} diff --git a/app/src/main/java/io/nekohasekai/sfa/xposed/XposedInit.kt b/app/src/main/java/io/nekohasekai/sfa/xposed/XposedInit.kt new file mode 100644 index 0000000..e150815 --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sfa/xposed/XposedInit.kt @@ -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 + } + } +} diff --git a/app/src/main/java/io/nekohasekai/sfa/xposed/hooks/IConnectivityManager+onTransact.kt b/app/src/main/java/io/nekohasekai/sfa/xposed/hooks/IConnectivityManager+onTransact.kt new file mode 100644 index 0000000..8581832 --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sfa/xposed/hooks/IConnectivityManager+onTransact.kt @@ -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() + 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 + } + } +} diff --git a/app/src/main/java/io/nekohasekai/sfa/xposed/hooks/SafeMethodHook.kt b/app/src/main/java/io/nekohasekai/sfa/xposed/hooks/SafeMethodHook.kt new file mode 100644 index 0000000..a62533a --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sfa/xposed/hooks/SafeMethodHook.kt @@ -0,0 +1,32 @@ +package io.nekohasekai.sfa.xposed.hooks + +import de.robv.android.xposed.XC_MethodHook +import io.nekohasekai.sfa.xposed.HookErrorStore + +abstract class SafeMethodHook(private val source: String) : XC_MethodHook() { + @Volatile + private var disabled = false + + final override fun beforeHookedMethod(param: MethodHookParam) { + if (disabled) return + try { + beforeHook(param) + } catch (e: Throwable) { + disabled = true + HookErrorStore.e(source, "Hook disabled due to unrecoverable error", e) + } + } + + final override fun afterHookedMethod(param: MethodHookParam) { + if (disabled) return + try { + afterHook(param) + } catch (e: Throwable) { + disabled = true + HookErrorStore.e(source, "Hook disabled due to unrecoverable error", e) + } + } + + protected open fun beforeHook(param: MethodHookParam) {} + protected open fun afterHook(param: MethodHookParam) {} +} diff --git a/app/src/main/java/io/nekohasekai/sfa/xposed/hooks/XHook.kt b/app/src/main/java/io/nekohasekai/sfa/xposed/hooks/XHook.kt new file mode 100644 index 0000000..f5d7619 --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sfa/xposed/hooks/XHook.kt @@ -0,0 +1,5 @@ +package io.nekohasekai.sfa.xposed.hooks + +interface XHook { + fun injectHook() +} diff --git a/app/src/main/java/io/nekohasekai/sfa/xposed/hooks/hidevpn/ConnectivityManager+CONNECTIVITY_ACTION.kt b/app/src/main/java/io/nekohasekai/sfa/xposed/hooks/hidevpn/ConnectivityManager+CONNECTIVITY_ACTION.kt new file mode 100644 index 0000000..38e7cd2 --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sfa/xposed/hooks/hidevpn/ConnectivityManager+CONNECTIVITY_ACTION.kt @@ -0,0 +1,36 @@ +package io.nekohasekai.sfa.xposed.hooks.hidevpn + +import android.net.ConnectivityManager +import android.net.NetworkInfo +import de.robv.android.xposed.XposedHelpers +import io.nekohasekai.sfa.xposed.VpnSanitizer +import io.nekohasekai.sfa.xposed.hooks.SafeMethodHook + +class HookConnectivityManagerConnectivityAction(private val helper: ConnectivityServiceHookHelper) { + private companion object { + private const val SOURCE = "HookConnectivityManagerConnectivityAction" + } + + fun install() { + XposedHelpers.findAndHookMethod( + helper.cls, + "sendGeneralBroadcast", + NetworkInfo::class.java, + String::class.java, + object : SafeMethodHook(SOURCE) { + override fun beforeHook(param: MethodHookParam) { + val info = param.args[0] as? NetworkInfo ?: return + if (info.type != ConnectivityManager.TYPE_VPN) return + val defaultNai = XposedHelpers.callMethod(param.thisObject, "getDefaultNetwork") + ?: return + if (helper.isVpnNai(defaultNai)) { + return + } + val replacement = XposedHelpers.getObjectField(defaultNai, "networkInfo") as? NetworkInfo + ?: return + param.args[0] = VpnSanitizer.cloneNetworkInfo(replacement) + } + } + ) + } +} diff --git a/app/src/main/java/io/nekohasekai/sfa/xposed/hooks/hidevpn/ConnectivityManager+PROXY_CHANGE_ACTION.kt b/app/src/main/java/io/nekohasekai/sfa/xposed/hooks/hidevpn/ConnectivityManager+PROXY_CHANGE_ACTION.kt new file mode 100644 index 0000000..d43422b --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sfa/xposed/hooks/hidevpn/ConnectivityManager+PROXY_CHANGE_ACTION.kt @@ -0,0 +1,85 @@ +package io.nekohasekai.sfa.xposed.hooks.hidevpn + +import android.content.Context +import android.content.Intent +import android.net.Proxy +import android.net.ProxyInfo +import android.os.Binder +import android.os.UserHandle +import de.robv.android.xposed.XposedHelpers +import io.nekohasekai.sfa.xposed.HookErrorStore +import io.nekohasekai.sfa.xposed.hooks.SafeMethodHook + +class HookConnectivityManagerProxyChangeAction(private val helper: ConnectivityServiceHookHelper) { + private companion object { + private const val SOURCE = "HookProxyChangeAction" + } + + fun install() { + if (helper.sdkInt >= 29) { + try { + hookProxyBroadcastTracker() + return + } catch (e: Throwable) { + HookErrorStore.w(SOURCE, "hookProxyBroadcastTracker failed: ${e.message}", e) + } + } + + try { + hookLegacyProxyBroadcast() + } catch (e: Throwable) { + HookErrorStore.w(SOURCE, "hookLegacyProxyBroadcast failed: ${e.message}", e) + } + } + + private fun hookProxyBroadcastTracker() { + val trackerClass = helper.resolveConnectivityModuleClass("ProxyTracker", "connectivity") + XposedHelpers.findAndHookMethod( + trackerClass, + "sendProxyBroadcast", + object : SafeMethodHook(SOURCE) { + override fun beforeHook(param: MethodHookParam) { + val tracker = param.thisObject ?: return + val context = XposedHelpers.getObjectField(tracker, "mContext") as Context + val proxyInfo = emptyProxyInfo() + val intent = Intent(Proxy.PROXY_CHANGE_ACTION) + intent.addFlags(Intent.FLAG_RECEIVER_REPLACE_PENDING) + intent.putExtra("android.intent.extra.PROXY_INFO", proxyInfo) + val ident = Binder.clearCallingIdentity() + try { + val userAll = try { + UserHandle::class.java.getField("ALL").get(null) as? UserHandle + } catch (_: Throwable) { + null + } + if (userAll != null) { + context.sendStickyBroadcastAsUser(intent, userAll) + } else { + context.sendStickyBroadcast(intent) + } + } finally { + Binder.restoreCallingIdentity(ident) + } + param.result = null + } + } + ) + } + + private fun hookLegacyProxyBroadcast() { + XposedHelpers.findAndHookMethod( + helper.cls, + "sendProxyBroadcast", + ProxyInfo::class.java, + object : SafeMethodHook(SOURCE) { + override fun beforeHook(param: MethodHookParam) { + param.args[0] = emptyProxyInfo() + } + } + ) + } + + private fun emptyProxyInfo(): ProxyInfo { + return ProxyInfo.buildDirectProxy("", 0) + } +} diff --git a/app/src/main/java/io/nekohasekai/sfa/xposed/hooks/hidevpn/ConnectivityManager+getActiveNetwork.kt b/app/src/main/java/io/nekohasekai/sfa/xposed/hooks/hidevpn/ConnectivityManager+getActiveNetwork.kt new file mode 100644 index 0000000..c29851b --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sfa/xposed/hooks/hidevpn/ConnectivityManager+getActiveNetwork.kt @@ -0,0 +1,41 @@ +package io.nekohasekai.sfa.xposed.hooks.hidevpn + +import android.os.Binder +import de.robv.android.xposed.XposedHelpers +import io.nekohasekai.sfa.xposed.hooks.SafeMethodHook + +class HookConnectivityManagerGetActiveNetwork(private val helper: ConnectivityServiceHookHelper) { + private companion object { + private const val SOURCE = "HookConnectivityManagerGetActiveNetwork" + } + + fun install() { + XposedHelpers.findAndHookMethod( + helper.cls, + "getActiveNetwork", + object : SafeMethodHook(SOURCE) { + override fun afterHook(param: MethodHookParam) { + val uid = Binder.getCallingUid() + if (!helper.shouldHide(param.thisObject, uid)) return + val replacement = helper.getUnderlyingNetwork(param.thisObject, uid) ?: return + param.result = replacement + } + } + ) + + XposedHelpers.findAndHookMethod( + helper.cls, + "getActiveNetworkForUid", + Int::class.javaPrimitiveType, + Boolean::class.javaPrimitiveType, + object : SafeMethodHook(SOURCE) { + override fun afterHook(param: MethodHookParam) { + val uid = param.args[0] as Int + if (!helper.shouldHide(param.thisObject, uid)) return + val replacement = helper.getUnderlyingNetwork(param.thisObject, uid) ?: return + param.result = replacement + } + } + ) + } +} diff --git a/app/src/main/java/io/nekohasekai/sfa/xposed/hooks/hidevpn/ConnectivityManager+getActiveNetworkInfo.kt b/app/src/main/java/io/nekohasekai/sfa/xposed/hooks/hidevpn/ConnectivityManager+getActiveNetworkInfo.kt new file mode 100644 index 0000000..cf478a3 --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sfa/xposed/hooks/hidevpn/ConnectivityManager+getActiveNetworkInfo.kt @@ -0,0 +1,51 @@ +package io.nekohasekai.sfa.xposed.hooks.hidevpn + +import android.net.ConnectivityManager +import android.net.NetworkInfo +import android.os.Binder +import de.robv.android.xposed.XposedHelpers +import io.nekohasekai.sfa.xposed.hooks.SafeMethodHook + +class HookConnectivityManagerGetActiveNetworkInfo(private val helper: ConnectivityServiceHookHelper) { + private companion object { + private const val SOURCE = "HookConnectivityManagerGetActiveNetworkInfo" + } + + fun install() { + XposedHelpers.findAndHookMethod( + helper.cls, + "getActiveNetworkInfo", + object : SafeMethodHook(SOURCE) { + override fun afterHook(param: MethodHookParam) { + val uid = Binder.getCallingUid() + if (!helper.shouldHide(param.thisObject, uid)) return + val info = param.result as? NetworkInfo ?: return + if (info.type != ConnectivityManager.TYPE_VPN) return + val replacement = helper.getUnderlyingNetworkInfo(param.thisObject, uid) + if (replacement != null) { + param.result = replacement + } + } + } + ) + + XposedHelpers.findAndHookMethod( + helper.cls, + "getActiveNetworkInfoForUid", + Int::class.javaPrimitiveType, + Boolean::class.javaPrimitiveType, + object : SafeMethodHook(SOURCE) { + override fun afterHook(param: MethodHookParam) { + val uid = param.args[0] as Int + if (!helper.shouldHide(param.thisObject, uid)) return + val info = param.result as? NetworkInfo ?: return + if (info.type != ConnectivityManager.TYPE_VPN) return + val replacement = helper.getUnderlyingNetworkInfo(param.thisObject, uid) + if (replacement != null) { + param.result = replacement + } + } + } + ) + } +} diff --git a/app/src/main/java/io/nekohasekai/sfa/xposed/hooks/hidevpn/ConnectivityManager+getAllNetworkInfo.kt b/app/src/main/java/io/nekohasekai/sfa/xposed/hooks/hidevpn/ConnectivityManager+getAllNetworkInfo.kt new file mode 100644 index 0000000..58c2f5f --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sfa/xposed/hooks/hidevpn/ConnectivityManager+getAllNetworkInfo.kt @@ -0,0 +1,30 @@ +package io.nekohasekai.sfa.xposed.hooks.hidevpn + +import android.net.ConnectivityManager +import android.net.NetworkInfo +import android.os.Binder +import de.robv.android.xposed.XposedHelpers +import io.nekohasekai.sfa.xposed.hooks.SafeMethodHook + +class HookConnectivityManagerGetAllNetworkInfo(private val helper: ConnectivityServiceHookHelper) { + private companion object { + private const val SOURCE = "HookConnectivityManagerGetAllNetworkInfo" + } + + fun install() { + XposedHelpers.findAndHookMethod( + helper.cls, + "getAllNetworkInfo", + object : SafeMethodHook(SOURCE) { + override fun afterHook(param: MethodHookParam) { + val uid = Binder.getCallingUid() + if (!helper.shouldHide(param.thisObject, uid)) return + @Suppress("UNCHECKED_CAST") + val infos = param.result as? Array ?: return + val filtered = infos.filter { it.type != ConnectivityManager.TYPE_VPN } + param.result = filtered.toTypedArray() + } + } + ) + } +} diff --git a/app/src/main/java/io/nekohasekai/sfa/xposed/hooks/hidevpn/ConnectivityManager+getAllNetworks.kt b/app/src/main/java/io/nekohasekai/sfa/xposed/hooks/hidevpn/ConnectivityManager+getAllNetworks.kt new file mode 100644 index 0000000..309906b --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sfa/xposed/hooks/hidevpn/ConnectivityManager+getAllNetworks.kt @@ -0,0 +1,29 @@ +package io.nekohasekai.sfa.xposed.hooks.hidevpn + +import android.net.Network +import android.os.Binder +import de.robv.android.xposed.XposedHelpers +import io.nekohasekai.sfa.xposed.hooks.SafeMethodHook + +class HookConnectivityManagerGetAllNetworks(private val helper: ConnectivityServiceHookHelper) { + private companion object { + private const val SOURCE = "HookConnectivityManagerGetAllNetworks" + } + + fun install() { + XposedHelpers.findAndHookMethod( + helper.cls, + "getAllNetworks", + object : SafeMethodHook(SOURCE) { + override fun afterHook(param: MethodHookParam) { + val uid = Binder.getCallingUid() + if (!helper.shouldHide(param.thisObject, uid)) return + @Suppress("UNCHECKED_CAST") + val networks = param.result as? Array ?: return + val filtered = networks.filter { !helper.isVpnNetwork(param.thisObject, it) } + param.result = filtered.toTypedArray() + } + } + ) + } +} diff --git a/app/src/main/java/io/nekohasekai/sfa/xposed/hooks/hidevpn/ConnectivityManager+getDefaultProxy.kt b/app/src/main/java/io/nekohasekai/sfa/xposed/hooks/hidevpn/ConnectivityManager+getDefaultProxy.kt new file mode 100644 index 0000000..1025381 --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sfa/xposed/hooks/hidevpn/ConnectivityManager+getDefaultProxy.kt @@ -0,0 +1,43 @@ +package io.nekohasekai.sfa.xposed.hooks.hidevpn + +import android.net.Network +import android.net.ProxyInfo +import android.os.Binder +import de.robv.android.xposed.XposedHelpers +import io.nekohasekai.sfa.xposed.VpnSanitizer +import io.nekohasekai.sfa.xposed.hooks.SafeMethodHook + +class HookConnectivityManagerGetDefaultProxy(private val helper: ConnectivityServiceHookHelper) { + private companion object { + private const val SOURCE = "HookConnectivityManagerGetDefaultProxy" + } + + fun install() { + XposedHelpers.findAndHookMethod( + helper.cls, + "getProxyForNetwork", + Network::class.java, + object : SafeMethodHook(SOURCE) { + override fun afterHook(param: MethodHookParam) { + val uid = Binder.getCallingUid() + if (!VpnSanitizer.shouldHide(uid)) return + param.result as? ProxyInfo ?: return + param.result = null + } + } + ) + + XposedHelpers.findAndHookMethod( + helper.cls, + "getGlobalProxy", + object : SafeMethodHook(SOURCE) { + override fun afterHook(param: MethodHookParam) { + val uid = Binder.getCallingUid() + if (!VpnSanitizer.shouldHide(uid)) return + param.result as? ProxyInfo ?: return + param.result = null + } + } + ) + } +} diff --git a/app/src/main/java/io/nekohasekai/sfa/xposed/hooks/hidevpn/ConnectivityManager+getLinkProperties.kt b/app/src/main/java/io/nekohasekai/sfa/xposed/hooks/hidevpn/ConnectivityManager+getLinkProperties.kt new file mode 100644 index 0000000..5e4f8ad --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sfa/xposed/hooks/hidevpn/ConnectivityManager+getLinkProperties.kt @@ -0,0 +1,94 @@ +package io.nekohasekai.sfa.xposed.hooks.hidevpn + +import android.net.ConnectivityManager +import android.net.LinkProperties +import android.net.Network +import android.os.Binder +import de.robv.android.xposed.XposedHelpers +import io.nekohasekai.sfa.xposed.HookErrorStore +import io.nekohasekai.sfa.xposed.VpnSanitizer +import io.nekohasekai.sfa.xposed.hooks.SafeMethodHook + +class HookConnectivityManagerGetLinkProperties(private val helper: ConnectivityServiceHookHelper) { + private companion object { + private const val SOURCE = "HookGetLinkProperties" + } + + fun install() { + if (helper.sdkInt >= 30) { + try { + hookLinkPropertiesRestricted() + } catch (e: Throwable) { + HookErrorStore.w(SOURCE, "hookLinkPropertiesRestricted failed: ${e.message}", e) + } + } + + try { + hookGetLinkProperties() + } catch (e: Throwable) { + HookErrorStore.w(SOURCE, "hookGetLinkProperties failed: ${e.message}", e) + } + + try { + hookGetLinkPropertiesForType() + } catch (e: Throwable) { + HookErrorStore.w(SOURCE, "hookGetLinkPropertiesForType failed: ${e.message}", e) + } + } + + private fun hookLinkPropertiesRestricted() { + XposedHelpers.findAndHookMethod( + helper.cls, + "linkPropertiesRestrictedForCallerPermissions", + LinkProperties::class.java, + Int::class.javaPrimitiveType, + Int::class.javaPrimitiveType, + object : SafeMethodHook(SOURCE) { + override fun afterHook(param: MethodHookParam) { + val callerUid = param.args[2] as Int + val lp = param.result as? LinkProperties ?: return + if (!VpnSanitizer.hasVpnInterface(lp)) return + if (!VpnSanitizer.shouldHide(callerUid)) return + val underlying = helper.getUnderlyingLinkProperties(param.thisObject, callerUid) + param.result = underlying ?: VpnSanitizer.sanitizeLinkProperties(lp) + } + } + ) + } + + private fun hookGetLinkProperties() { + XposedHelpers.findAndHookMethod( + helper.cls, + "getLinkProperties", + Network::class.java, + object : SafeMethodHook(SOURCE) { + override fun afterHook(param: MethodHookParam) { + val uid = Binder.getCallingUid() + if (!helper.shouldHide(param.thisObject, uid)) return + val lp = param.result as? LinkProperties ?: return + if (!VpnSanitizer.hasVpnInterface(lp)) return + val underlying = helper.getUnderlyingLinkProperties(param.thisObject, uid) + param.result = underlying ?: VpnSanitizer.sanitizeLinkProperties(lp) + } + } + ) + } + + private fun hookGetLinkPropertiesForType() { + XposedHelpers.findAndHookMethod( + helper.cls, + "getLinkPropertiesForType", + Int::class.javaPrimitiveType, + object : SafeMethodHook(SOURCE) { + override fun afterHook(param: MethodHookParam) { + val uid = Binder.getCallingUid() + if (!helper.shouldHide(param.thisObject, uid)) return + val networkType = param.args[0] as Int + if (networkType == ConnectivityManager.TYPE_VPN) { + param.result = null + } + } + } + ) + } +} diff --git a/app/src/main/java/io/nekohasekai/sfa/xposed/hooks/hidevpn/ConnectivityManager+getNetworkCapabilities.kt b/app/src/main/java/io/nekohasekai/sfa/xposed/hooks/hidevpn/ConnectivityManager+getNetworkCapabilities.kt new file mode 100644 index 0000000..66cdf1c --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sfa/xposed/hooks/hidevpn/ConnectivityManager+getNetworkCapabilities.kt @@ -0,0 +1,161 @@ +package io.nekohasekai.sfa.xposed.hooks.hidevpn + +import android.net.Network +import android.net.NetworkCapabilities +import android.os.Binder +import de.robv.android.xposed.XposedHelpers +import io.nekohasekai.sfa.xposed.HookErrorStore +import io.nekohasekai.sfa.xposed.VpnSanitizer +import io.nekohasekai.sfa.xposed.hooks.SafeMethodHook + +class HookConnectivityManagerGetNetworkCapabilities(private val helper: ConnectivityServiceHookHelper) { + private companion object { + private const val SOURCE = "HookGetNetworkCapabilities" + } + + fun install() { + // Hook networkCapabilitiesRestrictedForCallerPermissions (API 28+) + if (helper.sdkInt >= 28) { + try { + hookNetworkCapabilitiesRestricted() + } catch (e: Throwable) { + HookErrorStore.w(SOURCE, "hookNetworkCapabilitiesRestricted failed: ${e.message}", e) + } + } + + // Hook getNetworkCapabilities based on API level + when { + helper.sdkInt >= 31 -> { + try { + hookGetNetworkCapabilitiesV12() + } catch (e: Throwable) { + HookErrorStore.w(SOURCE, "hookGetNetworkCapabilitiesV12 failed: ${e.message}", e) + try { + hookGetNetworkCapabilitiesV11() + } catch (e2: Throwable) { + HookErrorStore.e(SOURCE, "hookGetNetworkCapabilitiesV11 failed: ${e2.message}", e2) + } + } + } + helper.sdkInt >= 30 -> { + try { + hookGetNetworkCapabilitiesV11() + } catch (e: Throwable) { + HookErrorStore.w(SOURCE, "hookGetNetworkCapabilitiesV11 failed: ${e.message}", e) + try { + hookGetNetworkCapabilitiesV8() + } catch (e2: Throwable) { + HookErrorStore.e(SOURCE, "hookGetNetworkCapabilitiesV8 failed: ${e2.message}", e2) + } + } + } + else -> { + try { + hookGetNetworkCapabilitiesV8() + } catch (e: Throwable) { + HookErrorStore.e(SOURCE, "hookGetNetworkCapabilitiesV8 failed: ${e.message}", e) + } + } + } + + // Hook createWithLocationInfoSanitizedIfNecessaryWhenParceled (API 31+) + if (helper.sdkInt >= 31) { + try { + hookCreateWithLocationInfoSanitized() + } catch (e: Throwable) { + HookErrorStore.w(SOURCE, "hookCreateWithLocationInfoSanitized failed: ${e.message}", e) + } + } + } + + private fun hookNetworkCapabilitiesRestricted() { + XposedHelpers.findAndHookMethod( + helper.cls, + "networkCapabilitiesRestrictedForCallerPermissions", + NetworkCapabilities::class.java, + Int::class.javaPrimitiveType, + Int::class.javaPrimitiveType, + object : SafeMethodHook(SOURCE) { + override fun afterHook(param: MethodHookParam) { + val callerUid = param.args[2] as Int + val nc = param.result as? NetworkCapabilities ?: return + if (!nc.hasTransport(NetworkCapabilities.TRANSPORT_VPN)) return + if (!VpnSanitizer.shouldHide(callerUid)) return + param.result = VpnSanitizer.sanitizeNetworkCapabilities(nc) + } + } + ) + } + + private fun hookGetNetworkCapabilitiesV8() { + XposedHelpers.findAndHookMethod( + helper.cls, + "getNetworkCapabilities", + Network::class.java, + object : SafeMethodHook(SOURCE) { + override fun afterHook(param: MethodHookParam) { + sanitizeNetworkCapabilitiesResult(param) + } + } + ) + } + + private fun hookGetNetworkCapabilitiesV11() { + XposedHelpers.findAndHookMethod( + helper.cls, + "getNetworkCapabilities", + Network::class.java, + String::class.java, + object : SafeMethodHook(SOURCE) { + override fun afterHook(param: MethodHookParam) { + sanitizeNetworkCapabilitiesResult(param) + } + } + ) + } + + private fun hookGetNetworkCapabilitiesV12() { + XposedHelpers.findAndHookMethod( + helper.cls, + "getNetworkCapabilities", + Network::class.java, + String::class.java, + String::class.java, + object : SafeMethodHook(SOURCE) { + override fun afterHook(param: MethodHookParam) { + sanitizeNetworkCapabilitiesResult(param) + } + } + ) + } + + private fun sanitizeNetworkCapabilitiesResult(param: de.robv.android.xposed.XC_MethodHook.MethodHookParam) { + val uid = Binder.getCallingUid() + if (!helper.shouldHide(param.thisObject, uid)) return + val nc = param.result as? NetworkCapabilities ?: return + if (!nc.hasTransport(NetworkCapabilities.TRANSPORT_VPN)) return + param.result = VpnSanitizer.sanitizeNetworkCapabilities(nc) + } + + private fun hookCreateWithLocationInfoSanitized() { + XposedHelpers.findAndHookMethod( + helper.cls, + "createWithLocationInfoSanitizedIfNecessaryWhenParceled", + NetworkCapabilities::class.java, + Boolean::class.javaPrimitiveType, + Int::class.javaPrimitiveType, + Int::class.javaPrimitiveType, + String::class.java, + String::class.java, + object : SafeMethodHook(SOURCE) { + override fun afterHook(param: MethodHookParam) { + val callerUid = param.args[3] as Int + if (!helper.shouldHide(param.thisObject, callerUid)) return + val nc = param.result as? NetworkCapabilities ?: return + if (!nc.hasTransport(NetworkCapabilities.TRANSPORT_VPN)) return + param.result = VpnSanitizer.sanitizeNetworkCapabilities(nc) + } + } + ) + } +} diff --git a/app/src/main/java/io/nekohasekai/sfa/xposed/hooks/hidevpn/ConnectivityManager+getNetworkForType.kt b/app/src/main/java/io/nekohasekai/sfa/xposed/hooks/hidevpn/ConnectivityManager+getNetworkForType.kt new file mode 100644 index 0000000..f519a98 --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sfa/xposed/hooks/hidevpn/ConnectivityManager+getNetworkForType.kt @@ -0,0 +1,29 @@ +package io.nekohasekai.sfa.xposed.hooks.hidevpn + +import android.net.ConnectivityManager +import android.os.Binder +import de.robv.android.xposed.XposedHelpers +import io.nekohasekai.sfa.xposed.hooks.SafeMethodHook + +class HookConnectivityManagerGetNetworkForType(private val helper: ConnectivityServiceHookHelper) { + private companion object { + private const val SOURCE = "HookConnectivityManagerGetNetworkForType" + } + + fun install() { + XposedHelpers.findAndHookMethod( + helper.cls, + "getNetworkForType", + Int::class.javaPrimitiveType, + object : SafeMethodHook(SOURCE) { + override fun afterHook(param: MethodHookParam) { + val type = param.args[0] as Int + if (type != ConnectivityManager.TYPE_VPN) return + val uid = Binder.getCallingUid() + if (!helper.shouldHide(param.thisObject, uid)) return + param.result = null + } + } + ) + } +} diff --git a/app/src/main/java/io/nekohasekai/sfa/xposed/hooks/hidevpn/ConnectivityManager+getNetworkInfo.kt b/app/src/main/java/io/nekohasekai/sfa/xposed/hooks/hidevpn/ConnectivityManager+getNetworkInfo.kt new file mode 100644 index 0000000..9bc9234 --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sfa/xposed/hooks/hidevpn/ConnectivityManager+getNetworkInfo.kt @@ -0,0 +1,54 @@ +package io.nekohasekai.sfa.xposed.hooks.hidevpn + +import android.net.ConnectivityManager +import android.net.Network +import android.net.NetworkInfo +import android.os.Binder +import de.robv.android.xposed.XposedHelpers +import io.nekohasekai.sfa.xposed.VpnSanitizer +import io.nekohasekai.sfa.xposed.hooks.SafeMethodHook + +class HookConnectivityManagerGetNetworkInfo(private val helper: ConnectivityServiceHookHelper) { + private companion object { + private const val SOURCE = "HookConnectivityManagerGetNetworkInfo" + } + + fun install() { + XposedHelpers.findAndHookMethod( + helper.cls, + "getNetworkInfo", + Int::class.javaPrimitiveType, + object : SafeMethodHook(SOURCE) { + override fun afterHook(param: MethodHookParam) { + val type = param.args[0] as Int + if (type != ConnectivityManager.TYPE_VPN) return + val uid = Binder.getCallingUid() + if (!helper.shouldHide(param.thisObject, uid)) return + param.result = null + } + } + ) + + XposedHelpers.findAndHookMethod( + helper.cls, + "getNetworkInfoForUid", + Network::class.java, + Int::class.javaPrimitiveType, + Boolean::class.javaPrimitiveType, + object : SafeMethodHook(SOURCE) { + override fun afterHook(param: MethodHookParam) { + val uid = param.args[1] as Int + if (!helper.shouldHide(param.thisObject, uid)) return + val info = param.result as? NetworkInfo ?: return + if (info.type != ConnectivityManager.TYPE_VPN) return + val replacement = helper.getUnderlyingNetworkInfo(param.thisObject, uid) + param.result = if (replacement != null) { + VpnSanitizer.cloneNetworkInfo(replacement) + } else { + null + } + } + } + ) + } +} diff --git a/app/src/main/java/io/nekohasekai/sfa/xposed/hooks/hidevpn/ConnectivityManager+requestNetwork.kt b/app/src/main/java/io/nekohasekai/sfa/xposed/hooks/hidevpn/ConnectivityManager+requestNetwork.kt new file mode 100644 index 0000000..8277c76 --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sfa/xposed/hooks/hidevpn/ConnectivityManager+requestNetwork.kt @@ -0,0 +1,672 @@ +package io.nekohasekai.sfa.xposed.hooks.hidevpn + +import android.net.NetworkCapabilities +import android.os.Binder +import android.os.Bundle +import de.robv.android.xposed.XposedHelpers +import io.nekohasekai.sfa.xposed.HookErrorStore +import io.nekohasekai.sfa.xposed.VpnHideContext +import io.nekohasekai.sfa.xposed.VpnSanitizer +import io.nekohasekai.sfa.xposed.hooks.SafeMethodHook + +class HookConnectivityManagerRequestNetwork(private val helper: ConnectivityServiceHookHelper) { + private companion object { + private const val SOURCE = "HookRequestNetwork" + } + + fun install() { + // Hook requestNetwork based on API level + hookRequestNetwork() + + // Hook listenForNetwork based on API level + hookListenForNetwork() + + // Hook pendingRequestForNetwork + hookPendingRequestForNetwork() + + // Hook pendingListenForNetwork + hookPendingListenForNetwork() + + // Hook createDefaultNetworkCapabilitiesForUid (API 28+) + if (helper.sdkInt >= 28) { + try { + hookCreateDefaultNetworkCapabilities() + } catch (e: Throwable) { + HookErrorStore.w(SOURCE, "hookCreateDefaultNetworkCapabilities failed: ${e.message}", e) + } + } + + // Hook copyDefaultNetworkCapabilitiesForUid (API 31+) + if (helper.sdkInt >= 31) { + try { + hookCopyDefaultNetworkCapabilities() + } catch (e: Throwable) { + HookErrorStore.w(SOURCE, "hookCopyDefaultNetworkCapabilities failed: ${e.message}", e) + } + } + + // Hook callCallbackForRequest + hookCallCallbackForRequest() + + // Hook sendPendingIntentForRequest + try { + hookSendPendingIntentForRequest() + } catch (e: Throwable) { + HookErrorStore.w(SOURCE, "hookSendPendingIntentForRequest failed: ${e.message}", e) + } + } + + private fun hookRequestNetwork() { + when { + helper.sdkInt >= 36 -> { + try { + hookRequestNetworkV16() + } catch (e: Throwable) { + HookErrorStore.w(SOURCE, "hookRequestNetworkV16 failed: ${e.message}", e) + try { + hookRequestNetworkV12() + } catch (e2: Throwable) { + HookErrorStore.e(SOURCE, "hookRequestNetworkV12 failed: ${e2.message}", e2) + } + } + } + helper.sdkInt >= 31 -> { + try { + hookRequestNetworkV12() + } catch (e: Throwable) { + HookErrorStore.w(SOURCE, "hookRequestNetworkV12 failed: ${e.message}", e) + try { + hookRequestNetworkV11() + } catch (e2: Throwable) { + HookErrorStore.e(SOURCE, "hookRequestNetworkV11 failed: ${e2.message}", e2) + } + } + } + helper.sdkInt >= 30 -> { + try { + hookRequestNetworkV11() + } catch (e: Throwable) { + HookErrorStore.w(SOURCE, "hookRequestNetworkV11 failed: ${e.message}", e) + try { + hookRequestNetworkV8() + } catch (e2: Throwable) { + HookErrorStore.e(SOURCE, "hookRequestNetworkV8 failed: ${e2.message}", e2) + } + } + } + else -> { + try { + hookRequestNetworkV8() + } catch (e: Throwable) { + HookErrorStore.e(SOURCE, "hookRequestNetworkV8 failed: ${e.message}", e) + } + } + } + } + + private fun hookListenForNetwork() { + when { + helper.sdkInt >= 36 -> { + try { + hookListenForNetworkV16() + } catch (e: Throwable) { + HookErrorStore.w(SOURCE, "hookListenForNetworkV16 failed: ${e.message}", e) + try { + hookListenForNetworkV12() + } catch (e2: Throwable) { + HookErrorStore.e(SOURCE, "hookListenForNetworkV12 failed: ${e2.message}", e2) + } + } + } + helper.sdkInt >= 31 -> { + try { + hookListenForNetworkV12() + } catch (e: Throwable) { + HookErrorStore.w(SOURCE, "hookListenForNetworkV12 failed: ${e.message}", e) + try { + hookListenForNetworkV11() + } catch (e2: Throwable) { + HookErrorStore.e(SOURCE, "hookListenForNetworkV11 failed: ${e2.message}", e2) + } + } + } + helper.sdkInt >= 30 -> { + try { + hookListenForNetworkV11() + } catch (e: Throwable) { + HookErrorStore.w(SOURCE, "hookListenForNetworkV11 failed: ${e.message}", e) + try { + hookListenForNetworkV8() + } catch (e2: Throwable) { + HookErrorStore.e(SOURCE, "hookListenForNetworkV8 failed: ${e2.message}", e2) + } + } + } + else -> { + try { + hookListenForNetworkV8() + } catch (e: Throwable) { + HookErrorStore.e(SOURCE, "hookListenForNetworkV8 failed: ${e.message}", e) + } + } + } + } + + private fun hookPendingRequestForNetwork() { + when { + helper.sdkInt >= 31 -> { + try { + hookPendingRequestForNetworkV12() + } catch (e: Throwable) { + HookErrorStore.w(SOURCE, "hookPendingRequestForNetworkV12 failed: ${e.message}", e) + try { + hookPendingRequestForNetworkV11() + } catch (e2: Throwable) { + HookErrorStore.e(SOURCE, "hookPendingRequestForNetworkV11 failed: ${e2.message}", e2) + } + } + } + helper.sdkInt >= 30 -> { + try { + hookPendingRequestForNetworkV11() + } catch (e: Throwable) { + HookErrorStore.w(SOURCE, "hookPendingRequestForNetworkV11 failed: ${e.message}", e) + try { + hookPendingRequestForNetworkV8() + } catch (e2: Throwable) { + HookErrorStore.e(SOURCE, "hookPendingRequestForNetworkV8 failed: ${e2.message}", e2) + } + } + } + else -> { + try { + hookPendingRequestForNetworkV8() + } catch (e: Throwable) { + HookErrorStore.e(SOURCE, "hookPendingRequestForNetworkV8 failed: ${e.message}", e) + } + } + } + } + + private fun hookPendingListenForNetwork() { + when { + helper.sdkInt >= 31 -> { + try { + hookPendingListenForNetworkV12() + } catch (e: Throwable) { + HookErrorStore.w(SOURCE, "hookPendingListenForNetworkV12 failed: ${e.message}", e) + try { + hookPendingListenForNetworkV11() + } catch (e2: Throwable) { + HookErrorStore.e(SOURCE, "hookPendingListenForNetworkV11 failed: ${e2.message}", e2) + } + } + } + helper.sdkInt >= 30 -> { + try { + hookPendingListenForNetworkV11() + } catch (e: Throwable) { + HookErrorStore.w(SOURCE, "hookPendingListenForNetworkV11 failed: ${e.message}", e) + try { + hookPendingListenForNetworkV8() + } catch (e2: Throwable) { + HookErrorStore.e(SOURCE, "hookPendingListenForNetworkV8 failed: ${e2.message}", e2) + } + } + } + else -> { + try { + hookPendingListenForNetworkV8() + } catch (e: Throwable) { + HookErrorStore.e(SOURCE, "hookPendingListenForNetworkV8 failed: ${e.message}", e) + } + } + } + } + + // region requestNetwork versions + + private fun hookRequestNetworkV8() { + XposedHelpers.findAndHookMethod( + helper.cls, + "requestNetwork", + NetworkCapabilities::class.java, + android.os.Messenger::class.java, + Int::class.javaPrimitiveType, + android.os.IBinder::class.java, + Int::class.javaPrimitiveType, + object : SafeMethodHook(SOURCE) { + override fun beforeHook(param: MethodHookParam) { + val uid = Binder.getCallingUid() + if (!VpnSanitizer.shouldHide(uid)) return + val nc = param.args[0] as? NetworkCapabilities ?: return + param.args[0] = VpnSanitizer.sanitizeRequestCapabilities(nc) + } + } + ) + } + + private fun hookRequestNetworkV11() { + XposedHelpers.findAndHookMethod( + helper.cls, + "requestNetwork", + NetworkCapabilities::class.java, + android.os.Messenger::class.java, + Int::class.javaPrimitiveType, + android.os.IBinder::class.java, + Int::class.javaPrimitiveType, + String::class.java, + object : SafeMethodHook(SOURCE) { + override fun beforeHook(param: MethodHookParam) { + val uid = Binder.getCallingUid() + if (!VpnSanitizer.shouldHide(uid)) return + val nc = param.args[0] as? NetworkCapabilities ?: return + param.args[0] = VpnSanitizer.sanitizeRequestCapabilities(nc) + } + } + ) + } + + private fun hookRequestNetworkV12() { + XposedHelpers.findAndHookMethod( + helper.cls, + "requestNetwork", + Int::class.javaPrimitiveType, + NetworkCapabilities::class.java, + Int::class.javaPrimitiveType, + android.os.Messenger::class.java, + Int::class.javaPrimitiveType, + android.os.IBinder::class.java, + Int::class.javaPrimitiveType, + Int::class.javaPrimitiveType, + String::class.java, + String::class.java, + object : SafeMethodHook(SOURCE) { + override fun beforeHook(param: MethodHookParam) { + val uid = Binder.getCallingUid() + if (!VpnSanitizer.shouldHide(uid)) return + val nc = param.args[1] as? NetworkCapabilities ?: return + param.args[1] = VpnSanitizer.sanitizeRequestCapabilities(nc) + } + } + ) + } + + private fun hookRequestNetworkV16() { + XposedHelpers.findAndHookMethod( + helper.cls, + "requestNetwork", + Int::class.javaPrimitiveType, + NetworkCapabilities::class.java, + Int::class.javaPrimitiveType, + android.os.Messenger::class.java, + Int::class.javaPrimitiveType, + android.os.IBinder::class.java, + Int::class.javaPrimitiveType, + Int::class.javaPrimitiveType, + String::class.java, + String::class.java, + Int::class.javaPrimitiveType, + object : SafeMethodHook(SOURCE) { + override fun beforeHook(param: MethodHookParam) { + val uid = Binder.getCallingUid() + if (!VpnSanitizer.shouldHide(uid)) return + val nc = param.args[1] as? NetworkCapabilities ?: return + param.args[1] = VpnSanitizer.sanitizeRequestCapabilities(nc) + } + } + ) + } + + // endregion + + // region listenForNetwork versions + + private fun hookListenForNetworkV8() { + XposedHelpers.findAndHookMethod( + helper.cls, + "listenForNetwork", + NetworkCapabilities::class.java, + android.os.Messenger::class.java, + android.os.IBinder::class.java, + object : SafeMethodHook(SOURCE) { + override fun beforeHook(param: MethodHookParam) { + val uid = Binder.getCallingUid() + if (!VpnSanitizer.shouldHide(uid)) return + val nc = param.args[0] as? NetworkCapabilities ?: return + param.args[0] = VpnSanitizer.sanitizeRequestCapabilities(nc) + } + } + ) + } + + private fun hookListenForNetworkV11() { + XposedHelpers.findAndHookMethod( + helper.cls, + "listenForNetwork", + NetworkCapabilities::class.java, + android.os.Messenger::class.java, + android.os.IBinder::class.java, + String::class.java, + object : SafeMethodHook(SOURCE) { + override fun beforeHook(param: MethodHookParam) { + val uid = Binder.getCallingUid() + if (!VpnSanitizer.shouldHide(uid)) return + val nc = param.args[0] as? NetworkCapabilities ?: return + param.args[0] = VpnSanitizer.sanitizeRequestCapabilities(nc) + } + } + ) + } + + private fun hookListenForNetworkV12() { + XposedHelpers.findAndHookMethod( + helper.cls, + "listenForNetwork", + NetworkCapabilities::class.java, + android.os.Messenger::class.java, + android.os.IBinder::class.java, + Int::class.javaPrimitiveType, + String::class.java, + String::class.java, + object : SafeMethodHook(SOURCE) { + override fun beforeHook(param: MethodHookParam) { + val uid = Binder.getCallingUid() + if (!VpnSanitizer.shouldHide(uid)) return + val nc = param.args[0] as? NetworkCapabilities ?: return + param.args[0] = VpnSanitizer.sanitizeRequestCapabilities(nc) + } + } + ) + } + + private fun hookListenForNetworkV16() { + XposedHelpers.findAndHookMethod( + helper.cls, + "listenForNetwork", + NetworkCapabilities::class.java, + android.os.Messenger::class.java, + android.os.IBinder::class.java, + Int::class.javaPrimitiveType, + String::class.java, + String::class.java, + Int::class.javaPrimitiveType, + object : SafeMethodHook(SOURCE) { + override fun beforeHook(param: MethodHookParam) { + val uid = Binder.getCallingUid() + if (!VpnSanitizer.shouldHide(uid)) return + val nc = param.args[0] as? NetworkCapabilities ?: return + param.args[0] = VpnSanitizer.sanitizeRequestCapabilities(nc) + } + } + ) + } + + // endregion + + // region pendingRequestForNetwork versions + + private fun hookPendingRequestForNetworkV8() { + XposedHelpers.findAndHookMethod( + helper.cls, + "pendingRequestForNetwork", + NetworkCapabilities::class.java, + android.app.PendingIntent::class.java, + object : SafeMethodHook(SOURCE) { + override fun beforeHook(param: MethodHookParam) { + val uid = Binder.getCallingUid() + if (!VpnSanitizer.shouldHide(uid)) return + val nc = param.args[0] as? NetworkCapabilities ?: return + param.args[0] = VpnSanitizer.sanitizeRequestCapabilities(nc) + } + } + ) + } + + private fun hookPendingRequestForNetworkV11() { + XposedHelpers.findAndHookMethod( + helper.cls, + "pendingRequestForNetwork", + NetworkCapabilities::class.java, + android.app.PendingIntent::class.java, + String::class.java, + object : SafeMethodHook(SOURCE) { + override fun beforeHook(param: MethodHookParam) { + val uid = Binder.getCallingUid() + if (!VpnSanitizer.shouldHide(uid)) return + val nc = param.args[0] as? NetworkCapabilities ?: return + param.args[0] = VpnSanitizer.sanitizeRequestCapabilities(nc) + } + } + ) + } + + private fun hookPendingRequestForNetworkV12() { + XposedHelpers.findAndHookMethod( + helper.cls, + "pendingRequestForNetwork", + NetworkCapabilities::class.java, + android.app.PendingIntent::class.java, + String::class.java, + String::class.java, + object : SafeMethodHook(SOURCE) { + override fun beforeHook(param: MethodHookParam) { + val uid = Binder.getCallingUid() + if (!VpnSanitizer.shouldHide(uid)) return + val nc = param.args[0] as? NetworkCapabilities ?: return + param.args[0] = VpnSanitizer.sanitizeRequestCapabilities(nc) + } + } + ) + } + + // endregion + + // region pendingListenForNetwork versions + + private fun hookPendingListenForNetworkV8() { + XposedHelpers.findAndHookMethod( + helper.cls, + "pendingListenForNetwork", + NetworkCapabilities::class.java, + android.app.PendingIntent::class.java, + object : SafeMethodHook(SOURCE) { + override fun beforeHook(param: MethodHookParam) { + val uid = Binder.getCallingUid() + if (!VpnSanitizer.shouldHide(uid)) return + val nc = param.args[0] as? NetworkCapabilities ?: return + param.args[0] = VpnSanitizer.sanitizeRequestCapabilities(nc) + } + } + ) + } + + private fun hookPendingListenForNetworkV11() { + XposedHelpers.findAndHookMethod( + helper.cls, + "pendingListenForNetwork", + NetworkCapabilities::class.java, + android.app.PendingIntent::class.java, + String::class.java, + object : SafeMethodHook(SOURCE) { + override fun beforeHook(param: MethodHookParam) { + val uid = Binder.getCallingUid() + if (!VpnSanitizer.shouldHide(uid)) return + val nc = param.args[0] as? NetworkCapabilities ?: return + param.args[0] = VpnSanitizer.sanitizeRequestCapabilities(nc) + } + } + ) + } + + private fun hookPendingListenForNetworkV12() { + XposedHelpers.findAndHookMethod( + helper.cls, + "pendingListenForNetwork", + NetworkCapabilities::class.java, + android.app.PendingIntent::class.java, + String::class.java, + String::class.java, + object : SafeMethodHook(SOURCE) { + override fun beforeHook(param: MethodHookParam) { + val uid = Binder.getCallingUid() + if (!VpnSanitizer.shouldHide(uid)) return + val nc = param.args[0] as? NetworkCapabilities ?: return + param.args[0] = VpnSanitizer.sanitizeRequestCapabilities(nc) + } + } + ) + } + + // endregion + + // region default capabilities + + private fun hookCreateDefaultNetworkCapabilities() { + XposedHelpers.findAndHookMethod( + helper.cls, + "createDefaultNetworkCapabilitiesForUid", + Int::class.javaPrimitiveType, + object : SafeMethodHook(SOURCE) { + override fun afterHook(param: MethodHookParam) { + val uid = param.args[0] as? Int ?: return + if (!VpnSanitizer.shouldHide(uid)) return + val nc = param.result as? NetworkCapabilities ?: return + param.result = VpnSanitizer.sanitizeRequestCapabilities(nc) + } + } + ) + } + + private fun hookCopyDefaultNetworkCapabilities() { + XposedHelpers.findAndHookMethod( + helper.cls, + "copyDefaultNetworkCapabilitiesForUid", + NetworkCapabilities::class.java, + Int::class.javaPrimitiveType, + Int::class.javaPrimitiveType, + String::class.java, + object : SafeMethodHook(SOURCE) { + override fun afterHook(param: MethodHookParam) { + val requestorUid = param.args[2] as? Int ?: return + if (!VpnSanitizer.shouldHide(requestorUid)) return + val nc = param.result as? NetworkCapabilities ?: return + param.result = VpnSanitizer.sanitizeRequestCapabilities(nc) + } + } + ) + } + + // endregion + + // region callback hooks + + private fun hookCallCallbackForRequest() { + if (helper.sdkInt >= 36) { + // API 36+ has both WithAgent and WithBundle variants + try { + hookCallCallbackForRequestWithAgent() + } catch (e: Throwable) { + HookErrorStore.w(SOURCE, "hookCallCallbackForRequestWithAgent failed: ${e.message}", e) + } + try { + hookCallCallbackForRequestWithBundle() + } catch (e: Throwable) { + HookErrorStore.w(SOURCE, "hookCallCallbackForRequestWithBundle failed: ${e.message}", e) + } + } else { + try { + hookCallCallbackForRequestWithAgent() + } catch (e: Throwable) { + HookErrorStore.w(SOURCE, "hookCallCallbackForRequestWithAgent failed: ${e.message}", e) + } + } + } + + private fun hookCallCallbackForRequestWithAgent() { + val (nriClass, naiClass) = helper.resolveNriAndNaiClasses() + XposedHelpers.findAndHookMethod( + helper.cls, + "callCallbackForRequest", + nriClass, + naiClass, + Int::class.javaPrimitiveType, + Int::class.javaPrimitiveType, + object : SafeMethodHook(SOURCE) { + override fun beforeHook(param: MethodHookParam) { + val nri = param.args[0] ?: return + val uid = helper.getAsUid(nri) + if (!VpnSanitizer.shouldHide(uid)) return + val networkAgent = param.args[1] + if (networkAgent != null && helper.isVpnNai(networkAgent)) { + val underlying = helper.getUnderlyingNai(param.thisObject, uid) + if (underlying != null) { + param.args[1] = underlying + } + } + VpnHideContext.setTargetUid(uid) + } + + override fun afterHook(param: MethodHookParam) { + VpnHideContext.clear() + } + } + ) + } + + private fun hookCallCallbackForRequestWithBundle() { + val (nriClass, _) = helper.resolveNriAndNaiClasses() + XposedHelpers.findAndHookMethod( + helper.cls, + "callCallbackForRequest", + nriClass, + Int::class.javaPrimitiveType, + Bundle::class.java, + Int::class.javaPrimitiveType, + object : SafeMethodHook(SOURCE) { + override fun beforeHook(param: MethodHookParam) { + val nri = param.args[0] ?: return + val uid = helper.getAsUid(nri) + if (!VpnSanitizer.shouldHide(uid)) return + VpnHideContext.setTargetUid(uid) + } + + override fun afterHook(param: MethodHookParam) { + VpnHideContext.clear() + } + } + ) + } + + private fun hookSendPendingIntentForRequest() { + val (nriClass, naiClass) = helper.resolveNriAndNaiClasses() + XposedHelpers.findAndHookMethod( + helper.cls, + "sendPendingIntentForRequest", + nriClass, + naiClass, + Int::class.javaPrimitiveType, + object : SafeMethodHook(SOURCE) { + override fun beforeHook(param: MethodHookParam) { + val nri = param.args[0] ?: return + val uid = helper.getAsUid(nri) + if (!VpnSanitizer.shouldHide(uid)) return + val networkAgent = param.args[1] + if (networkAgent != null && helper.isVpnNai(networkAgent)) { + val underlying = helper.getUnderlyingNai(param.thisObject, uid) + if (underlying != null) { + param.args[1] = underlying + } + } + VpnHideContext.setTargetUid(uid) + } + + override fun afterHook(param: MethodHookParam) { + VpnHideContext.clear() + } + } + ) + } + + // endregion +} diff --git a/app/src/main/java/io/nekohasekai/sfa/xposed/hooks/hidevpn/ConnectivityServiceHookHelper.kt b/app/src/main/java/io/nekohasekai/sfa/xposed/hooks/hidevpn/ConnectivityServiceHookHelper.kt new file mode 100644 index 0000000..de27be4 --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sfa/xposed/hooks/hidevpn/ConnectivityServiceHookHelper.kt @@ -0,0 +1,467 @@ +package io.nekohasekai.sfa.xposed.hooks.hidevpn + +import android.content.Context +import android.net.LinkProperties +import android.net.Network +import android.net.NetworkInfo +import android.os.Build +import android.os.IBinder +import de.robv.android.xposed.XC_MethodHook +import de.robv.android.xposed.XposedHelpers +import io.nekohasekai.sfa.xposed.HookErrorStore +import io.nekohasekai.sfa.xposed.PrivilegeSettingsStore +import io.nekohasekai.sfa.xposed.VpnAppStore +import io.nekohasekai.sfa.xposed.VpnSanitizer +import io.nekohasekai.sfa.xposed.hooks.SafeMethodHook +import io.nekohasekai.sfa.xposed.hooks.XHook +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.atomic.AtomicBoolean + +class ConnectivityServiceHookHelper(private val classLoader: ClassLoader) : XHook { + companion object { + private const val SOURCE = "ConnectivityServiceHookHelper" + } + + private val hooked = AtomicBoolean(false) + private val initializerHooked = AtomicBoolean(false) + private var classLoadUnhook: XC_MethodHook.Unhook? = null + private val serviceManagerHooked = AtomicBoolean(false) + private var connectivityClassLoader: ClassLoader = classLoader + private val skipLogKeys = ConcurrentHashMap() + val sdkInt = Build.VERSION.SDK_INT + + lateinit var cls: Class<*> + private set + + override fun injectHook() { + val foundClass = findConnectivityServiceClass() + if (foundClass != null) { + installHooks(foundClass, "direct") + return + } + hookConnectivityServiceInitializer() + hookClassLoaderFallback() + tryHookFromServiceManager() + } + + private fun installHooks(cls: Class<*>, source: String) { + if (!hooked.compareAndSet(false, true)) { + return + } + this.cls = cls + connectivityClassLoader = cls.classLoader ?: classLoader + HookErrorStore.i( + SOURCE, + "Installing ConnectivityService hooks ($source) cls=${cls.name} loader=${connectivityClassLoader.javaClass.name}", + ) + + // Install all individual hooks + HookConnectivityManagerGetActiveNetwork(this).install() + HookConnectivityManagerGetActiveNetworkInfo(this).install() + HookConnectivityManagerGetNetworkInfo(this).install() + HookConnectivityManagerGetAllNetworkInfo(this).install() + HookConnectivityManagerGetAllNetworks(this).install() + HookConnectivityManagerGetNetworkForType(this).install() + HookConnectivityManagerGetNetworkCapabilities(this).install() + HookConnectivityManagerGetLinkProperties(this).install() + HookConnectivityManagerRequestNetwork(this).install() + HookConnectivityManagerGetDefaultProxy(this).install() + HookConnectivityManagerConnectivityAction(this).install() + HookConnectivityManagerProxyChangeAction(this).install() + + HookErrorStore.i(SOURCE, "Hooked ConnectivityService ($source) cls=${cls.name}") + } + + // region Service Discovery + + private fun findConnectivityServiceClass(): Class<*>? { + val candidates = listOf( + "com.android.server.ConnectivityService", + ) + val loaders = listOf( + classLoader, + classLoader.parent, + Thread.currentThread().contextClassLoader, + ClassLoader.getSystemClassLoader(), + ClassLoader.getSystemClassLoader()?.parent, + ) + for (name in candidates) { + for (loader in loaders) { + try { + val found = if (loader != null) { + Class.forName(name, false, loader) + } else { + Class.forName(name) + } + HookErrorStore.i( + SOURCE, + "ConnectivityService class found: $name via ${loader?.javaClass?.name ?: "null"}", + ) + return found + } catch (_: Throwable) { + } + } + } + HookErrorStore.i(SOURCE, "ConnectivityService class not found in known classloaders") + return null + } + + private fun hookConnectivityServiceInitializer() { + if (sdkInt < 31 || sdkInt >= 33) { + HookErrorStore.d(SOURCE, "Skip ConnectivityServiceInitializer: sdk=$sdkInt (only exists in API 31-32)") + return + } + val candidates = listOf( + "com.android.server.ConnectivityServiceInitializer", + "com.android.server.ConnectivityServiceInitializerB", + ) + val loaders = listOf( + classLoader, + classLoader.parent, + Thread.currentThread().contextClassLoader, + ClassLoader.getSystemClassLoader(), + ClassLoader.getSystemClassLoader()?.parent, + ) + for (name in candidates) { + for (loader in loaders) { + val cls = try { + if (loader != null) { + Class.forName(name, false, loader) + } else { + Class.forName(name) + } + } catch (_: Throwable) { + null + } ?: continue + try { + if (initializerHooked.get()) { + return + } + XposedHelpers.findAndHookConstructor( + cls, + Context::class.java, + object : SafeMethodHook(SOURCE) { + override fun afterHook(param: MethodHookParam) { + if (hooked.get()) return + val instance = param.thisObject ?: return + val connectivity = findConnectivityServiceInstance(instance) ?: return + installHooks(connectivity.javaClass, "initializer_ctor") + } + }, + ) + XposedHelpers.findAndHookMethod( + cls, + "onStart", + object : SafeMethodHook(SOURCE) { + override fun afterHook(param: MethodHookParam) { + if (hooked.get()) return + val instance = param.thisObject ?: return + val connectivity = findConnectivityServiceInstance(instance) ?: return + installHooks(connectivity.javaClass, "initializer") + } + }, + ) + initializerHooked.set(true) + HookErrorStore.i( + SOURCE, + "Hooked $name (ctor/onStart) via ${loader?.javaClass?.name ?: "null"}", + ) + return + } catch (e: Throwable) { + HookErrorStore.w(SOURCE, "Hook $name failed: ${e.message}", e) + } + } + } + HookErrorStore.d(SOURCE, "ConnectivityServiceInitializer not found in known classloaders") + } + + private fun hookClassLoaderFallback() { + if (classLoadUnhook != null) { + return + } + try { + classLoadUnhook = XposedHelpers.findAndHookMethod( + ClassLoader::class.java, + "loadClass", + String::class.java, + Boolean::class.javaPrimitiveType, + object : SafeMethodHook(SOURCE) { + override fun afterHook(param: MethodHookParam) { + val name = param.args[0] as? String ?: return + if (hooked.get()) { + classLoadUnhook?.unhook() + classLoadUnhook = null + return + } + when (name) { + "com.android.server.ConnectivityService" -> { + val cls = param.result as? Class<*> ?: return + HookErrorStore.i( + SOURCE, + "ConnectivityService loaded via ${param.thisObject.javaClass.name}", + ) + installHooks(cls, "loadClass") + classLoadUnhook?.unhook() + classLoadUnhook = null + } + "com.android.server.ConnectivityServiceInitializer", + "com.android.server.ConnectivityServiceInitializerB", + -> { + if (sdkInt < 31) return + if (initializerHooked.get()) return + val cls = param.result as? Class<*> ?: return + HookErrorStore.i( + SOURCE, + "ConnectivityServiceInitializer loaded via ${param.thisObject.javaClass.name}", + ) + hookConnectivityServiceInitializerClass(cls) + } + } + } + }, + ) + HookErrorStore.i(SOURCE, "Hooked ClassLoader.loadClass for ConnectivityService") + } catch (e: Throwable) { + HookErrorStore.w(SOURCE, "Hook ClassLoader.loadClass failed: ${e.message}", e) + } + } + + private fun tryHookFromServiceManager() { + if (hooked.get()) return + val binder = try { + val serviceManager = Class.forName("android.os.ServiceManager") + val checkService = serviceManager.getMethod("checkService", String::class.java) + checkService.invoke(null, Context.CONNECTIVITY_SERVICE) as? IBinder + } catch (_: Throwable) { + null + } + if (binder != null) { + HookErrorStore.i( + SOURCE, + "ConnectivityService binder from ServiceManager: ${binder.javaClass.name}", + ) + installHooks(binder.javaClass, "ServiceManager.checkService") + return + } + hookServiceManagerAddService() + } + + private fun hookServiceManagerAddService() { + if (!serviceManagerHooked.compareAndSet(false, true)) { + return + } + try { + val serviceManager = Class.forName("android.os.ServiceManager") + XposedHelpers.findAndHookMethod( + serviceManager, + "addService", + String::class.java, + IBinder::class.java, + Boolean::class.javaPrimitiveType, + Int::class.javaPrimitiveType, + object : SafeMethodHook(SOURCE) { + override fun afterHook(param: MethodHookParam) { + if (hooked.get()) return + val name = param.args[0] as? String ?: return + if (name != Context.CONNECTIVITY_SERVICE) return + val binder = param.args[1] as? IBinder ?: return + HookErrorStore.i( + SOURCE, + "ConnectivityService registered: ${binder.javaClass.name}", + ) + installHooks(binder.javaClass, "ServiceManager.addService") + } + }, + ) + HookErrorStore.i(SOURCE, "Hooked ServiceManager.addService for ConnectivityService") + } catch (e: Throwable) { + HookErrorStore.w(SOURCE, "Hook ServiceManager.addService failed: ${e.message}", e) + } + } + + private fun hookConnectivityServiceInitializerClass(cls: Class<*>) { + if (sdkInt < 31) return + if (initializerHooked.get()) return + try { + XposedHelpers.findAndHookConstructor( + cls, + Context::class.java, + object : SafeMethodHook(SOURCE) { + override fun afterHook(param: MethodHookParam) { + if (hooked.get()) return + val instance = param.thisObject ?: return + val connectivity = findConnectivityServiceInstance(instance) ?: return + installHooks(connectivity.javaClass, "initializer_ctor") + } + }, + ) + XposedHelpers.findAndHookMethod( + cls, + "onStart", + object : SafeMethodHook(SOURCE) { + override fun afterHook(param: MethodHookParam) { + if (hooked.get()) return + val instance = param.thisObject ?: return + val connectivity = findConnectivityServiceInstance(instance) ?: return + installHooks(connectivity.javaClass, "initializer") + } + }, + ) + initializerHooked.set(true) + HookErrorStore.i(SOURCE, "Hooked ${cls.name} (ctor/onStart) via loadClass") + } catch (e: Throwable) { + HookErrorStore.w(SOURCE, "Hook ${cls.name} via loadClass failed: ${e.message}", e) + } + } + + private fun findConnectivityServiceInstance(instance: Any): Any? { + try { + val direct = XposedHelpers.getObjectField(instance, "mConnectivity") + if (direct != null) { + return direct + } + } catch (_: Throwable) { + } + return try { + val fields = instance.javaClass.declaredFields + for (field in fields) { + if (field.type.name.endsWith(".ConnectivityService")) { + field.isAccessible = true + val value = field.get(instance) + if (value != null) { + return value + } + } + } + null + } catch (_: Throwable) { + null + } + } + + // endregion + + // region Helper Methods + + fun shouldHide(connectivityService: Any, uid: Int): Boolean { + if (!PrivilegeSettingsStore.isEnabled()) { + logSkipOnce(uid, "hide_disabled", "Skip hide: uid=$uid hide settings disabled") + return false + } + if (!PrivilegeSettingsStore.isUidSelected(uid)) { + logSkipOnce(uid, "hide_not_selected", "Skip hide: uid=$uid not in hide list") + return false + } + if (VpnAppStore.isVpnUidExcludeSelf(uid)) { + logSkipOnce(uid, "uid_vpn_app", "Skip hide: uid=$uid vpn app") + return false + } + val hasVpn = hasVpnForUid(connectivityService, uid) + if (!hasVpn) { + logSkipOnce(uid, "uid_no_vpn", "Skip hide: uid=$uid noVpnForUid") + } + return hasVpn + } + + fun hasVpnForUid(connectivityService: Any, uid: Int): Boolean { + if (sdkInt >= 31) { + return XposedHelpers.callMethod(connectivityService, "getVpnForUid", uid) != null + } + @Suppress("UNCHECKED_CAST") + val networks = XposedHelpers.callMethod(connectivityService, "getVpnUnderlyingNetworks", uid) + as? Array + return networks != null && networks.isNotEmpty() + } + + fun isVpnNetwork(connectivityService: Any, network: Network): Boolean { + val nai = XposedHelpers.callMethod(connectivityService, "getNetworkAgentInfoForNetwork", network) + ?: return false + return isVpnNai(nai) + } + + fun isVpnNai(nai: Any): Boolean { + return XposedHelpers.callMethod(nai, "isVPN") as Boolean + } + + fun getUnderlyingNetwork(connectivityService: Any, uid: Int): Network? { + val nai = getUnderlyingNai(connectivityService, uid) ?: return null + return XposedHelpers.callMethod(nai, "network") as Network? + } + + fun getUnderlyingLinkProperties(connectivityService: Any, uid: Int): LinkProperties? { + val nai = getUnderlyingNai(connectivityService, uid) ?: return null + val lp = XposedHelpers.getObjectField(nai, "linkProperties") as LinkProperties? + ?: return null + return VpnSanitizer.cloneLinkProperties(lp) + } + + fun getUnderlyingNetworkInfo(connectivityService: Any, uid: Int): NetworkInfo? { + val nai = getUnderlyingNai(connectivityService, uid) ?: return null + return XposedHelpers.callMethod(connectivityService, "getFilteredNetworkInfo", nai, uid, false) + as NetworkInfo? + } + + fun getUnderlyingNai(connectivityService: Any, uid: Int): Any? { + @Suppress("UNCHECKED_CAST") + val networks = XposedHelpers.callMethod(connectivityService, "getVpnUnderlyingNetworks", uid) + as? Array + if (networks != null && networks.isNotEmpty()) { + return XposedHelpers.callMethod(connectivityService, "getNetworkAgentInfoForNetwork", networks[0]) + } + val defaultNai = XposedHelpers.callMethod(connectivityService, "getDefaultNetwork") + if (defaultNai != null && !isVpnNai(defaultNai)) { + return defaultNai + } + return null + } + + /** + * Resolves a class from the Connectivity module, handling APEX package rewriting. + * + * When the Connectivity module runs as an APEX (Android 12+), all classes get prefixed + * with "android.net.connectivity.". This method derives the correct prefix from + * the already-loaded ConnectivityService class. + * + * @param simpleClassName Simple class name (e.g., "ProxyTracker") + * @param subPackage Sub-package under com.android.server (e.g., "connectivity"), or null + */ + fun resolveConnectivityModuleClass(simpleClassName: String, subPackage: String? = null): Class<*> { + val base = cls.name + val serverPackage = if (base.endsWith(".ConnectivityService")) { + base.removeSuffix(".ConnectivityService") + } else { + base.substringBeforeLast(".ConnectivityService", base) + } + + val fullClassName = if (subPackage != null) { + "$serverPackage.$subPackage.$simpleClassName" + } else { + "$serverPackage.$simpleClassName" + } + + return XposedHelpers.findClass(fullClassName, connectivityClassLoader) + } + + fun resolveNriAndNaiClasses(): Pair, Class<*>> { + val nriClass = XposedHelpers.findClass( + cls.name + '$' + "NetworkRequestInfo", + connectivityClassLoader, + ) + val naiClass = resolveConnectivityModuleClass("NetworkAgentInfo", "connectivity") + return Pair(nriClass, naiClass) + } + + fun getAsUid(nri: Any): Int { + val fieldName = if (sdkInt >= 31) "mAsUid" else "mUid" + return XposedHelpers.getIntField(nri, fieldName) + } + + fun logSkipOnce(uid: Int, reason: String, message: String) { + val key = "$uid:$reason" + if (skipLogKeys.putIfAbsent(key, true) == null) { + HookErrorStore.d(SOURCE, message) + } + } + + // endregion +} diff --git a/app/src/main/java/io/nekohasekai/sfa/xposed/hooks/hidevpn/NetworkCapabilities+writeToParcel.kt b/app/src/main/java/io/nekohasekai/sfa/xposed/hooks/hidevpn/NetworkCapabilities+writeToParcel.kt new file mode 100644 index 0000000..1a95954 --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sfa/xposed/hooks/hidevpn/NetworkCapabilities+writeToParcel.kt @@ -0,0 +1,108 @@ +package io.nekohasekai.sfa.xposed.hooks.hidevpn + +import android.net.NetworkCapabilities +import android.os.Binder +import android.os.Parcel +import de.robv.android.xposed.XposedBridge +import de.robv.android.xposed.XposedHelpers +import io.nekohasekai.sfa.xposed.HookErrorStore +import io.nekohasekai.sfa.xposed.HookStatusStore +import io.nekohasekai.sfa.xposed.VpnHideContext +import io.nekohasekai.sfa.xposed.VpnSanitizer +import io.nekohasekai.sfa.xposed.hooks.SafeMethodHook +import io.nekohasekai.sfa.xposed.hooks.XHook + +class HookNetworkCapabilitiesWriteToParcel : XHook { + private companion object { + private const val SOURCE = "HookNCWriteToParcel" + } + + private val inWrite = ThreadLocal.withInitial { false } + + override fun injectHook() { + XposedHelpers.findAndHookMethod( + NetworkCapabilities::class.java, + "writeToParcel", + Parcel::class.java, + Int::class.javaPrimitiveType!!, + object : SafeMethodHook(SOURCE) { + override fun beforeHook(param: MethodHookParam) { + if (inWrite.get() == true) { + return + } + val targetUid = VpnHideContext.consumeTargetUid() + val shouldHide = when { + targetUid != null -> VpnSanitizer.shouldHide(targetUid) + else -> VpnSanitizer.shouldHide(Binder.getCallingUid()) + } + if (!shouldHide) { + return + } + val caps = param.thisObject as NetworkCapabilities + val sanitized = copyNetworkCapabilities(caps) + sanitizeNetworkCapabilities(sanitized) + HookStatusStore.markPatched() + inWrite.set(true) + try { + XposedBridge.invokeOriginalMethod(param.method, sanitized, param.args) + param.result = null + } finally { + inWrite.set(false) + } + } + }, + ) + HookStatusStore.markHookActive() + HookErrorStore.i(SOURCE, "Hooked NetworkCapabilities.writeToParcel (sender)") + } + + private fun copyNetworkCapabilities(caps: NetworkCapabilities): NetworkCapabilities { + return if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.S) { + val ctor = NetworkCapabilities::class.java.getDeclaredConstructor( + NetworkCapabilities::class.java, + Long::class.javaPrimitiveType!! + ) + ctor.isAccessible = true + ctor.newInstance(caps, 0L) + } else { + val ctor = NetworkCapabilities::class.java.getDeclaredConstructor(NetworkCapabilities::class.java) + ctor.isAccessible = true + ctor.newInstance(caps) + } + } + + private fun sanitizeNetworkCapabilities(caps: NetworkCapabilities) { + XposedHelpers.callMethod(caps, "removeTransportType", NetworkCapabilities.TRANSPORT_VPN) + XposedHelpers.callMethod(caps, "addCapability", NetworkCapabilities.NET_CAPABILITY_NOT_VPN) + clearVpnTransportInfo(caps) + clearUnderlyingNetworks(caps) + clearOwnerUid(caps) + } + + private fun clearVpnTransportInfo(caps: NetworkCapabilities) { + if (android.os.Build.VERSION.SDK_INT < android.os.Build.VERSION_CODES.Q) { + return + } + 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 clearUnderlyingNetworks(caps: NetworkCapabilities) { + if (android.os.Build.VERSION.SDK_INT < android.os.Build.VERSION_CODES.S) { + return + } + val field = XposedHelpers.findField(NetworkCapabilities::class.java, "mUnderlyingNetworks") + field.set(caps, null) + } + + private fun clearOwnerUid(caps: NetworkCapabilities) { + if (android.os.Build.VERSION.SDK_INT < android.os.Build.VERSION_CODES.R) { + return + } + val field = XposedHelpers.findField(NetworkCapabilities::class.java, "mOwnerUid") + field.setInt(caps, android.os.Process.INVALID_UID) + } +} diff --git a/app/src/main/java/io/nekohasekai/sfa/xposed/hooks/hidevpn/NetworkInterface+getName.kt b/app/src/main/java/io/nekohasekai/sfa/xposed/hooks/hidevpn/NetworkInterface+getName.kt new file mode 100644 index 0000000..2402841 --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sfa/xposed/hooks/hidevpn/NetworkInterface+getName.kt @@ -0,0 +1,317 @@ +package io.nekohasekai.sfa.xposed.hooks.hidevpn + +import android.system.ErrnoException +import android.system.Os +import android.system.OsConstants +import android.system.StructTimeval +import de.robv.android.xposed.XposedHelpers +import io.nekohasekai.sfa.xposed.HookErrorStore +import io.nekohasekai.sfa.xposed.PrivilegeSettingsStore +import io.nekohasekai.sfa.xposed.hooks.SafeMethodHook +import io.nekohasekai.sfa.xposed.hooks.XHook +import java.io.FileDescriptor +import java.net.SocketAddress +import java.nio.ByteBuffer +import java.nio.ByteOrder +import java.util.concurrent.atomic.AtomicInteger + +class HookNetworkInterfaceGetName(private val classLoader: ClassLoader) : XHook { + private companion object { + private const val SOURCE = "HookNetworkInterfaceGetName" + private const val MAX_NAME_LEN = 15 + private const val MAX_SUFFIX = 63 + private const val NLMSG_HEADER_LEN = 16 + private const val IFINFO_MSG_LEN = 16 + private const val NLA_HEADER_LEN = 4 + private const val RTM_NEWLINK = 16 + private const val IFLA_IFNAME = 3 + private const val NLM_F_REQUEST = 0x1 + private const val NLM_F_ACK = 0x4 + private const val NLMSG_ERROR = 2 + private const val IFF_UP = 0x1 + } + + private val seq = AtomicInteger(1) + + override fun injectHook() { + if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.TIRAMISU) { + hookJniGetNameApi33Plus() + } else { + hookJniGetNameLegacy() + } + } + + private fun hookJniGetNameApi33Plus() { + val vpnClass = findVpnClass() + val depsClass = XposedHelpers.findClass("${vpnClass.name}\$Dependencies", classLoader) + XposedHelpers.findAndHookMethod( + depsClass, + "jniGetName", + vpnClass, + Int::class.javaPrimitiveType, + object : SafeMethodHook(SOURCE) { + override fun afterHook(param: MethodHookParam) { + processJniGetNameResult(param) + } + }, + ) + HookErrorStore.i(SOURCE, "Hooked ${depsClass.name}.jniGetName (API 33+)") + } + + private fun hookJniGetNameLegacy() { + val cls = findVpnClass() + XposedHelpers.findAndHookMethod( + cls, + "jniGetName", + Int::class.javaPrimitiveType, + object : SafeMethodHook(SOURCE) { + override fun afterHook(param: MethodHookParam) { + processJniGetNameResult(param) + } + }, + ) + HookErrorStore.i(SOURCE, "Hooked ${cls.name}.jniGetName (legacy)") + } + + private fun processJniGetNameResult(param: de.robv.android.xposed.XC_MethodHook.MethodHookParam) { + val result = param.result + if (result !is String) { + if (result != null) { + HookErrorStore.e(SOURCE, "jniGetName returned unexpected type: ${result.javaClass.name}") + } + return + } + if (!PrivilegeSettingsStore.shouldRenameInterface()) return + if (!isTunInterface(result)) return + val prefix = PrivilegeSettingsStore.interfacePrefix() + val renamed = renameInterface(result, prefix) ?: return + param.result = renamed + } + + private fun findVpnClass(): Class<*> { + return XposedHelpers.findClass("com.android.server.connectivity.Vpn", classLoader) + } + + private fun isTunInterface(name: String): Boolean { + return name.startsWith("tun") + } + + private fun renameInterface(oldName: String, prefix: String): String? { + val oldIndex = getInterfaceIndex(oldName) + if (oldIndex <= 0) { + HookErrorStore.e(SOURCE, "rename interface: old name not found (old=$oldName)") + return null + } + val newName = findAvailableName(prefix) + if (newName == null) { + HookErrorStore.e(SOURCE, "rename interface: no available name (prefix=$prefix)") + return null + } + if (newName == oldName) { + return oldName + } + if (!renameWithNetlink(oldIndex, newName)) { + HookErrorStore.e(SOURCE, "rename failed: $oldName -> $newName") + return null + } + val newIndex = getInterfaceIndex(newName) + if (newIndex <= 0) { + HookErrorStore.e( + SOURCE, + "rename interface: new name not found (old=$oldName index=$oldIndex)", + ) + return null + } + HookErrorStore.i(SOURCE, "rename interface: $oldName -> $newName") + return newName + } + + private fun getInterfaceIndex(name: String): Int { + return Os.if_nametoindex(name) + } + + private fun findAvailableName(prefix: String): String? { + val base = prefix.trim() + if (base.isEmpty()) { + return null + } + for (i in 0..MAX_SUFFIX) { + val candidate = buildInterfaceName(base, i) ?: return null + if (getInterfaceIndex(candidate) == 0) { + return candidate + } + } + return null + } + + private fun buildInterfaceName(prefix: String, suffix: Int): String? { + val suffixText = suffix.toString() + val maxPrefixLen = MAX_NAME_LEN - suffixText.length + if (maxPrefixLen <= 0) { + return null + } + val trimmed = if (prefix.length > maxPrefixLen) { + prefix.substring(0, maxPrefixLen) + } else { + prefix + } + return trimmed + suffixText + } + + private fun renameWithNetlink(index: Int, newName: String): Boolean { + val fd = openNetlinkSocket() + try { + val renameResult = sendNetlinkMessage( + fd, + buildLinkMessage(index, newName, 0, 0, seq.getAndIncrement()), + OsConstants.EBUSY, + ) ?: return false + if (renameResult == 0) { + return true + } + if (renameResult != OsConstants.EBUSY) { + HookErrorStore.e(SOURCE, "rename interface: netlink ack errno=$renameResult") + return false + } + val downResult = sendNetlinkMessage( + fd, + buildLinkMessage(index, null, 0, IFF_UP, seq.getAndIncrement()), + ) ?: return false + if (downResult != 0) { + HookErrorStore.e(SOURCE, "rename interface: set down failed errno=$downResult") + return false + } + val retryResult = sendNetlinkMessage( + fd, + buildLinkMessage(index, newName, 0, 0, seq.getAndIncrement()), + ) ?: return false + if (retryResult != 0) { + HookErrorStore.e(SOURCE, "rename interface: retry failed errno=$retryResult") + return false + } + val upResult = sendNetlinkMessage( + fd, + buildLinkMessage(index, null, IFF_UP, IFF_UP, seq.getAndIncrement()), + ) + if (upResult != null && upResult != 0) { + HookErrorStore.w(SOURCE, "rename interface: set up failed errno=$upResult") + } + return true + } catch (e: Throwable) { + HookErrorStore.e(SOURCE, "rename interface: netlink exception", e) + return false + } finally { + try { + Os.close(fd) + } catch (e: Throwable) { + HookErrorStore.w(SOURCE, "close netlink socket failed", e) + } + } + } + + private fun openNetlinkSocket(): FileDescriptor { + val fd = Os.socket(OsConstants.AF_NETLINK, OsConstants.SOCK_RAW, OsConstants.NETLINK_ROUTE) + Os.setsockoptTimeval( + fd, + OsConstants.SOL_SOCKET, + OsConstants.SO_RCVTIMEO, + StructTimeval.fromMillis(200), + ) + val address = buildNetlinkAddress() + Os.connect(fd, address) + return fd + } + + private fun buildNetlinkAddress(): SocketAddress { + val cls = Class.forName("android.system.NetlinkSocketAddress") + val ctor = cls.getConstructor(Int::class.javaPrimitiveType, Int::class.javaPrimitiveType) + return ctor.newInstance(0, 0) as SocketAddress + } + + private fun buildLinkMessage( + index: Int, + ifName: String?, + flags: Int, + change: Int, + seq: Int, + ): ByteArray { + val nameBytes = ifName?.let { (it + "\u0000").toByteArray(Charsets.US_ASCII) } + val attrLen = if (nameBytes != null) NLA_HEADER_LEN + nameBytes.size else 0 + val attrAligned = align(attrLen) + val totalLength = NLMSG_HEADER_LEN + IFINFO_MSG_LEN + attrAligned + val buffer = ByteBuffer.allocate(totalLength).order(ByteOrder.nativeOrder()) + buffer.putInt(totalLength) + buffer.putShort(RTM_NEWLINK.toShort()) + buffer.putShort((NLM_F_REQUEST or NLM_F_ACK).toShort()) + buffer.putInt(seq) + buffer.putInt(Os.getpid()) + buffer.put(OsConstants.AF_UNSPEC.toByte()) + buffer.put(0.toByte()) + buffer.putShort(0) + buffer.putInt(index) + buffer.putInt(flags) + buffer.putInt(change) + if (nameBytes != null) { + buffer.putShort(attrLen.toShort()) + buffer.putShort(IFLA_IFNAME.toShort()) + buffer.put(nameBytes) + val pad = attrAligned - attrLen + repeat(pad) { + buffer.put(0.toByte()) + } + } + return buffer.array() + } + + private fun align(length: Int): Int { + return (length + 3) and -4 + } + + private fun sendNetlinkMessage( + fd: FileDescriptor, + message: ByteArray, + suppressErrno: Int? = null, + ): Int? { + Os.write(fd, message, 0, message.size) + val ack = readNetlinkAck(fd) + if (ack == null) { + HookErrorStore.e(SOURCE, "rename interface: netlink ack missing") + return null + } + if (ack.errno != 0 && ack.errno != suppressErrno) { + HookErrorStore.e( + SOURCE, + "rename interface: netlink ack errno=${ack.errno} seq=${ack.seq} pid=${ack.pid}", + ) + } + return ack.errno + } + + private data class NetlinkAck(val errno: Int, val seq: Int, val pid: Int) + + private fun readNetlinkAck(fd: FileDescriptor): NetlinkAck? { + val buffer = ByteArray(4096) + val length = Os.read(fd, buffer, 0, buffer.size) + if (length <= 0 || length < NLMSG_HEADER_LEN) { + return null + } + val byteBuffer = ByteBuffer.wrap(buffer, 0, length).order(ByteOrder.nativeOrder()) + val msgLen = byteBuffer.int + val msgType = byteBuffer.short.toInt() and 0xFFFF + byteBuffer.short + val msgSeq = byteBuffer.int + val msgPid = byteBuffer.int + if (msgLen < NLMSG_HEADER_LEN || msgLen > length) { + return null + } + if (msgType != NLMSG_ERROR) { + return NetlinkAck(0, msgSeq, msgPid) + } + if (byteBuffer.remaining() < 4) { + return null + } + val error = byteBuffer.int + val errno = if (error == 0) 0 else -error + return NetlinkAck(errno, msgSeq, msgPid) + } +} diff --git a/app/src/main/java/io/nekohasekai/sfa/xposed/hooks/hidevpnapp/PackageManager+getInstalledPackages.kt b/app/src/main/java/io/nekohasekai/sfa/xposed/hooks/hidevpnapp/PackageManager+getInstalledPackages.kt new file mode 100644 index 0000000..40caedd --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sfa/xposed/hooks/hidevpnapp/PackageManager+getInstalledPackages.kt @@ -0,0 +1,265 @@ +package io.nekohasekai.sfa.xposed.hooks.hidevpnapp + +import android.content.pm.ResolveInfo +import android.os.Binder +import android.os.Build +import android.os.Process +import de.robv.android.xposed.XC_MethodHook +import de.robv.android.xposed.XposedBridge +import de.robv.android.xposed.XposedHelpers +import io.nekohasekai.sfa.BuildConfig +import io.nekohasekai.sfa.xposed.HookErrorStore +import io.nekohasekai.sfa.xposed.PrivilegeSettingsStore +import io.nekohasekai.sfa.xposed.VpnAppStore +import io.nekohasekai.sfa.xposed.hooks.SafeMethodHook +import io.nekohasekai.sfa.xposed.hooks.XHook + +class HookPackageManagerGetInstalledPackages(private val classLoader: ClassLoader) : XHook { + private companion object { + private const val SOURCE = "HookPMGetInstalledPackages" + private const val PER_USER_RANGE = 100000 + } + + override fun injectHook() { + val hooked = ArrayList() + val sdk = Build.VERSION.SDK_INT + when { + sdk >= 35 /* VANILLA_ICE_CREAM */ -> { + hookAppsFilter33Plus(hooked) + hookArchivedPackageInternal(hooked) + } + sdk >= Build.VERSION_CODES.TIRAMISU -> { + hookAppsFilter33Plus(hooked) + } + sdk >= Build.VERSION_CODES.R -> { + hookAppsFilter30(hooked) + } + else -> { + hookPmsLegacy(hooked) + } + } + if (hooked.isNotEmpty()) { + HookErrorStore.i(SOURCE, "Hooked hide applist: ${hooked.joinToString()}") + } else { + HookErrorStore.w(SOURCE, "Hide applist hook not applied") + } + } + + private fun hookAppsFilter33Plus(hooked: MutableList) { + val cls = XposedHelpers.findClassIfExists("com.android.server.pm.AppsFilterImpl", classLoader) + if (cls == null) { + HookErrorStore.e(SOURCE, "Class com.android.server.pm.AppsFilterImpl not found") + return + } + val unhooks = try { + XposedBridge.hookAllMethods(cls, "shouldFilterApplication", object : SafeMethodHook(SOURCE) { + override fun beforeHook(param: MethodHookParam) { + val callingUid = param.args[1] as Int + val callerPackages = getCallerPackages(callingUid) ?: return + val target = param.args[3]!! + val targetPackage = extractPackageName(target) ?: return + if (shouldHidePackage(callingUid, callerPackages, targetPackage)) { + param.result = true + } + } + }) + } catch (e: Throwable) { + HookErrorStore.w(SOURCE, "Skip AppsFilterImpl.shouldFilterApplication: ${e.message}", e) + emptySet() + } + if (unhooks.isNotEmpty()) { + hooked.add("AppsFilterImpl.shouldFilterApplication") + } + } + + private fun hookAppsFilter30(hooked: MutableList) { + val cls = XposedHelpers.findClassIfExists("com.android.server.pm.AppsFilter", classLoader) + if (cls == null) { + HookErrorStore.e(SOURCE, "Class com.android.server.pm.AppsFilter not found") + return + } + val unhooks = try { + XposedBridge.hookAllMethods(cls, "shouldFilterApplication", object : SafeMethodHook(SOURCE) { + override fun beforeHook(param: MethodHookParam) { + val callingUid = param.args[0] as Int + val callerPackages = getCallerPackages(callingUid) ?: return + val target = param.args[2]!! + val targetPackage = extractPackageName(target) ?: return + if (shouldHidePackage(callingUid, callerPackages, targetPackage)) { + param.result = true + } + } + }) + } catch (e: Throwable) { + HookErrorStore.w(SOURCE, "Skip AppsFilter.shouldFilterApplication: ${e.message}", e) + emptySet() + } + if (unhooks.isNotEmpty()) { + hooked.add("AppsFilter.shouldFilterApplication") + } + } + + private fun hookArchivedPackageInternal(hooked: MutableList) { + val cls = XposedHelpers.findClassIfExists("com.android.server.pm.PackageManagerService", classLoader) + if (cls == null) { + HookErrorStore.e(SOURCE, "Class com.android.server.pm.PackageManagerService not found") + return + } + val unhooks = try { + XposedBridge.hookAllMethods(cls, "getArchivedPackageInternal", object : SafeMethodHook(SOURCE) { + override fun beforeHook(param: MethodHookParam) { + val callingUid = Binder.getCallingUid() + val callerPackages = getCallerPackages(callingUid) ?: return + val targetPackage = param.args[0]!!.toString() + if (shouldHidePackage(callingUid, callerPackages, targetPackage)) { + param.result = null + } + } + }) + } catch (e: Throwable) { + HookErrorStore.w(SOURCE, "Skip PackageManagerService.getArchivedPackageInternal: ${e.message}", e) + emptySet() + } + if (unhooks.isNotEmpty()) { + hooked.add("PackageManagerService.getArchivedPackageInternal") + } + } + + private fun hookPmsLegacy(hooked: MutableList) { + val cls = XposedHelpers.findClassIfExists("com.android.server.pm.PackageManagerService", classLoader) + if (cls == null) { + HookErrorStore.e(SOURCE, "Class com.android.server.pm.PackageManagerService not found") + return + } + val filterHooks = try { + XposedBridge.hookAllMethods(cls, "filterAppAccessLPr", object : SafeMethodHook(SOURCE) { + override fun beforeHook(param: MethodHookParam) { + val callingUid = param.args[1] as Int + val callerPackages = getCallerPackages(callingUid) ?: return + val target = param.args[0]!! + val targetPackage = extractPackageName(target) ?: return + if (shouldHidePackage(callingUid, callerPackages, targetPackage)) { + param.result = true + } + } + }) + } catch (e: Throwable) { + HookErrorStore.w(SOURCE, "Skip PackageManagerService.filterAppAccessLPr: ${e.message}", e) + emptySet() + } + if (filterHooks.isNotEmpty()) { + hooked.add("PackageManagerService.filterAppAccessLPr") + } + + val resolutionHooks = try { + XposedBridge.hookAllMethods(cls, "applyPostResolutionFilter", object : SafeMethodHook(SOURCE) { + override fun afterHook(param: MethodHookParam) { + val callingUid = param.args[3] as Int + val callerPackages = getCallerPackages(callingUid) ?: return + val rawResult = param.result ?: return + when (rawResult) { + is MutableCollection<*> -> { + @Suppress("UNCHECKED_CAST") + val result = rawResult as MutableCollection + val iterator = result.iterator() + while (iterator.hasNext()) { + val info = iterator.next() + val targetPackage = with(info) { + activityInfo?.packageName + ?: serviceInfo?.packageName + ?: providerInfo?.packageName + ?: resolvePackageName + } + if (targetPackage != null && + shouldHidePackage(callingUid, callerPackages, targetPackage) + ) { + iterator.remove() + } + } + } + is List<*> -> { + val filtered = rawResult.filterNot { item -> + val info = item as? ResolveInfo ?: return@filterNot false + val targetPackage = with(info) { + activityInfo?.packageName + ?: serviceInfo?.packageName + ?: providerInfo?.packageName + ?: resolvePackageName + } + targetPackage != null && + shouldHidePackage(callingUid, callerPackages, targetPackage) + } + param.result = filtered + } + } + } + }) + } catch (e: Throwable) { + HookErrorStore.w(SOURCE, "Skip PackageManagerService.applyPostResolutionFilter: ${e.message}", e) + emptySet() + } + if (resolutionHooks.isNotEmpty()) { + hooked.add("PackageManagerService.applyPostResolutionFilter") + } + } + + private fun getCallerPackages(callingUid: Int): List? { + if (callingUid < Process.FIRST_APPLICATION_UID) { + return null + } + if (!PrivilegeSettingsStore.shouldHideUid(callingUid)) { + return null + } + val packages = VpnAppStore.getPackagesForUid(callingUid) + if (packages.isEmpty()) { + return null + } + if (packages.contains(BuildConfig.APPLICATION_ID)) { + return null + } + return packages + } + + private fun shouldHidePackage( + callingUid: Int, + callerPackages: List, + targetPackage: String, + ): Boolean { + if (callerPackages.contains(targetPackage)) { + return false + } + val userId = callingUid / PER_USER_RANGE + if (!VpnAppStore.isVpnPackage(targetPackage, userId)) { + return false + } + return true + } + + private fun extractPackageName(arg: Any?): String? { + if (arg == null) return null + try { + val method = arg.javaClass.getMethod("getPackageName") + val result = method.invoke(arg) as String? + if (!result.isNullOrEmpty()) { + return result + } + } catch (_: NoSuchMethodException) { + } catch (e: Throwable) { + HookErrorStore.w(SOURCE, "extractPackageName via getPackageName() failed for ${arg.javaClass.name}", e) + } + val fields = arrayOf("packageName", "mPackageName", "name", "mName") + for (name in fields) { + val field = XposedHelpers.findFieldIfExists(arg.javaClass, name) ?: continue + try { + val result = field.get(arg) as String? + if (!result.isNullOrEmpty()) { + return result + } + } catch (e: Throwable) { + HookErrorStore.w(SOURCE, "extractPackageName via field $name failed for ${arg.javaClass.name}", e) + } + } + HookErrorStore.w(SOURCE, "extractPackageName failed for ${arg.javaClass.name}") + return null + } +} diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 0224479..568556b 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -403,4 +403,42 @@ 暂停日志 折叠搜索 搜索日志 + + 特权增强 + 特权模块 + 隐藏设置 + 对选中应用抵抗 VPN 检测 + 管理 + 运行测试 + 测试结果 + 正在运行检测测试… + 未检测到 + 接口: + HTTP 代理: + 接口重命名 + 接口前缀 + VPN 检测 + 查看日志 + 导出调试信息 + 暂无日志 + 导出完成 + 此文件带有隐私内容,不应该发布到公共场合。大小:%s + 导出调试信息失败:%s + 警告 + 该应用是 VPN 应用,抵抗 VPN 检测可能造成问题:%1$s + 这些应用是 VPN 应用,抵抗 VPN 检测可能造成问题:%1$s + 该应用具有网络管理权限,抵抗 VPN 检测可能造成问题:%1$s + 这些应用具有网络管理权限,抵抗 VPN 检测可能造成问题:%1$s + 需要重启 + LSPosed 模块已更新,请重启以应用改动。 + 重新启动 + 请求重启失败:%1$s + 需要重启 + LSPosed 模块已更新,请重启以应用改动。 + LSPosed 模块 + LSPosed 模块 + LSPosed 已激活 + LSPosed 待更新 + LSPosed 待降级 + LSPosed 未激活 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 364e583..a4c25fc 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -259,6 +259,7 @@ Check Update + Force Download and Install Automatic Update Check Would you like to enable automatic update checking from **Play Store**? Would you like to enable automatic update checking from **GitHub**? @@ -268,6 +269,7 @@ Current track does not support update checking yet View Release Downloading… + Exporting… No updates available New version available: %s Auto Update @@ -408,4 +410,48 @@ Pause logs Collapse search Search logs + + + Privileged Enhancement for sing-box + + + Privilege modules + LSPosed Module + LSPosed activated + LSPosed update pending + LSPosed downgrade pending + LSPosed not activated + System Framework only. + Privileged Enhancement + Hide Settings + Resist VPN detection for selected apps + Manage + Run Test + Test Result + Running detection tests… + Not detected + Interfaces: + HTTP proxy: + Interface Rename + Interface prefix + VPN Detection + View logs + Export debug info + No logs + Export complete + This file contains private information and should not be shared publicly. Size: %s + Failed to export debug info: %s + Warning + This app is a VPN app. Resisting VPN detection may cause issues: %1$s + These apps are VPN apps. Resisting VPN detection may cause issues: %1$s + This app has network management permissions. Resisting VPN detection may cause issues: %1$s + These apps have network management permissions. Resisting VPN detection may cause issues: %1$s + Reboot required + LSPosed module updated. Reboot to apply changes. + Restart + Failed to request reboot: %1$s + Reboot required + LSPosed module updated. Reboot to apply changes. + LSPosed Module + Reboot required diff --git a/app/src/main/resources/META-INF/xposed/java_init.list b/app/src/main/resources/META-INF/xposed/java_init.list new file mode 100644 index 0000000..54a7373 --- /dev/null +++ b/app/src/main/resources/META-INF/xposed/java_init.list @@ -0,0 +1 @@ +io.nekohasekai.sfa.xposed.XposedInit diff --git a/app/src/main/resources/META-INF/xposed/module.prop b/app/src/main/resources/META-INF/xposed/module.prop new file mode 100644 index 0000000..8dc7ff3 --- /dev/null +++ b/app/src/main/resources/META-INF/xposed/module.prop @@ -0,0 +1,3 @@ +minApiVersion=100 +targetApiVersion=100 +staticScope=true diff --git a/app/src/main/resources/META-INF/xposed/scope.list b/app/src/main/resources/META-INF/xposed/scope.list new file mode 100644 index 0000000..bec3a35 --- /dev/null +++ b/app/src/main/resources/META-INF/xposed/scope.list @@ -0,0 +1 @@ +system diff --git a/app/src/minApi21/java/io/nekohasekai/sfa/vendor/PackageQueryManager.kt b/app/src/minApi21/java/io/nekohasekai/sfa/vendor/PackageQueryManager.kt index e0ccd52..cfab496 100644 --- a/app/src/minApi21/java/io/nekohasekai/sfa/vendor/PackageQueryManager.kt +++ b/app/src/minApi21/java/io/nekohasekai/sfa/vendor/PackageQueryManager.kt @@ -4,37 +4,63 @@ import android.content.pm.PackageInfo import android.content.pm.PackageManager import android.os.Build import io.nekohasekai.sfa.Application +import io.nekohasekai.sfa.BuildConfig +import io.nekohasekai.sfa.bg.RootClient +import io.nekohasekai.sfa.database.Settings +import io.nekohasekai.sfa.utils.HookStatusClient import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow object PackageQueryManager { - val needsPrivilegedQuery: Boolean = false + val strategy: PackageQueryStrategy + get() = when { + HookStatusClient.status.value?.active == true -> PackageQueryStrategy.ForcedRoot + BuildConfig.FLAVOR == "play" -> PackageQueryStrategy.UserSelected(queryMode.value) + else -> PackageQueryStrategy.Direct + } - private val _queryMode = MutableStateFlow("") + val showModeSelector: Boolean + get() = strategy is PackageQueryStrategy.UserSelected + + private val _queryMode = MutableStateFlow(Settings.perAppProxyPackageQueryMode) val queryMode: StateFlow = _queryMode val shizukuInstalled: StateFlow = MutableStateFlow(false) val shizukuBinderReady: StateFlow = MutableStateFlow(false) val shizukuPermissionGranted: StateFlow = MutableStateFlow(false) - val rootAvailable: StateFlow = MutableStateFlow(null) - val rootServiceConnected: StateFlow = MutableStateFlow(false) + val rootAvailable: StateFlow get() = RootClient.rootAvailable + val rootServiceConnected: StateFlow get() = RootClient.serviceConnected fun isShizukuAvailable(): Boolean = false - fun registerListeners() {} + fun registerListeners() { + _queryMode.value = Settings.perAppProxyPackageQueryMode + } fun unregisterListeners() {} fun requestShizukuPermission() {} - suspend fun checkRootAvailable(): Boolean = false + fun refreshShizukuState() {} + + suspend fun checkRootAvailable(): Boolean { + return RootClient.checkRootAvailable() + } fun setQueryMode(mode: String) { _queryMode.value = mode } suspend fun getInstalledPackages(flags: Int): List { + return when (val s = strategy) { + is PackageQueryStrategy.ForcedRoot -> RootClient.getInstalledPackages(flags) + is PackageQueryStrategy.UserSelected -> RootClient.getInstalledPackages(flags) + is PackageQueryStrategy.Direct -> getPackagesViaPackageManager(flags) + } + } + + private fun getPackagesViaPackageManager(flags: Int): List { return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { Application.packageManager.getInstalledPackages( PackageManager.PackageInfoFlags.of(flags.toLong()) diff --git a/app/src/minApi23/aidl/io/nekohasekai/sfa/vendor/IRootPackageManager.aidl b/app/src/minApi23/aidl/io/nekohasekai/sfa/vendor/IRootPackageManager.aidl deleted file mode 100644 index a4a4f4b..0000000 --- a/app/src/minApi23/aidl/io/nekohasekai/sfa/vendor/IRootPackageManager.aidl +++ /dev/null @@ -1,7 +0,0 @@ -package io.nekohasekai.sfa.vendor; - -import android.content.pm.PackageInfo; - -interface IRootPackageManager { - List getInstalledPackages(int flags, int offset, int limit); -} diff --git a/app/src/minApi23/java/io/nekohasekai/sfa/vendor/PackageQueryManager.kt b/app/src/minApi23/java/io/nekohasekai/sfa/vendor/PackageQueryManager.kt index 86d7c11..e1543d7 100644 --- a/app/src/minApi23/java/io/nekohasekai/sfa/vendor/PackageQueryManager.kt +++ b/app/src/minApi23/java/io/nekohasekai/sfa/vendor/PackageQueryManager.kt @@ -1,34 +1,27 @@ package io.nekohasekai.sfa.vendor -import android.Manifest import android.content.pm.PackageInfo import android.content.pm.PackageManager import android.os.Build -import android.util.Log import io.nekohasekai.sfa.Application +import io.nekohasekai.sfa.BuildConfig +import io.nekohasekai.sfa.bg.RootClient import io.nekohasekai.sfa.database.Settings +import io.nekohasekai.sfa.utils.HookStatusClient import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow object PackageQueryManager { - private const val TAG = "PackageQueryManager" - - val needsPrivilegedQuery: Boolean by lazy { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { - // Check if QUERY_ALL_PACKAGES is declared in manifest - val packageInfo = Application.packageManager.getPackageInfo( - Application.application.packageName, - PackageManager.GET_PERMISSIONS - ) - val hasPermission = packageInfo.requestedPermissions?.contains( - Manifest.permission.QUERY_ALL_PACKAGES - ) == true - !hasPermission - } else { - false + val strategy: PackageQueryStrategy + get() = when { + HookStatusClient.status.value?.active == true -> PackageQueryStrategy.ForcedRoot + BuildConfig.FLAVOR == "play" -> PackageQueryStrategy.UserSelected(queryMode.value) + else -> PackageQueryStrategy.Direct } - } + + val showModeSelector: Boolean + get() = strategy is PackageQueryStrategy.UserSelected private val _queryMode = MutableStateFlow(Settings.perAppProxyPackageQueryMode) val queryMode: StateFlow = _queryMode @@ -36,8 +29,8 @@ object PackageQueryManager { val shizukuInstalled: StateFlow get() = ShizukuPackageManager.shizukuInstalled val shizukuBinderReady: StateFlow get() = ShizukuPackageManager.binderReady val shizukuPermissionGranted: StateFlow get() = ShizukuPackageManager.permissionGranted - val rootAvailable: StateFlow get() = RootPackageManager.rootAvailable - val rootServiceConnected: StateFlow get() = RootPackageManager.serviceConnected + val rootAvailable: StateFlow get() = RootClient.rootAvailable + val rootServiceConnected: StateFlow get() = RootClient.serviceConnected fun isShizukuAvailable(): Boolean = ShizukuPackageManager.isAvailable() && ShizukuPackageManager.checkPermission() @@ -55,8 +48,12 @@ object PackageQueryManager { ShizukuPackageManager.requestPermission() } + fun refreshShizukuState() { + ShizukuPackageManager.refresh() + } + suspend fun checkRootAvailable(): Boolean { - return RootPackageManager.checkRootAvailable() + return RootClient.checkRootAvailable() } fun setQueryMode(mode: String) { @@ -64,26 +61,14 @@ object PackageQueryManager { } suspend fun getInstalledPackages(flags: Int): List { - if (!needsPrivilegedQuery) { - return getPackagesViaPackageManager(flags) - } - - val mode = _queryMode.value - - if (mode == Settings.PACKAGE_QUERY_MODE_ROOT) { - if (rootAvailable.value != true) { - val isAvailable = RootPackageManager.checkRootAvailable() - if (!isAvailable) { - throw PrivilegedAccessRequiredException("ROOT access required") - } + return when (val s = strategy) { + is PackageQueryStrategy.ForcedRoot -> RootClient.getInstalledPackages(flags) + is PackageQueryStrategy.UserSelected -> when (s.mode) { + Settings.PACKAGE_QUERY_MODE_ROOT -> RootClient.getInstalledPackages(flags) + else -> ShizukuPackageManager.getInstalledPackages(flags) } - return RootPackageManager.getInstalledPackages(flags) + is PackageQueryStrategy.Direct -> getPackagesViaPackageManager(flags) } - - if (!isShizukuAvailable()) { - throw PrivilegedAccessRequiredException("Shizuku access required") - } - return ShizukuPackageManager.getInstalledPackages(flags) } private fun getPackagesViaPackageManager(flags: Int): List { diff --git a/app/src/minApi23/java/io/nekohasekai/sfa/vendor/RootPackageManagerService.kt b/app/src/minApi23/java/io/nekohasekai/sfa/vendor/RootPackageManagerService.kt deleted file mode 100644 index 4349c80..0000000 --- a/app/src/minApi23/java/io/nekohasekai/sfa/vendor/RootPackageManagerService.kt +++ /dev/null @@ -1,33 +0,0 @@ -package io.nekohasekai.sfa.vendor - -import android.content.Intent -import android.content.pm.PackageInfo -import android.content.pm.PackageManager -import android.os.Build -import android.os.IBinder -import com.topjohnwu.superuser.ipc.RootService - -class RootPackageManagerService : RootService() { - - private val binder = object : IRootPackageManager.Stub() { - override fun getInstalledPackages(flags: Int, offset: Int, limit: Int): List { - val allPackages = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - packageManager.getInstalledPackages( - PackageManager.PackageInfoFlags.of(flags.toLong()) - ) - } else { - @Suppress("DEPRECATION") - packageManager.getInstalledPackages(flags) - } - val endIndex = minOf(offset + limit, allPackages.size) - if (offset >= allPackages.size) { - return emptyList() - } - return allPackages.subList(offset, endIndex) - } - } - - override fun onBind(intent: Intent): IBinder { - return binder - } -} diff --git a/app/src/minApi23/java/io/nekohasekai/sfa/vendor/ShizukuInstaller.kt b/app/src/minApi23/java/io/nekohasekai/sfa/vendor/ShizukuInstaller.kt index 32dd75b..65bdeba 100644 --- a/app/src/minApi23/java/io/nekohasekai/sfa/vendor/ShizukuInstaller.kt +++ b/app/src/minApi23/java/io/nekohasekai/sfa/vendor/ShizukuInstaller.kt @@ -1,25 +1,12 @@ 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.PackageInstaller import android.content.pm.PackageManager -import android.os.Build +import android.os.ParcelFileDescriptor import android.os.Process -import io.nekohasekai.sfa.vendor.hidden.IPackageManager import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext -import org.lsposed.hiddenapibypass.HiddenApiBypass import rikka.shizuku.Shizuku -import rikka.shizuku.ShizukuBinderWrapper -import rikka.shizuku.SystemServiceHelper import java.io.File -import java.io.FileInputStream -import java.util.concurrent.CountDownLatch -import java.util.concurrent.TimeUnit -import android.content.IIntentSender object ShizukuInstaller { @@ -59,127 +46,11 @@ object ShizukuInstaller { } } - private fun getPackageInstaller(): IPackageInstaller { - val packageManagerBinder = SystemServiceHelper.getSystemService("package") - val packageManager = IPackageManager.Stub.asInterface(ShizukuBinderWrapper(packageManagerBinder)) - val installerBinder = packageManager.packageInstaller.asBinder() - return IPackageInstaller.Stub.asInterface(ShizukuBinderWrapper(installerBinder)) - } - - private fun createPackageInstaller( - installer: IPackageInstaller, - installerPackageName: String, - installerAttributionTag: String?, - userId: Int - ): PackageInstaller { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { - return PackageInstaller::class.java - .getConstructor( - IPackageInstaller::class.java, - String::class.java, - String::class.java, - Int::class.javaPrimitiveType - ) - .newInstance(installer, installerPackageName, installerAttributionTag, userId) - } else { - return 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: android.os.Bundle? - ) { - onResult(intent) - } - } - return IntentSender::class.java - .getConstructor(IIntentSender::class.java) - .newInstance(sender) - } - - suspend fun install(apkFile: File): Result = withContext(Dispatchers.IO) { - try { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { - HiddenApiBypass.addHiddenApiExemptions("") - } - - val iPackageInstaller = getPackageInstaller() - val isRoot = isRunningAsRoot() - - val installerPackageName = if (isRoot) "io.nekohasekai.sfa" else "com.android.shell" - val installerAttributionTag: String? = null - val userId = if (isRoot) Process.myUserHandle().hashCode() else 0 - - val packageInstaller = createPackageInstaller( - iPackageInstaller, - installerPackageName, - installerAttributionTag, - userId - ) - - val params = PackageInstaller.SessionParams(PackageInstaller.SessionParams.MODE_FULL_INSTALL) - val sessionId = packageInstaller.createSession(params) - - val iSession = IPackageInstallerSession.Stub.asInterface( - ShizukuBinderWrapper(iPackageInstaller.openSession(sessionId).asBinder()) - ) - val session = createSession(iSession) - - try { - FileInputStream(apkFile).use { inputStream -> - session.openWrite("base.apk", 0, apkFile.length()).use { outputStream -> - inputStream.copyTo(outputStream) - session.fsync(outputStream) - } - } - - val resultIntent = arrayOfNulls(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] - ?: return@withContext Result.failure(Exception("Installation timed out")) - - val status = intent.getIntExtra(PackageInstaller.EXTRA_STATUS, PackageInstaller.STATUS_FAILURE) - val message = intent.getStringExtra(PackageInstaller.EXTRA_STATUS_MESSAGE) - - if (status == PackageInstaller.STATUS_SUCCESS) { - Result.success(Unit) - } else { - Result.failure(Exception("Installation failed: $status - $message")) - } - } finally { - session.close() - } - } catch (e: Exception) { - Result.failure(e) + suspend fun install(apkFile: File) = withContext(Dispatchers.IO) { + val service = ShizukuPrivilegedServiceClient.getService() + val userId = if (isRunningAsRoot()) Process.myUserHandle().hashCode() else 0 + ParcelFileDescriptor.open(apkFile, ParcelFileDescriptor.MODE_READ_ONLY).use { pfd -> + service.installPackage(pfd, apkFile.length(), userId) } } } diff --git a/app/src/minApi23/java/io/nekohasekai/sfa/vendor/ShizukuPackageManager.kt b/app/src/minApi23/java/io/nekohasekai/sfa/vendor/ShizukuPackageManager.kt index 6afffe2..f2836bd 100644 --- a/app/src/minApi23/java/io/nekohasekai/sfa/vendor/ShizukuPackageManager.kt +++ b/app/src/minApi23/java/io/nekohasekai/sfa/vendor/ShizukuPackageManager.kt @@ -2,15 +2,13 @@ package io.nekohasekai.sfa.vendor import android.content.pm.PackageInfo import android.content.pm.PackageManager -import android.os.Build -import android.os.IBinder +import android.os.Process import io.nekohasekai.sfa.Application import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow -import org.lsposed.hiddenapibypass.HiddenApiBypass +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext import rikka.shizuku.Shizuku -import rikka.shizuku.ShizukuBinderWrapper -import rikka.shizuku.SystemServiceHelper object ShizukuPackageManager { @@ -33,19 +31,19 @@ object ShizukuPackageManager { private val binderDeadListener = Shizuku.OnBinderDeadListener { _binderReady.value = false _permissionGranted.value = false + ShizukuPrivilegedServiceClient.reset() } private val permissionResultListener = Shizuku.OnRequestPermissionResultListener { _, grantResult -> _permissionGranted.value = grantResult == PackageManager.PERMISSION_GRANTED + _binderReady.value = isAvailable() } fun registerListeners() { Shizuku.addBinderReceivedListenerSticky(binderReceivedListener) Shizuku.addBinderDeadListener(binderDeadListener) Shizuku.addRequestPermissionResultListener(permissionResultListener) - _shizukuInstalled.value = isShizukuInstalled() - _binderReady.value = isAvailable() - _permissionGranted.value = checkPermission() + refresh() } fun isShizukuInstalled(): Boolean { @@ -69,46 +67,17 @@ object ShizukuPackageManager { fun requestPermission() = ShizukuInstaller.requestPermission() - fun getInstalledPackages(flags: Int): List { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { - HiddenApiBypass.addHiddenApiExemptions("") - } - - val packageManagerBinder = SystemServiceHelper.getSystemService("package") - val wrappedBinder = ShizukuBinderWrapper(packageManagerBinder) - - val iPackageManagerClass = Class.forName("android.content.pm.IPackageManager") - val stubClass = Class.forName("android.content.pm.IPackageManager\$Stub") - val asInterfaceMethod = stubClass.getMethod("asInterface", IBinder::class.java) - val iPackageManager = asInterfaceMethod.invoke(null, wrappedBinder) - - val userId = android.os.Process.myUserHandle().hashCode() - - 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 refresh() { + _shizukuInstalled.value = isShizukuInstalled() + _binderReady.value = isAvailable() + _permissionGranted.value = checkPermission() } - @Suppress("UNCHECKED_CAST") - private fun extractPackageList(parceledListSlice: Any?): List { - if (parceledListSlice == null) return emptyList() - - val getListMethod = parceledListSlice.javaClass.getMethod("getList") - val list = getListMethod.invoke(parceledListSlice) as? List<*> - return list?.filterIsInstance() ?: emptyList() + suspend fun getInstalledPackages(flags: Int): List = withContext(Dispatchers.IO) { + val service = ShizukuPrivilegedServiceClient.getService() + val userId = Process.myUserHandle().hashCode() + val slice = service.getInstalledPackages(flags, userId) + @Suppress("UNCHECKED_CAST") + slice.list as List } } diff --git a/app/src/minApi23/java/io/nekohasekai/sfa/vendor/ShizukuPrivilegedService.kt b/app/src/minApi23/java/io/nekohasekai/sfa/vendor/ShizukuPrivilegedService.kt new file mode 100644 index 0000000..88d39a8 --- /dev/null +++ b/app/src/minApi23/java/io/nekohasekai/sfa/vendor/ShizukuPrivilegedService.kt @@ -0,0 +1,24 @@ +package io.nekohasekai.sfa.vendor + +import android.content.pm.PackageInfo +import android.os.ParcelFileDescriptor +import io.nekohasekai.sfa.bg.IShizukuService +import io.nekohasekai.sfa.bg.ParceledListSlice +import java.io.IOException + +class ShizukuPrivilegedService : IShizukuService.Stub() { + + override fun destroy() { + System.exit(0) + } + + override fun getInstalledPackages(flags: Int, userId: Int): ParceledListSlice { + 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) + } +} diff --git a/app/src/minApi23/java/io/nekohasekai/sfa/vendor/ShizukuPrivilegedServiceClient.kt b/app/src/minApi23/java/io/nekohasekai/sfa/vendor/ShizukuPrivilegedServiceClient.kt new file mode 100644 index 0000000..6b10f66 --- /dev/null +++ b/app/src/minApi23/java/io/nekohasekai/sfa/vendor/ShizukuPrivilegedServiceClient.kt @@ -0,0 +1,86 @@ +package io.nekohasekai.sfa.vendor + +import android.content.ComponentName +import android.content.ServiceConnection +import android.os.IBinder +import io.nekohasekai.sfa.Application +import io.nekohasekai.sfa.BuildConfig +import io.nekohasekai.sfa.bg.IShizukuService +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext +import rikka.shizuku.Shizuku +import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException + +object ShizukuPrivilegedServiceClient { + + private val serviceMutex = Mutex() + private var service: IShizukuService? = null + private var connection: ServiceConnection? = null + + private val args = Shizuku.UserServiceArgs( + ComponentName(Application.application, ShizukuPrivilegedService::class.java) + ) + .tag("sfa-privileged") + .processNameSuffix("privileged") + .version(BuildConfig.VERSION_CODE) + .debuggable(BuildConfig.DEBUG) + + suspend fun getService(): IShizukuService = serviceMutex.withLock { + service?.let { return it } + return withContext(Dispatchers.Main) { + suspendCancellableCoroutine { continuation -> + val conn = object : ServiceConnection { + override fun onServiceConnected(name: ComponentName?, binder: IBinder?) { + val svc = if (binder != null && binder.pingBinder()) { + IShizukuService.Stub.asInterface(binder) + } else { + null + } + if (svc == null) { + continuation.resumeWithException(IllegalStateException("Invalid Shizuku service binder")) + return + } + service = svc + connection = this + continuation.resume(svc) + } + + override fun onServiceDisconnected(name: ComponentName?) { + service = null + connection = null + } + } + + try { + Shizuku.bindUserService(args, conn) + } catch (e: Throwable) { + continuation.resumeWithException(e) + return@suspendCancellableCoroutine + } + + continuation.invokeOnCancellation { + try { + Shizuku.unbindUserService(args, conn, false) + } catch (_: Throwable) { + // Ignore + } + } + } + } + } + + fun reset() { + val conn = connection ?: return + service = null + connection = null + try { + Shizuku.unbindUserService(args, conn, false) + } catch (_: Throwable) { + // Ignore + } + } +} diff --git a/app/src/minApi23/java/io/nekohasekai/sfa/vendor/hidden/IPackageManager.java b/app/src/minApi23/java/io/nekohasekai/sfa/vendor/hidden/IPackageManager.java deleted file mode 100644 index 21eeaac..0000000 --- a/app/src/minApi23/java/io/nekohasekai/sfa/vendor/hidden/IPackageManager.java +++ /dev/null @@ -1,19 +0,0 @@ -package io.nekohasekai.sfa.vendor.hidden; - -import android.os.Binder; -import android.os.IBinder; -import android.os.IInterface; -import android.os.RemoteException; - -import android.content.pm.IPackageInstaller; - -public interface IPackageManager extends IInterface { - - IPackageInstaller getPackageInstaller() throws RemoteException; - - abstract class Stub extends Binder implements IPackageManager { - public static IPackageManager asInterface(IBinder binder) { - throw new UnsupportedOperationException(); - } - } -} diff --git a/app/src/other/AndroidManifest.xml b/app/src/other/AndroidManifest.xml index 671d1c7..25abe5e 100644 --- a/app/src/other/AndroidManifest.xml +++ b/app/src/other/AndroidManifest.xml @@ -14,7 +14,7 @@ android:permission="android.permission.INTERACT_ACROSS_USERS_FULL" /> diff --git a/app/src/other/java/io/nekohasekai/sfa/vendor/ApkInstaller.kt b/app/src/other/java/io/nekohasekai/sfa/vendor/ApkInstaller.kt index 458c6a3..a07226b 100644 --- a/app/src/other/java/io/nekohasekai/sfa/vendor/ApkInstaller.kt +++ b/app/src/other/java/io/nekohasekai/sfa/vendor/ApkInstaller.kt @@ -1,7 +1,10 @@ package io.nekohasekai.sfa.vendor import android.content.Context +import io.nekohasekai.sfa.Application import io.nekohasekai.sfa.database.Settings +import io.nekohasekai.sfa.utils.HookStatusClient +import io.nekohasekai.sfa.xposed.XposedActivation import java.io.File enum class InstallMethod { @@ -13,6 +16,11 @@ enum class InstallMethod { object ApkInstaller { fun getConfiguredMethod(): InstallMethod { + if (HookStatusClient.status.value?.active == true || + XposedActivation.isActivated(Application.application) + ) { + return InstallMethod.ROOT + } return if (Settings.silentInstallEnabled) { InstallMethod.valueOf(Settings.silentInstallMethod) } else { @@ -20,8 +28,8 @@ object ApkInstaller { } } - suspend fun install(context: Context, apkFile: File, method: InstallMethod = getConfiguredMethod()): Result { - return when (method) { + suspend fun install(context: Context, apkFile: File, method: InstallMethod = getConfiguredMethod()) { + when (method) { InstallMethod.SHIZUKU -> ShizukuInstaller.install(apkFile) InstallMethod.ROOT -> RootInstaller.install(apkFile) InstallMethod.PACKAGE_INSTALLER -> SystemPackageInstaller.install(context, apkFile) diff --git a/app/src/other/java/io/nekohasekai/sfa/vendor/Vendor.kt b/app/src/other/java/io/nekohasekai/sfa/vendor/Vendor.kt index 64b8b76..e422d6b 100644 --- a/app/src/other/java/io/nekohasekai/sfa/vendor/Vendor.kt +++ b/app/src/other/java/io/nekohasekai/sfa/vendor/Vendor.kt @@ -9,7 +9,7 @@ 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.ui.profile.QRCodeCropArea +import io.nekohasekai.sfa.compose.screen.qrscan.QRCodeCropArea import io.nekohasekai.sfa.update.UpdateCheckException import io.nekohasekai.sfa.update.UpdateInfo import io.nekohasekai.sfa.update.UpdateState @@ -108,6 +108,13 @@ object Vendor : VendorInterface { } } + override fun forceGetLatestUpdate(): UpdateInfo? { + val track = UpdateTrack.fromString(Settings.updateTrack) + return GitHubUpdateChecker().use { checker -> + checker.forceGetLatestUpdate(track) + } + } + override fun supportsSilentInstall(): Boolean { return true } @@ -123,8 +130,7 @@ object Vendor : VendorInterface { override suspend fun verifySilentInstallMethod(method: String): Boolean { return when (method) { "PACKAGE_INSTALLER" -> { - ApkInstaller.canSystemSilentInstall() && - Application.application.packageManager.canRequestPackageInstalls() + ApkInstaller.canSystemSilentInstall() } "SHIZUKU" -> { if (!ShizukuInstaller.isAvailable()) { @@ -141,17 +147,13 @@ object Vendor : VendorInterface { } } - override suspend fun downloadAndInstall(context: android.content.Context, downloadUrl: String): Result { - return try { - val cachedApk = UpdateState.cachedApkFile.value - val apkFile = if (cachedApk != null && cachedApk.exists() && cachedApk.length() > 0) { - cachedApk - } else { - ApkDownloader().use { it.download(downloadUrl) } - } - ApkInstaller.install(context, apkFile) - } catch (e: Exception) { - Result.failure(e) + override suspend fun downloadAndInstall(context: android.content.Context, downloadUrl: String) { + val cachedApk = UpdateState.cachedApkFile.value + val apkFile = if (cachedApk != null && cachedApk.exists() && cachedApk.length() > 0) { + cachedApk + } else { + ApkDownloader().use { it.download(downloadUrl) } } + ApkInstaller.install(context, apkFile) } } diff --git a/app/src/otherLegacy/AndroidManifest.xml b/app/src/otherLegacy/AndroidManifest.xml index e874170..cc11d4d 100644 --- a/app/src/otherLegacy/AndroidManifest.xml +++ b/app/src/otherLegacy/AndroidManifest.xml @@ -4,8 +4,9 @@ + android:name=".bg.RootServer" + android:exported="false" + tools:ignore="Instantiatable" /> diff --git a/app/src/otherLegacy/java/io/nekohasekai/sfa/vendor/ApkInstaller.kt b/app/src/otherLegacy/java/io/nekohasekai/sfa/vendor/ApkInstaller.kt index df0ffb7..39bb4b5 100644 --- a/app/src/otherLegacy/java/io/nekohasekai/sfa/vendor/ApkInstaller.kt +++ b/app/src/otherLegacy/java/io/nekohasekai/sfa/vendor/ApkInstaller.kt @@ -1,7 +1,10 @@ package io.nekohasekai.sfa.vendor import android.content.Context +import io.nekohasekai.sfa.Application import io.nekohasekai.sfa.database.Settings +import io.nekohasekai.sfa.utils.HookStatusClient +import io.nekohasekai.sfa.xposed.XposedActivation import java.io.File enum class InstallMethod { @@ -12,6 +15,11 @@ enum class InstallMethod { object ApkInstaller { fun getConfiguredMethod(): InstallMethod { + if (HookStatusClient.status.value?.active == true || + XposedActivation.isActivated(Application.application) + ) { + return InstallMethod.ROOT + } return if (Settings.silentInstallEnabled) { val method = Settings.silentInstallMethod if (method == "SHIZUKU") InstallMethod.ROOT else InstallMethod.valueOf(method) @@ -20,8 +28,8 @@ object ApkInstaller { } } - suspend fun install(context: Context, apkFile: File, method: InstallMethod = getConfiguredMethod()): Result { - return when (method) { + suspend fun install(context: Context, apkFile: File, method: InstallMethod = getConfiguredMethod()) { + when (method) { InstallMethod.ROOT -> RootInstaller.install(apkFile) InstallMethod.PACKAGE_INSTALLER -> SystemPackageInstaller.install(context, apkFile) } diff --git a/app/src/otherLegacy/java/io/nekohasekai/sfa/vendor/Vendor.kt b/app/src/otherLegacy/java/io/nekohasekai/sfa/vendor/Vendor.kt index 847649c..5969174 100644 --- a/app/src/otherLegacy/java/io/nekohasekai/sfa/vendor/Vendor.kt +++ b/app/src/otherLegacy/java/io/nekohasekai/sfa/vendor/Vendor.kt @@ -9,7 +9,7 @@ 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.ui.profile.QRCodeCropArea +import io.nekohasekai.sfa.compose.screen.qrscan.QRCodeCropArea import io.nekohasekai.sfa.update.UpdateCheckException import io.nekohasekai.sfa.update.UpdateInfo import io.nekohasekai.sfa.update.UpdateState @@ -123,25 +123,20 @@ object Vendor : VendorInterface { override suspend fun verifySilentInstallMethod(method: String): Boolean { return when (method) { "PACKAGE_INSTALLER" -> { - ApkInstaller.canSystemSilentInstall() && - Application.application.packageManager.canRequestPackageInstalls() + ApkInstaller.canSystemSilentInstall() } "ROOT" -> RootInstaller.checkAccess() else -> false } } - override suspend fun downloadAndInstall(context: android.content.Context, downloadUrl: String): Result { - return try { - val cachedApk = UpdateState.cachedApkFile.value - val apkFile = if (cachedApk != null && cachedApk.exists() && cachedApk.length() > 0) { - cachedApk - } else { - ApkDownloader().use { it.download(downloadUrl) } - } - ApkInstaller.install(context, apkFile) - } catch (e: Exception) { - Result.failure(e) + override suspend fun downloadAndInstall(context: android.content.Context, downloadUrl: String) { + val cachedApk = UpdateState.cachedApkFile.value + val apkFile = if (cachedApk != null && cachedApk.exists() && cachedApk.length() > 0) { + cachedApk + } else { + ApkDownloader().use { it.download(downloadUrl) } } + ApkInstaller.install(context, apkFile) } } diff --git a/app/src/play/AndroidManifest.xml b/app/src/play/AndroidManifest.xml index ac3e8b7..ee49f03 100644 --- a/app/src/play/AndroidManifest.xml +++ b/app/src/play/AndroidManifest.xml @@ -16,7 +16,7 @@ android:permission="android.permission.INTERACT_ACROSS_USERS_FULL" /> diff --git a/app/src/play/java/io/nekohasekai/sfa/vendor/MLKitQRCodeAnalyzer.kt b/app/src/play/java/io/nekohasekai/sfa/vendor/MLKitQRCodeAnalyzer.kt index 4bda124..e573f63 100644 --- a/app/src/play/java/io/nekohasekai/sfa/vendor/MLKitQRCodeAnalyzer.kt +++ b/app/src/play/java/io/nekohasekai/sfa/vendor/MLKitQRCodeAnalyzer.kt @@ -8,8 +8,8 @@ import com.google.mlkit.vision.barcode.BarcodeScannerOptions import com.google.mlkit.vision.barcode.BarcodeScanning import com.google.mlkit.vision.barcode.common.Barcode import com.google.mlkit.vision.common.InputImage -import io.nekohasekai.sfa.ui.profile.QRCodeCropArea -import io.nekohasekai.sfa.ui.profile.QRCodeSmartCrop +import io.nekohasekai.sfa.compose.screen.qrscan.QRCodeCropArea +import io.nekohasekai.sfa.compose.screen.qrscan.QRCodeSmartCrop // kanged from: https://github.com/G00fY2/quickie/blob/main/quickie/src/main/kotlin/io/github/g00fy2/quickie/QRCodeAnalyzer.kt diff --git a/app/src/play/java/io/nekohasekai/sfa/vendor/Vendor.kt b/app/src/play/java/io/nekohasekai/sfa/vendor/Vendor.kt index 42e1310..6b1e6ff 100644 --- a/app/src/play/java/io/nekohasekai/sfa/vendor/Vendor.kt +++ b/app/src/play/java/io/nekohasekai/sfa/vendor/Vendor.kt @@ -12,7 +12,7 @@ import com.google.android.play.core.install.model.InstallStatus import com.google.android.play.core.install.model.UpdateAvailability import com.google.mlkit.common.MlKitException import io.nekohasekai.sfa.R -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.UpdateState diff --git a/settings.gradle.kts b/settings.gradle.kts index 003bd2a..4044430 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -11,7 +11,10 @@ dependencyResolutionManagement { google() mavenCentral() maven { url = uri("https://jitpack.io") } + maven { url = uri("https://api.xposed.info/") } } } rootProject.name = "sing-box" include(":app") +include(":libxposed-api") +project(":libxposed-api").projectDir = file("third_party/libxposed-api") diff --git a/third_party/libxposed-api/LICENSE b/third_party/libxposed-api/LICENSE new file mode 100644 index 0000000..261eeb9 --- /dev/null +++ b/third_party/libxposed-api/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + 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. diff --git a/third_party/libxposed-api/build.gradle.kts b/third_party/libxposed-api/build.gradle.kts new file mode 100644 index 0000000..ccc91f2 --- /dev/null +++ b/third_party/libxposed-api/build.gradle.kts @@ -0,0 +1,26 @@ +plugins { + id("com.android.library") +} + +android { + namespace = "io.github.libxposed.api" + compileSdk = 36 + + defaultConfig { + minSdk = 21 + } + + buildFeatures { + androidResources = false + buildConfig = false + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } +} + +dependencies { + compileOnly("androidx.annotation:annotation:1.7.1") +} diff --git a/third_party/libxposed-api/src/main/AndroidManifest.xml b/third_party/libxposed-api/src/main/AndroidManifest.xml new file mode 100644 index 0000000..8072ee0 --- /dev/null +++ b/third_party/libxposed-api/src/main/AndroidManifest.xml @@ -0,0 +1,2 @@ + + diff --git a/third_party/libxposed-api/src/main/java/io/github/libxposed/api/XposedInterface.java b/third_party/libxposed-api/src/main/java/io/github/libxposed/api/XposedInterface.java new file mode 100644 index 0000000..3c4ac86 --- /dev/null +++ b/third_party/libxposed-api/src/main/java/io/github/libxposed/api/XposedInterface.java @@ -0,0 +1,525 @@ +package io.github.libxposed.api; + +import android.content.SharedPreferences; +import android.content.pm.ApplicationInfo; +import android.os.ParcelFileDescriptor; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import java.io.FileNotFoundException; +import java.io.IOException; +import java.lang.reflect.Constructor; +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Member; +import java.lang.reflect.Method; +import java.nio.ByteBuffer; + +import io.github.libxposed.api.errors.HookFailedError; +import io.github.libxposed.api.utils.DexParser; + +/** + * Xposed interface for modules to operate on application processes. + */ +@SuppressWarnings("unused") +public interface XposedInterface { + /** + * SDK API version. + */ + int API = 100; + + /** + * Indicates that the framework is running as root. + */ + int FRAMEWORK_PRIVILEGE_ROOT = 0; + /** + * Indicates that the framework is running in a container with a fake system_server. + */ + int FRAMEWORK_PRIVILEGE_CONTAINER = 1; + /** + * Indicates that the framework is running as a different app, which may have at most shell permission. + */ + int FRAMEWORK_PRIVILEGE_APP = 2; + /** + * Indicates that the framework is embedded in the hooked app, + * which means {@link #getRemotePreferences} will be null and remote file is unsupported. + */ + int FRAMEWORK_PRIVILEGE_EMBEDDED = 3; + + /** + * The default hook priority. + */ + int PRIORITY_DEFAULT = 50; + /** + * Execute the hook callback late. + */ + int PRIORITY_LOWEST = -10000; + /** + * Execute the hook callback early. + */ + int PRIORITY_HIGHEST = 10000; + + /** + * Contextual interface for before invocation callbacks. + */ + interface BeforeHookCallback { + /** + * Gets the method / constructor to be hooked. + */ + @NonNull + Member getMember(); + + /** + * Gets the {@code this} object, or {@code null} if the method is static. + */ + @Nullable + Object getThisObject(); + + /** + * Gets the arguments passed to the method / constructor. You can modify the arguments. + */ + @NonNull + Object[] getArgs(); + + /** + * Sets the return value of the method and skip the invocation. If the procedure is a constructor, + * the {@code result} param will be ignored. + * Note that the after invocation callback will still be called. + * + * @param result The return value + */ + void returnAndSkip(@Nullable Object result); + + /** + * Throw an exception from the method / constructor and skip the invocation. + * Note that the after invocation callback will still be called. + * + * @param throwable The exception to be thrown + */ + void throwAndSkip(@Nullable Throwable throwable); + } + + /** + * Contextual interface for after invocation callbacks. + */ + interface AfterHookCallback { + /** + * Gets the method / constructor to be hooked. + */ + @NonNull + Member getMember(); + + /** + * Gets the {@code this} object, or {@code null} if the method is static. + */ + @Nullable + Object getThisObject(); + + /** + * Gets all arguments passed to the method / constructor. + */ + @NonNull + Object[] getArgs(); + + /** + * Gets the return value of the method or the before invocation callback. If the procedure is a + * constructor, a void method or an exception was thrown, the return value will be {@code null}. + */ + @Nullable + Object getResult(); + + /** + * Gets the exception thrown by the method / constructor or the before invocation callback. If the + * procedure call was successful, the return value will be {@code null}. + */ + @Nullable + Throwable getThrowable(); + + /** + * Gets whether the invocation was skipped by the before invocation callback. + */ + boolean isSkipped(); + + /** + * Sets the return value of the method and skip the invocation. If the procedure is a constructor, + * the {@code result} param will be ignored. + * + * @param result The return value + */ + void setResult(@Nullable Object result); + + /** + * Sets the exception thrown by the method / constructor. + * + * @param throwable The exception to be thrown. + */ + void setThrowable(@Nullable Throwable throwable); + } + + /** + * Interface for method / constructor hooking. Xposed modules should define their own hooker class + * and implement this interface. Normally, a hooker class corresponds to a method / constructor, but + * there could also be a single hooker class for all of them. By this way you can implement an interface + * like the old API. + * + *

+ * Classes implementing this interface should should provide two public static methods named + * before and after for before invocation and after invocation respectively. + *

+ * + *

+ * The before invocation method should have the following signature:
+ * Param {@code callback}: The {@link BeforeHookCallback} of the procedure call.
+ * Return value: If you want to save contextual information of one procedure call between the before + * and after callback, it could be a self-defined class, otherwise it should be {@code void}. + *

+ * + *

+ * The after invocation method should have the following signature:
+ * Param {@code callback}: The {@link AfterHookCallback} of the procedure call.
+ * Param {@code context} (optional): The contextual object returned by the before invocation. + *

+ * + *

Example usage:

+ * + *
{@code
+     *   public class ExampleHooker implements Hooker {
+     *
+     *       public static void before(@NonNull BeforeHookCallback callback) {
+     *           // Pre-hooking logic goes here
+     *       }
+     *
+     *       public static void after(@NonNull AfterHookCallback callback) {
+     *           // Post-hooking logic goes here
+     *       }
+     *   }
+     *
+     *   public class ExampleHookerWithContext implements Hooker {
+     *
+     *       public static MyContext before(@NonNull BeforeHookCallback callback) {
+     *           // Pre-hooking logic goes here
+     *           return new MyContext();
+     *       }
+     *
+     *       public static void after(@NonNull AfterHookCallback callback, MyContext context) {
+     *           // Post-hooking logic goes here
+     *       }
+     *   }
+     * }
+ */ + interface Hooker { + } + + /** + * Interface for canceling a hook. + * + * @param {@link Method} or {@link Constructor} + */ + interface MethodUnhooker { + /** + * Gets the method or constructor being hooked. + */ + @NonNull + T getOrigin(); + + /** + * Cancels the hook. The behavior of calling this method multiple times is undefined. + */ + void unhook(); + } + + /** + * Gets the Xposed framework name of current implementation. + * + * @return Framework name + */ + @NonNull + String getFrameworkName(); + + /** + * Gets the Xposed framework version of current implementation. + * + * @return Framework version + */ + @NonNull + String getFrameworkVersion(); + + /** + * Gets the Xposed framework version code of current implementation. + * + * @return Framework version code + */ + long getFrameworkVersionCode(); + + /** + * Gets the Xposed framework privilege of current implementation. + * + * @return Framework privilege + */ + int getFrameworkPrivilege(); + + /** + * Hook a method with default priority. + * + * @param origin The method to be hooked + * @param hooker The hooker class + * @return Unhooker for canceling the hook + * @throws IllegalArgumentException if origin is abstract, framework internal or {@link Method#invoke}, + * or hooker is invalid + * @throws HookFailedError if hook fails due to framework internal error + */ + @NonNull + MethodUnhooker hook(@NonNull Method origin, @NonNull Class hooker); + + /** + * Hook the static initializer of a class with default priority. + *

+ * Note: If the class is initialized, the hook will never be called. + *

+ * + * @param origin The class to be hooked + * @param hooker The hooker class + * @return Unhooker for canceling the hook + * @throws IllegalArgumentException if class has no static initializer or hooker is invalid + * @throws HookFailedError if hook fails due to framework internal error + */ + @NonNull + MethodUnhooker> hookClassInitializer(@NonNull Class origin, @NonNull Class hooker); + + /** + * Hook the static initializer of a class with specified priority. + *

+ * Note: If the class is initialized, the hook will never be called. + *

+ * + * @param origin The class to be hooked + * @param priority The hook priority + * @param hooker The hooker class + * @return Unhooker for canceling the hook + * @throws IllegalArgumentException if class has no static initializer or hooker is invalid + * @throws HookFailedError if hook fails due to framework internal error + */ + @NonNull + MethodUnhooker> hookClassInitializer(@NonNull Class origin, int priority, @NonNull Class hooker); + + /** + * Hook a method with specified priority. + * + * @param origin The method to be hooked + * @param priority The hook priority + * @param hooker The hooker class + * @return Unhooker for canceling the hook + * @throws IllegalArgumentException if origin is abstract, framework internal or {@link Method#invoke}, + * or hooker is invalid + * @throws HookFailedError if hook fails due to framework internal error + */ + @NonNull + MethodUnhooker hook(@NonNull Method origin, int priority, @NonNull Class hooker); + + /** + * Hook a constructor with default priority. + * + * @param The type of the constructor + * @param origin The constructor to be hooked + * @param hooker The hooker class + * @return Unhooker for canceling the hook + * @throws IllegalArgumentException if origin is abstract, framework internal or {@link Method#invoke}, + * or hooker is invalid + * @throws HookFailedError if hook fails due to framework internal error + */ + @NonNull + MethodUnhooker> hook(@NonNull Constructor origin, @NonNull Class hooker); + + /** + * Hook a constructor with specified priority. + * + * @param The type of the constructor + * @param origin The constructor to be hooked + * @param priority The hook priority + * @param hooker The hooker class + * @return Unhooker for canceling the hook + * @throws IllegalArgumentException if origin is abstract, framework internal or {@link Method#invoke}, + * or hooker is invalid + * @throws HookFailedError if hook fails due to framework internal error + */ + @NonNull + MethodUnhooker> hook(@NonNull Constructor origin, int priority, @NonNull Class hooker); + + /** + * Deoptimizes a method in case hooked callee is not called because of inline. + * + *

By deoptimizing the method, the method will back all callee without inlining. + * For example, when a short hooked method B is invoked by method A, the callback to B is not invoked + * after hooking, which may mean A has inlined B inside its method body. To force A to call the hooked B, + * you can deoptimize A and then your hook can take effect.

+ * + *

Generally, you need to find all the callers of your hooked callee and that can be hardly achieve + * (but you can still search all callers by using {@link DexParser}). Use this method if you are sure + * the deoptimized callers are all you need. Otherwise, it would be better to change the hook point or + * to deoptimize the whole app manually (by simply reinstalling the app without uninstall).

+ * + * @param method The method to deoptimize + * @return Indicate whether the deoptimizing succeed or not + */ + boolean deoptimize(@NonNull Method method); + + /** + * Deoptimizes a constructor in case hooked callee is not called because of inline. + * + * @param The type of the constructor + * @param constructor The constructor to deoptimize + * @return Indicate whether the deoptimizing succeed or not + * @see #deoptimize(Method) + */ + boolean deoptimize(@NonNull Constructor constructor); + + /** + * Basically the same as {@link Method#invoke(Object, Object...)}, but calls the original method + * as it was before the interception by Xposed. + * + * @param method The method to be called + * @param thisObject For non-static calls, the {@code this} pointer, otherwise {@code null} + * @param args The arguments used for the method call + * @return The result returned from the invoked method + * @see Method#invoke(Object, Object...) + */ + @Nullable + Object invokeOrigin(@NonNull Method method, @Nullable Object thisObject, Object... args) throws InvocationTargetException, IllegalArgumentException, IllegalAccessException; + + /** + * Basically the same as {@link Constructor#newInstance(Object...)}, but calls the original constructor + * as it was before the interception by Xposed. + * + * @param constructor The constructor to create and initialize a new instance + * @param thisObject The instance to be constructed + * @param args The arguments used for the construction + * @param The type of the instance + * @see Constructor#newInstance(Object...) + */ + void invokeOrigin(@NonNull Constructor constructor, @NonNull T thisObject, Object... args) throws InvocationTargetException, IllegalArgumentException, IllegalAccessException; + + /** + * Invokes a special (non-virtual) method on a given object instance, similar to the functionality of + * {@code CallNonVirtualMethod} in JNI, which invokes an instance (nonstatic) method on a Java + * object. This method is useful when you need to call a specific method on an object, bypassing any + * overridden methods in subclasses and directly invoking the method defined in the specified class. + * + *

This method is useful when you need to call {@code super.xxx()} in a hooked constructor.

+ * + * @param method The method to be called + * @param thisObject For non-static calls, the {@code this} pointer, otherwise {@code null} + * @param args The arguments used for the method call + * @return The result returned from the invoked method + * @see Method#invoke(Object, Object...) + */ + @Nullable + Object invokeSpecial(@NonNull Method method, @NonNull Object thisObject, Object... args) throws InvocationTargetException, IllegalArgumentException, IllegalAccessException; + + /** + * Invokes a special (non-virtual) method on a given object instance, similar to the functionality of + * {@code CallNonVirtualMethod} in JNI, which invokes an instance (nonstatic) method on a Java + * object. This method is useful when you need to call a specific method on an object, bypassing any + * overridden methods in subclasses and directly invoking the method defined in the specified class. + * + *

This method is useful when you need to call {@code super.xxx()} in a hooked constructor.

+ * + * @param constructor The constructor to create and initialize a new instance + * @param thisObject The instance to be constructed + * @param args The arguments used for the construction + * @see Constructor#newInstance(Object...) + */ + void invokeSpecial(@NonNull Constructor constructor, @NonNull T thisObject, Object... args) throws InvocationTargetException, IllegalArgumentException, IllegalAccessException; + + /** + * Basically the same as {@link Constructor#newInstance(Object...)}, but calls the original constructor + * as it was before the interception by Xposed. + * + * @param The type of the constructor + * @param constructor The constructor to create and initialize a new instance + * @param args The arguments used for the construction + * @return The instance created and initialized by the constructor + * @see Constructor#newInstance(Object...) + */ + @NonNull + T newInstanceOrigin(@NonNull Constructor constructor, Object... args) throws InvocationTargetException, IllegalArgumentException, IllegalAccessException, InstantiationException; + + /** + * Creates a new instance of the given subclass, but initialize it with a parent constructor. This could + * leave the object in an invalid state, where the subclass constructor are not called and the fields + * of the subclass are not initialized. + * + *

This method is useful when you need to initialize some fields in the subclass by yourself.

+ * + * @param The type of the parent constructor + * @param The type of the subclass + * @param constructor The parent constructor to initialize a new instance + * @param subClass The subclass to create a new instance + * @param args The arguments used for the construction + * @return The instance of subclass initialized by the constructor + * @see Constructor#newInstance(Object...) + */ + @NonNull + U newInstanceSpecial(@NonNull Constructor constructor, @NonNull Class subClass, Object... args) throws InvocationTargetException, IllegalArgumentException, IllegalAccessException, InstantiationException; + + /** + * Writes a message to the Xposed log. + * + * @param message The log message + */ + void log(@NonNull String message); + + /** + * Writes a message with a stack trace to the Xposed log. + * + * @param message The log message + * @param throwable The Throwable object for the stack trace + */ + void log(@NonNull String message, @NonNull Throwable throwable); + + /** + * Parse a dex file in memory. + * + * @param dexData The content of the dex file + * @param includeAnnotations Whether to include annotations + * @return The {@link DexParser} of the dex file + * @throws IOException if the dex file is invalid + */ + @Nullable + DexParser parseDex(@NonNull ByteBuffer dexData, boolean includeAnnotations) throws IOException; + + /** + * Gets the application info of the module. + */ + @NonNull + ApplicationInfo getApplicationInfo(); + + /** + * Gets remote preferences stored in Xposed framework. Note that those are read-only in hooked apps. + * + * @param group Group name + * @return The preferences + * @throws UnsupportedOperationException If the framework is embedded + */ + @NonNull + SharedPreferences getRemotePreferences(@NonNull String group); + + /** + * List all files in the module's shared data directory. + * + * @return The file list + * @throws UnsupportedOperationException If the framework is embedded + */ + @NonNull + String[] listRemoteFiles(); + + /** + * Open a file in the module's shared data directory. The file is opened in read-only mode. + * + * @param name File name, must not contain path separators and . or .. + * @return The file descriptor + * @throws FileNotFoundException If the file does not exist or the path is forbidden + * @throws UnsupportedOperationException If the framework is embedded + */ + @NonNull + ParcelFileDescriptor openRemoteFile(@NonNull String name) throws FileNotFoundException; +} diff --git a/third_party/libxposed-api/src/main/java/io/github/libxposed/api/XposedInterfaceWrapper.java b/third_party/libxposed-api/src/main/java/io/github/libxposed/api/XposedInterfaceWrapper.java new file mode 100644 index 0000000..425596f --- /dev/null +++ b/third_party/libxposed-api/src/main/java/io/github/libxposed/api/XposedInterfaceWrapper.java @@ -0,0 +1,171 @@ +package io.github.libxposed.api; + +import android.content.SharedPreferences; +import android.content.pm.ApplicationInfo; +import android.os.ParcelFileDescriptor; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import java.io.FileNotFoundException; +import java.io.IOException; +import java.lang.reflect.Constructor; +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; +import java.nio.ByteBuffer; + +import io.github.libxposed.api.utils.DexParser; + +/** + * Wrap of {@link XposedInterface} used by the modules for the purpose of shielding framework implementation details. + */ +public class XposedInterfaceWrapper implements XposedInterface { + + private final XposedInterface mBase; + + XposedInterfaceWrapper(@NonNull XposedInterface base) { + mBase = base; + } + + @NonNull + @Override + public final String getFrameworkName() { + return mBase.getFrameworkName(); + } + + @NonNull + @Override + public final String getFrameworkVersion() { + return mBase.getFrameworkVersion(); + } + + @Override + public final long getFrameworkVersionCode() { + return mBase.getFrameworkVersionCode(); + } + + @Override + public final int getFrameworkPrivilege() { + return mBase.getFrameworkPrivilege(); + } + + @NonNull + @Override + public final MethodUnhooker hook(@NonNull Method origin, @NonNull Class hooker) { + return mBase.hook(origin, hooker); + } + + @NonNull + @Override + public MethodUnhooker> hookClassInitializer(@NonNull Class origin, @NonNull Class hooker) { + return mBase.hookClassInitializer(origin, hooker); + } + + @NonNull + @Override + public MethodUnhooker> hookClassInitializer(@NonNull Class origin, int priority, @NonNull Class hooker) { + return mBase.hookClassInitializer(origin, priority, hooker); + } + + @NonNull + @Override + public final MethodUnhooker hook(@NonNull Method origin, int priority, @NonNull Class hooker) { + return mBase.hook(origin, priority, hooker); + } + + @NonNull + @Override + public final MethodUnhooker> hook(@NonNull Constructor origin, @NonNull Class hooker) { + return mBase.hook(origin, hooker); + } + + @NonNull + @Override + public final MethodUnhooker> hook(@NonNull Constructor origin, int priority, @NonNull Class hooker) { + return mBase.hook(origin, priority, hooker); + } + + @Override + public final boolean deoptimize(@NonNull Method method) { + return mBase.deoptimize(method); + } + + @Override + public final boolean deoptimize(@NonNull Constructor constructor) { + return mBase.deoptimize(constructor); + } + + @Nullable + @Override + public final Object invokeOrigin(@NonNull Method method, @Nullable Object thisObject, Object... args) throws InvocationTargetException, IllegalArgumentException, IllegalAccessException { + return mBase.invokeOrigin(method, thisObject, args); + } + + @Override + public void invokeOrigin(@NonNull Constructor constructor, @NonNull T thisObject, Object... args) throws InvocationTargetException, IllegalArgumentException, IllegalAccessException { + mBase.invokeOrigin(constructor, thisObject, args); + } + + @Nullable + @Override + public final Object invokeSpecial(@NonNull Method method, @NonNull Object thisObject, Object... args) throws InvocationTargetException, IllegalArgumentException, IllegalAccessException { + return mBase.invokeSpecial(method, thisObject, args); + } + + @Override + public void invokeSpecial(@NonNull Constructor constructor, @NonNull T thisObject, Object... args) throws InvocationTargetException, IllegalArgumentException, IllegalAccessException { + mBase.invokeSpecial(constructor, thisObject, args); + } + + @NonNull + @Override + public final T newInstanceOrigin(@NonNull Constructor constructor, Object... args) throws InvocationTargetException, IllegalArgumentException, IllegalAccessException, InstantiationException { + return mBase.newInstanceOrigin(constructor, args); + } + + @NonNull + @Override + public final U newInstanceSpecial(@NonNull Constructor constructor, @NonNull Class subClass, Object... args) throws InvocationTargetException, IllegalArgumentException, IllegalAccessException, InstantiationException { + return mBase.newInstanceSpecial(constructor, subClass, args); + } + + @Override + public final void log(@NonNull String message) { + mBase.log(message); + } + + @Override + public final void log(@NonNull String message, @NonNull Throwable throwable) { + mBase.log(message, throwable); + } + + @Nullable + @Override + public final DexParser parseDex(@NonNull ByteBuffer dexData, boolean includeAnnotations) throws IOException { + return mBase.parseDex(dexData, includeAnnotations); + } + + @NonNull + @Override + public SharedPreferences getRemotePreferences(@NonNull String name) { + return mBase.getRemotePreferences(name); + } + + @NonNull + @Override + public ApplicationInfo getApplicationInfo() { + return mBase.getApplicationInfo(); + } + + @NonNull + @Override + public String[] listRemoteFiles() { + return mBase.listRemoteFiles(); + } + + @NonNull + @Override + public ParcelFileDescriptor openRemoteFile(@NonNull String name) throws FileNotFoundException { + return mBase.openRemoteFile(name); + } +} diff --git a/third_party/libxposed-api/src/main/java/io/github/libxposed/api/XposedModule.java b/third_party/libxposed-api/src/main/java/io/github/libxposed/api/XposedModule.java new file mode 100644 index 0000000..b2e1a03 --- /dev/null +++ b/third_party/libxposed-api/src/main/java/io/github/libxposed/api/XposedModule.java @@ -0,0 +1,21 @@ +package io.github.libxposed.api; + +import androidx.annotation.NonNull; + +/** + * Super class which all Xposed module entry classes should extend.
+ * Entry classes will be instantiated exactly once for each process. + */ +@SuppressWarnings("unused") +public abstract class XposedModule extends XposedInterfaceWrapper implements XposedModuleInterface { + /** + * Instantiates a new Xposed module.
+ * When the module is loaded into the target process, the constructor will be called. + * + * @param base The implementation interface provided by the framework, should not be used by the module + * @param param Information about the process in which the module is loaded + */ + public XposedModule(@NonNull XposedInterface base, @NonNull ModuleLoadedParam param) { + super(base); + } +} diff --git a/third_party/libxposed-api/src/main/java/io/github/libxposed/api/XposedModuleInterface.java b/third_party/libxposed-api/src/main/java/io/github/libxposed/api/XposedModuleInterface.java new file mode 100644 index 0000000..1cb548c --- /dev/null +++ b/third_party/libxposed-api/src/main/java/io/github/libxposed/api/XposedModuleInterface.java @@ -0,0 +1,108 @@ +package io.github.libxposed.api; + +import android.content.pm.ApplicationInfo; +import android.os.Build; + +import androidx.annotation.NonNull; +import androidx.annotation.RequiresApi; + +/** + * Interface for module initialization. + */ +@SuppressWarnings("unused") +public interface XposedModuleInterface { + /** + * Wraps information about the process in which the module is loaded. + */ + interface ModuleLoadedParam { + /** + * Gets information about whether the module is running in system server. + * + * @return {@code true} if the module is running in system server + */ + boolean isSystemServer(); + + /** + * Gets the process name. + * + * @return The process name + */ + @NonNull + String getProcessName(); + } + + /** + * Wraps information about system server. + */ + interface SystemServerLoadedParam { + /** + * Gets the class loader of system server. + * + * @return The class loader + */ + @NonNull + ClassLoader getClassLoader(); + } + + /** + * Wraps information about the package being loaded. + */ + interface PackageLoadedParam { + /** + * Gets the package name of the package being loaded. + * + * @return The package name. + */ + @NonNull + String getPackageName(); + + /** + * Gets the {@link ApplicationInfo} of the package being loaded. + * + * @return The ApplicationInfo. + */ + @NonNull + ApplicationInfo getApplicationInfo(); + + /** + * Gets default class loader. + * + * @return the default class loader + */ + @RequiresApi(Build.VERSION_CODES.Q) + @NonNull + ClassLoader getDefaultClassLoader(); + + /** + * Gets the class loader of the package being loaded. + * + * @return The class loader. + */ + @NonNull + ClassLoader getClassLoader(); + + /** + * Gets information about whether is this package the first and main package of the app process. + * + * @return {@code true} if this is the first package. + */ + boolean isFirstPackage(); + } + + /** + * Gets notified when a package is loaded into the app process.
+ * This callback could be invoked multiple times for the same process on each package. + * + * @param param Information about the package being loaded + */ + default void onPackageLoaded(@NonNull PackageLoadedParam param) { + } + + /** + * Gets notified when the system server is loaded. + * + * @param param Information about system server + */ + default void onSystemServerLoaded(@NonNull SystemServerLoadedParam param) { + } +} diff --git a/third_party/libxposed-api/src/main/java/io/github/libxposed/api/errors/HookFailedError.java b/third_party/libxposed-api/src/main/java/io/github/libxposed/api/errors/HookFailedError.java new file mode 100644 index 0000000..0eb4b05 --- /dev/null +++ b/third_party/libxposed-api/src/main/java/io/github/libxposed/api/errors/HookFailedError.java @@ -0,0 +1,20 @@ +package io.github.libxposed.api.errors; + +/** + * Thrown to indicate that a hook failed due to framework internal error. + */ +@SuppressWarnings("unused") +public class HookFailedError extends XposedFrameworkError { + + public HookFailedError(String message) { + super(message); + } + + public HookFailedError(String message, Throwable cause) { + super(message, cause); + } + + public HookFailedError(Throwable cause) { + super(cause); + } +} diff --git a/third_party/libxposed-api/src/main/java/io/github/libxposed/api/errors/XposedFrameworkError.java b/third_party/libxposed-api/src/main/java/io/github/libxposed/api/errors/XposedFrameworkError.java new file mode 100644 index 0000000..0b3bba0 --- /dev/null +++ b/third_party/libxposed-api/src/main/java/io/github/libxposed/api/errors/XposedFrameworkError.java @@ -0,0 +1,19 @@ +package io.github.libxposed.api.errors; + +/** + * Thrown to indicate that the Xposed framework function is broken. + */ +public class XposedFrameworkError extends Error { + + public XposedFrameworkError(String message) { + super(message); + } + + public XposedFrameworkError(String message, Throwable cause) { + super(message, cause); + } + + public XposedFrameworkError(Throwable cause) { + super(cause); + } +} diff --git a/third_party/libxposed-api/src/main/java/io/github/libxposed/api/utils/DexParser.java b/third_party/libxposed-api/src/main/java/io/github/libxposed/api/utils/DexParser.java new file mode 100644 index 0000000..00a5f43 --- /dev/null +++ b/third_party/libxposed-api/src/main/java/io/github/libxposed/api/utils/DexParser.java @@ -0,0 +1,376 @@ +package io.github.libxposed.api.utils; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import java.io.Closeable; + +/** + * Xposed interface for parsing dex files. + */ +@SuppressWarnings("unused") +public interface DexParser extends Closeable { + /** + * The constant NO_INDEX. + */ + int NO_INDEX = 0xffffffff; + + /** + * The interface Array. + */ + interface Array { + /** + * Get values value [ ]. + * + * @return the value [ ] + */ + @NonNull + Value[] getValues(); + } + + /** + * The interface Annotation. + */ + interface Annotation { + /** + * Gets visibility. + * + * @return the visibility + */ + int getVisibility(); + + /** + * Gets type. + * + * @return the type + */ + @NonNull + TypeId getType(); + + /** + * Get elements element [ ]. + * + * @return the element [ ] + */ + @NonNull + Element[] getElements(); + } + + /** + * The interface Value. + */ + interface Value { + + /** + * Get value byte [ ]. + * + * @return the byte [ ] + */ + @Nullable + byte[] getValue(); + + /** + * Gets value type. + * + * @return the value type + */ + int getValueType(); + } + + /** + * The interface Element. + */ + interface Element extends Value { + /** + * Gets name. + * + * @return the name + */ + @NonNull + StringId getName(); + } + /** + * The interface Id. + */ + interface Id extends Comparable { + /** + * Gets id. + * + * @return the id + */ + int getId(); + } + + /** + * The interface Type id. + */ + interface TypeId extends Id { + /** + * Gets descriptor. + * + * @return the descriptor + */ + @NonNull + StringId getDescriptor(); + } + + + /** + * The interface String id. + */ + interface StringId extends Id { + /** + * Gets string. + * + * @return the string + */ + @NonNull + String getString(); + } + + /** + * The interface Field id. + */ + interface FieldId extends Id { + /** + * Gets type. + * + * @return the type + */ + @NonNull + TypeId getType(); + + /** + * Gets declaring class. + * + * @return the declaring class + */ + @NonNull + TypeId getDeclaringClass(); + + /** + * Gets name. + * + * @return the name + */ + @NonNull + StringId getName(); + } + + /** + * The interface Method id. + */ + interface MethodId extends Id { + /** + * Gets declaring class. + * + * @return the declaring class + */ + @NonNull + TypeId getDeclaringClass(); + + /** + * Gets prototype. + * + * @return the prototype + */ + @NonNull + ProtoId getPrototype(); + + /** + * Gets name. + * + * @return the name + */ + @NonNull + StringId getName(); + } + + /** + * The interface Proto id. + */ + interface ProtoId extends Id { + /** + * Gets shorty. + * + * @return the shorty + */ + @NonNull + StringId getShorty(); + + /** + * Gets return type. + * + * @return the return type + */ + @NonNull + TypeId getReturnType(); + + /** + * Get parameters type id [ ]. + * + * @return the type id [ ] + */ + @Nullable + TypeId[] getParameters(); + } + + /** + * Get string id string id [ ]. + * + * @return the string id [ ] + */ + @NonNull + StringId[] getStringId(); + + /** + * Get type id type id [ ]. + * + * @return the type id [ ] + */ + @NonNull + TypeId[] getTypeId(); + + /** + * Get field id field id [ ]. + * + * @return the field id [ ] + */ + @NonNull + FieldId[] getFieldId(); + + /** + * Get method id method id [ ]. + * + * @return the method id [ ] + */ + @NonNull + MethodId[] getMethodId(); + + /** + * Get proto id proto id [ ]. + * + * @return the proto id [ ] + */ + @NonNull + ProtoId[] getProtoId(); + + /** + * Get annotations annotation [ ]. + * + * @return the annotation [ ] + */ + @NonNull + Annotation[] getAnnotations(); + + /** + * Get arrays array [ ]. + * + * @return the array [ ] + */ + @NonNull + Array[] getArrays(); + + /** + * The interface Early stop visitor. + */ + interface EarlyStopVisitor { + /** + * Stop boolean. + * + * @return the boolean + */ + boolean stop(); + } + + /** + * The interface Member visitor. + */ + interface MemberVisitor extends EarlyStopVisitor { + } + + /** + * The interface Class visitor. + */ + interface ClassVisitor extends EarlyStopVisitor { + /** + * Visit member visitor. + * + * @param clazz the clazz + * @param accessFlags the access flags + * @param superClass the super class + * @param interfaces the interfaces + * @param sourceFile the source file + * @param staticFields the static fields + * @param staticFieldsAccessFlags the static fields access flags + * @param instanceFields the instance fields + * @param instanceFieldsAccessFlags the instance fields access flags + * @param directMethods the direct methods + * @param directMethodsAccessFlags the direct methods access flags + * @param virtualMethods the virtual methods + * @param virtualMethodsAccessFlags the virtual methods access flags + * @param annotations the annotations + * @return the member visitor + */ + @Nullable + MemberVisitor visit(int clazz, int accessFlags, int superClass, @NonNull int[] interfaces, int sourceFile, @NonNull int[] staticFields, @NonNull int[] staticFieldsAccessFlags, @NonNull int[] instanceFields, @NonNull int[] instanceFieldsAccessFlags, @NonNull int[] directMethods, @NonNull int[] directMethodsAccessFlags, @NonNull int[] virtualMethods, @NonNull int[] virtualMethodsAccessFlags, @NonNull int[] annotations); + } + + /** + * The interface Field visitor. + */ + interface FieldVisitor extends MemberVisitor { + /** + * Visit. + * + * @param field the field + * @param accessFlags the access flags + * @param annotations the annotations + */ + void visit(int field, int accessFlags, @NonNull int[] annotations); + } + + /** + * The interface Method visitor. + */ + interface MethodVisitor extends MemberVisitor { + /** + * Visit method body visitor. + * + * @param method the method + * @param accessFlags the access flags + * @param hasBody the has body + * @param annotations the annotations + * @param parameterAnnotations the parameter annotations + * @return the method body visitor + */ + @Nullable + MethodBodyVisitor visit(int method, int accessFlags, boolean hasBody, @NonNull int[] annotations, @NonNull int[] parameterAnnotations); + } + + /** + * The interface Method body visitor. + */ + interface MethodBodyVisitor { + /** + * Visit. + * + * @param method the method + * @param accessFlags the access flags + * @param referredStrings the referred strings + * @param invokedMethods the invoked methods + * @param accessedFields the accessed fields + * @param assignedFields the assigned fields + * @param opcodes the opcodes + */ + void visit(int method, int accessFlags, @NonNull int[] referredStrings, @NonNull int[] invokedMethods, @NonNull int[] accessedFields, @NonNull int[] assignedFields, @NonNull byte[] opcodes); + } + + /** + * Visit defined classes. + * + * @param visitor the visitor + * @throws IllegalStateException the illegal state exception + */ + void visitDefinedClasses(@NonNull ClassVisitor visitor) throws IllegalStateException; +}