Apply Spotless formatting to Java and Kotlin files
This commit is contained in:
@@ -25,7 +25,7 @@ object RootInstaller {
|
||||
handle.service.installPackage(
|
||||
pfd,
|
||||
apkFile.length(),
|
||||
android.os.Process.myUserHandle().hashCode()
|
||||
android.os.Process.myUserHandle().hashCode(),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -69,10 +69,7 @@ object RootInstaller {
|
||||
}
|
||||
}
|
||||
|
||||
private class RootServiceHandle(
|
||||
val connection: ServiceConnection,
|
||||
val service: IRootService
|
||||
) : java.io.Closeable {
|
||||
private class RootServiceHandle(val connection: ServiceConnection, val service: IRootService) : java.io.Closeable {
|
||||
override fun close() {
|
||||
Handler(Looper.getMainLooper()).post {
|
||||
RootService.unbind(connection)
|
||||
|
||||
@@ -10,9 +10,7 @@ import android.content.pm.PackageInstaller as AndroidPackageInstaller
|
||||
|
||||
object SystemPackageInstaller {
|
||||
|
||||
fun canSystemSilentInstall(): Boolean {
|
||||
return Build.VERSION.SDK_INT >= Build.VERSION_CODES.S
|
||||
}
|
||||
fun canSystemSilentInstall(): Boolean = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S
|
||||
|
||||
fun install(context: Context, apkFile: File) {
|
||||
val packageInstaller = context.packageManager.packageInstaller
|
||||
@@ -38,7 +36,7 @@ object SystemPackageInstaller {
|
||||
context,
|
||||
sessionId,
|
||||
intent,
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_MUTABLE
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_MUTABLE,
|
||||
)
|
||||
|
||||
session.commit(pendingIntent.intentSender)
|
||||
|
||||
@@ -15,10 +15,7 @@ import io.nekohasekai.sfa.update.UpdateState
|
||||
import io.nekohasekai.sfa.update.UpdateTrack
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
class UpdateWorker(
|
||||
private val appContext: Context,
|
||||
params: WorkerParameters
|
||||
) : CoroutineWorker(appContext, params) {
|
||||
class UpdateWorker(private val appContext: Context, params: WorkerParameters) : CoroutineWorker(appContext, params) {
|
||||
|
||||
companion object {
|
||||
private const val WORK_NAME = "AutoUpdate"
|
||||
@@ -37,7 +34,8 @@ class UpdateWorker(
|
||||
.build()
|
||||
|
||||
val workRequest = PeriodicWorkRequestBuilder<UpdateWorker>(
|
||||
24, TimeUnit.HOURS
|
||||
24,
|
||||
TimeUnit.HOURS,
|
||||
)
|
||||
.setConstraints(constraints)
|
||||
.setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 1, TimeUnit.HOURS)
|
||||
@@ -46,7 +44,7 @@ class UpdateWorker(
|
||||
WorkManager.getInstance(context).enqueueUniquePeriodicWork(
|
||||
WORK_NAME,
|
||||
ExistingPeriodicWorkPolicy.KEEP,
|
||||
workRequest
|
||||
workRequest,
|
||||
)
|
||||
Log.d(TAG, "Auto update scheduled")
|
||||
}
|
||||
|
||||
@@ -4,6 +4,12 @@ import android.os.Bundle;
|
||||
import android.os.IInterface;
|
||||
|
||||
public interface IIntentReceiver extends IInterface {
|
||||
void performReceive(Intent intent, int resultCode, String data, Bundle extras,
|
||||
boolean ordered, boolean sticky, int sendingUser);
|
||||
void performReceive(
|
||||
Intent intent,
|
||||
int resultCode,
|
||||
String data,
|
||||
Bundle extras,
|
||||
boolean ordered,
|
||||
boolean sticky,
|
||||
int sendingUser);
|
||||
}
|
||||
|
||||
@@ -7,17 +7,23 @@ import android.os.IInterface;
|
||||
|
||||
public interface IIntentSender extends IInterface {
|
||||
|
||||
void send(int code, Intent intent, String resolvedType, IBinder whitelistToken,
|
||||
IIntentReceiver finishedReceiver, String requiredPermission, Bundle options);
|
||||
void send(
|
||||
int code,
|
||||
Intent intent,
|
||||
String resolvedType,
|
||||
IBinder whitelistToken,
|
||||
IIntentReceiver finishedReceiver,
|
||||
String requiredPermission,
|
||||
Bundle options);
|
||||
|
||||
abstract class Stub extends Binder implements IIntentSender {
|
||||
public static IIntentSender asInterface(IBinder binder) {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
|
||||
@Override
|
||||
public IBinder asBinder() {
|
||||
return this;
|
||||
}
|
||||
abstract class Stub extends Binder implements IIntentSender {
|
||||
public static IIntentSender asInterface(IBinder binder) {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
|
||||
@Override
|
||||
public IBinder asBinder() {
|
||||
return this;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,15 +7,20 @@ import android.os.RemoteException;
|
||||
|
||||
public interface IPackageInstaller extends IInterface {
|
||||
|
||||
int createSession(PackageInstaller.SessionParams params, String installerPackageName, String installerAttributionTag, int userId) throws RemoteException;
|
||||
int createSession(
|
||||
PackageInstaller.SessionParams params,
|
||||
String installerPackageName,
|
||||
String installerAttributionTag,
|
||||
int userId)
|
||||
throws RemoteException;
|
||||
|
||||
IPackageInstallerSession openSession(int sessionId) throws RemoteException;
|
||||
IPackageInstallerSession openSession(int sessionId) throws RemoteException;
|
||||
|
||||
void abandonSession(int sessionId) throws RemoteException;
|
||||
void abandonSession(int sessionId) throws RemoteException;
|
||||
|
||||
abstract class Stub extends Binder implements IPackageInstaller {
|
||||
public static IPackageInstaller asInterface(IBinder binder) {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
abstract class Stub extends Binder implements IPackageInstaller {
|
||||
public static IPackageInstaller asInterface(IBinder binder) {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,9 +6,9 @@ import android.os.IInterface;
|
||||
|
||||
public interface IPackageInstallerSession extends IInterface {
|
||||
|
||||
abstract class Stub extends Binder implements IPackageInstallerSession {
|
||||
public static IPackageInstallerSession asInterface(IBinder binder) {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
abstract class Stub extends Binder implements IPackageInstallerSession {
|
||||
public static IPackageInstallerSession asInterface(IBinder binder) {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,10 +6,8 @@ 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;
|
||||
@@ -26,211 +24,215 @@ 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 static final String TAG = "RemotePreferences";
|
||||
private static final Object CONTENT = new Object();
|
||||
private static final Handler HANDLER = new Handler(Looper.getMainLooper());
|
||||
|
||||
private final XposedService mService;
|
||||
private final String mGroup;
|
||||
private final Lock mLock = new ReentrantLock();
|
||||
private final Map<String, Object> mMap = new ConcurrentHashMap<>();
|
||||
private final Map<OnSharedPreferenceChangeListener, Object> mListeners = Collections.synchronizedMap(new WeakHashMap<>());
|
||||
private final XposedService mService;
|
||||
private final String mGroup;
|
||||
private final Lock mLock = new ReentrantLock();
|
||||
private final Map<String, Object> mMap = new ConcurrentHashMap<>();
|
||||
private final Map<OnSharedPreferenceChangeListener, Object> mListeners =
|
||||
Collections.synchronizedMap(new WeakHashMap<>());
|
||||
|
||||
private volatile boolean isDeleted = false;
|
||||
private volatile boolean isDeleted = false;
|
||||
|
||||
private RemotePreferences(XposedService service, String group) {
|
||||
this.mService = service;
|
||||
this.mGroup = group;
|
||||
private RemotePreferences(XposedService service, String group) {
|
||||
this.mService = service;
|
||||
this.mGroup = group;
|
||||
}
|
||||
|
||||
@Nullable
|
||||
static RemotePreferences newInstance(XposedService service, String group) throws RemoteException {
|
||||
Bundle output = service.getRaw().requestRemotePreferences(group);
|
||||
if (output == null) return null;
|
||||
RemotePreferences prefs = new RemotePreferences(service, group);
|
||||
if (output.containsKey("map")) {
|
||||
prefs.mMap.putAll((Map<String, Object>) output.getSerializable("map"));
|
||||
}
|
||||
return prefs;
|
||||
}
|
||||
|
||||
@Nullable
|
||||
static RemotePreferences newInstance(XposedService service, String group) throws RemoteException {
|
||||
Bundle output = service.getRaw().requestRemotePreferences(group);
|
||||
if (output == null) return null;
|
||||
RemotePreferences prefs = new RemotePreferences(service, group);
|
||||
if (output.containsKey("map")) {
|
||||
prefs.mMap.putAll((Map<String, Object>) output.getSerializable("map"));
|
||||
}
|
||||
return prefs;
|
||||
}
|
||||
void setDeleted() {
|
||||
this.isDeleted = true;
|
||||
}
|
||||
|
||||
void setDeleted() {
|
||||
this.isDeleted = true;
|
||||
@Override
|
||||
public Map<String, ?> getAll() {
|
||||
return new TreeMap<>(mMap);
|
||||
}
|
||||
|
||||
@Nullable
|
||||
@Override
|
||||
public String getString(String key, @Nullable String defValue) {
|
||||
return (String) mMap.getOrDefault(key, defValue);
|
||||
}
|
||||
|
||||
@Nullable
|
||||
@Override
|
||||
public Set<String> getStringSet(String key, @Nullable Set<String> defValues) {
|
||||
return (Set<String>) mMap.getOrDefault(key, defValues);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getInt(String key, int defValue) {
|
||||
Integer v = (Integer) mMap.getOrDefault(key, defValue);
|
||||
assert v != null;
|
||||
return v;
|
||||
}
|
||||
|
||||
@Override
|
||||
public long getLong(String key, long defValue) {
|
||||
Long v = (Long) mMap.getOrDefault(key, defValue);
|
||||
assert v != null;
|
||||
return v;
|
||||
}
|
||||
|
||||
@Override
|
||||
public float getFloat(String key, float defValue) {
|
||||
Float v = (Float) mMap.getOrDefault(key, defValue);
|
||||
assert v != null;
|
||||
return v;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean getBoolean(String key, boolean defValue) {
|
||||
Boolean v = (Boolean) mMap.getOrDefault(key, defValue);
|
||||
assert v != null;
|
||||
return v;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean contains(String key) {
|
||||
return mMap.containsKey(key);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void registerOnSharedPreferenceChangeListener(OnSharedPreferenceChangeListener listener) {
|
||||
mListeners.put(listener, CONTENT);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void unregisterOnSharedPreferenceChangeListener(
|
||||
OnSharedPreferenceChangeListener listener) {
|
||||
mListeners.remove(listener);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Editor edit() {
|
||||
return new Editor();
|
||||
}
|
||||
|
||||
public class Editor implements SharedPreferences.Editor {
|
||||
|
||||
private final HashSet<String> mDelete = new HashSet<>();
|
||||
private final HashMap<String, Object> mPut = new HashMap<>();
|
||||
|
||||
private void put(String key, @NonNull Object value) {
|
||||
mDelete.remove(key);
|
||||
mPut.put(key, value);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Map<String, ?> getAll() {
|
||||
return new TreeMap<>(mMap);
|
||||
}
|
||||
|
||||
@Nullable
|
||||
@Override
|
||||
public String getString(String key, @Nullable String defValue) {
|
||||
return (String) mMap.getOrDefault(key, defValue);
|
||||
}
|
||||
|
||||
@Nullable
|
||||
@Override
|
||||
public Set<String> getStringSet(String key, @Nullable Set<String> defValues) {
|
||||
return (Set<String>) mMap.getOrDefault(key, defValues);
|
||||
public SharedPreferences.Editor putString(String key, @Nullable String value) {
|
||||
if (value == null) remove(key);
|
||||
else put(key, value);
|
||||
return this;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getInt(String key, int defValue) {
|
||||
Integer v = (Integer) mMap.getOrDefault(key, defValue);
|
||||
assert v != null;
|
||||
return v;
|
||||
public SharedPreferences.Editor putStringSet(String key, @Nullable Set<String> values) {
|
||||
if (values == null) remove(key);
|
||||
else put(key, values);
|
||||
return this;
|
||||
}
|
||||
|
||||
@Override
|
||||
public long getLong(String key, long defValue) {
|
||||
Long v = (Long) mMap.getOrDefault(key, defValue);
|
||||
assert v != null;
|
||||
return v;
|
||||
public SharedPreferences.Editor putInt(String key, int value) {
|
||||
put(key, value);
|
||||
return this;
|
||||
}
|
||||
|
||||
@Override
|
||||
public float getFloat(String key, float defValue) {
|
||||
Float v = (Float) mMap.getOrDefault(key, defValue);
|
||||
assert v != null;
|
||||
return v;
|
||||
public SharedPreferences.Editor putLong(String key, long value) {
|
||||
put(key, value);
|
||||
return this;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean getBoolean(String key, boolean defValue) {
|
||||
Boolean v = (Boolean) mMap.getOrDefault(key, defValue);
|
||||
assert v != null;
|
||||
return v;
|
||||
public SharedPreferences.Editor putFloat(String key, float value) {
|
||||
put(key, value);
|
||||
return this;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean contains(String key) {
|
||||
return mMap.containsKey(key);
|
||||
public SharedPreferences.Editor putBoolean(String key, boolean value) {
|
||||
put(key, value);
|
||||
return this;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void registerOnSharedPreferenceChangeListener(OnSharedPreferenceChangeListener listener) {
|
||||
mListeners.put(listener, CONTENT);
|
||||
public SharedPreferences.Editor remove(String key) {
|
||||
mDelete.add(key);
|
||||
mPut.remove(key);
|
||||
return this;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void unregisterOnSharedPreferenceChangeListener(OnSharedPreferenceChangeListener listener) {
|
||||
mListeners.remove(listener);
|
||||
public SharedPreferences.Editor clear() {
|
||||
mDelete.clear();
|
||||
mPut.clear();
|
||||
return this;
|
||||
}
|
||||
|
||||
private void doUpdate(boolean throwing) {
|
||||
mService.deletionLock.readLock().lock();
|
||||
try {
|
||||
if (isDeleted) {
|
||||
throw new IllegalStateException("This preferences group has been deleted");
|
||||
}
|
||||
mDelete.forEach(mMap::remove);
|
||||
mMap.putAll(mPut);
|
||||
List<String> changes = new ArrayList<>(mDelete.size() + mMap.size());
|
||||
changes.addAll(mDelete);
|
||||
changes.addAll(mMap.keySet());
|
||||
for (String key : changes) {
|
||||
mListeners
|
||||
.keySet()
|
||||
.forEach(listener -> listener.onSharedPreferenceChanged(RemotePreferences.this, key));
|
||||
}
|
||||
|
||||
Bundle bundle = new Bundle();
|
||||
bundle.putSerializable("delete", mDelete);
|
||||
bundle.putSerializable("put", mPut);
|
||||
try {
|
||||
mService.getRaw().updateRemotePreferences(mGroup, bundle);
|
||||
} catch (RemoteException e) {
|
||||
if (throwing) {
|
||||
throw new RuntimeException(e);
|
||||
} else {
|
||||
Log.e(TAG, "Failed to update remote preferences", e);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
mService.deletionLock.readLock().unlock();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public Editor edit() {
|
||||
return new Editor();
|
||||
public boolean commit() {
|
||||
if (!mLock.tryLock()) return false;
|
||||
try {
|
||||
doUpdate(true);
|
||||
return true;
|
||||
} finally {
|
||||
mLock.unlock();
|
||||
}
|
||||
}
|
||||
|
||||
public class Editor implements SharedPreferences.Editor {
|
||||
|
||||
private final HashSet<String> mDelete = new HashSet<>();
|
||||
private final HashMap<String, Object> mPut = new HashMap<>();
|
||||
|
||||
private void put(String key, @NonNull Object value) {
|
||||
mDelete.remove(key);
|
||||
mPut.put(key, value);
|
||||
}
|
||||
|
||||
@Override
|
||||
public SharedPreferences.Editor putString(String key, @Nullable String value) {
|
||||
if (value == null) remove(key);
|
||||
else put(key, value);
|
||||
return this;
|
||||
}
|
||||
|
||||
@Override
|
||||
public SharedPreferences.Editor putStringSet(String key, @Nullable Set<String> values) {
|
||||
if (values == null) remove(key);
|
||||
else put(key, values);
|
||||
return this;
|
||||
}
|
||||
|
||||
@Override
|
||||
public SharedPreferences.Editor putInt(String key, int value) {
|
||||
put(key, value);
|
||||
return this;
|
||||
}
|
||||
|
||||
@Override
|
||||
public SharedPreferences.Editor putLong(String key, long value) {
|
||||
put(key, value);
|
||||
return this;
|
||||
}
|
||||
|
||||
@Override
|
||||
public SharedPreferences.Editor putFloat(String key, float value) {
|
||||
put(key, value);
|
||||
return this;
|
||||
}
|
||||
|
||||
@Override
|
||||
public SharedPreferences.Editor putBoolean(String key, boolean value) {
|
||||
put(key, value);
|
||||
return this;
|
||||
}
|
||||
|
||||
@Override
|
||||
public SharedPreferences.Editor remove(String key) {
|
||||
mDelete.add(key);
|
||||
mPut.remove(key);
|
||||
return this;
|
||||
}
|
||||
|
||||
@Override
|
||||
public SharedPreferences.Editor clear() {
|
||||
mDelete.clear();
|
||||
mPut.clear();
|
||||
return this;
|
||||
}
|
||||
|
||||
private void doUpdate(boolean throwing) {
|
||||
mService.deletionLock.readLock().lock();
|
||||
try {
|
||||
if (isDeleted) {
|
||||
throw new IllegalStateException("This preferences group has been deleted");
|
||||
}
|
||||
mDelete.forEach(mMap::remove);
|
||||
mMap.putAll(mPut);
|
||||
List<String> changes = new ArrayList<>(mDelete.size() + mMap.size());
|
||||
changes.addAll(mDelete);
|
||||
changes.addAll(mMap.keySet());
|
||||
for (String key : changes) {
|
||||
mListeners.keySet().forEach(listener -> listener.onSharedPreferenceChanged(RemotePreferences.this, key));
|
||||
}
|
||||
|
||||
Bundle bundle = new Bundle();
|
||||
bundle.putSerializable("delete", mDelete);
|
||||
bundle.putSerializable("put", mPut);
|
||||
try {
|
||||
mService.getRaw().updateRemotePreferences(mGroup, bundle);
|
||||
} catch (RemoteException e) {
|
||||
if (throwing) {
|
||||
throw new RuntimeException(e);
|
||||
} else {
|
||||
Log.e(TAG, "Failed to update remote preferences", e);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
mService.deletionLock.readLock().unlock();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean commit() {
|
||||
if (!mLock.tryLock()) return false;
|
||||
try {
|
||||
doUpdate(true);
|
||||
return true;
|
||||
} finally {
|
||||
mLock.unlock();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void apply() {
|
||||
HANDLER.post(() -> doUpdate(false));
|
||||
}
|
||||
@Override
|
||||
public void apply() {
|
||||
HANDLER.post(() -> doUpdate(false));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,58 +7,67 @@ 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";
|
||||
private static final String TAG = "XposedProvider";
|
||||
|
||||
@Override
|
||||
public boolean onCreate() {
|
||||
return false;
|
||||
}
|
||||
@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 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 String getType(@NonNull Uri uri) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@Nullable
|
||||
@Override
|
||||
public Uri insert(@NonNull Uri uri, @Nullable ContentValues values) {
|
||||
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 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;
|
||||
}
|
||||
@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;
|
||||
@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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,10 +3,8 @@ 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;
|
||||
@@ -16,363 +14,359 @@ 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);
|
||||
}
|
||||
public static final class ServiceException extends RuntimeException {
|
||||
ServiceException(String message) {
|
||||
super(message);
|
||||
}
|
||||
|
||||
private final static Map<OnScopeEventListener, IXposedScopeCallback> scopeCallbacks = new WeakHashMap<>();
|
||||
ServiceException(RemoteException e) {
|
||||
super("Xposed service error", e);
|
||||
}
|
||||
}
|
||||
|
||||
private static final Map<OnScopeEventListener, IXposedScopeCallback> scopeCallbacks =
|
||||
new WeakHashMap<>();
|
||||
|
||||
/** Callback interface for module scope request. */
|
||||
public interface OnScopeEventListener {
|
||||
/**
|
||||
* Callback when the request notification / window prompted.
|
||||
*
|
||||
* @param packageName Package name of requested app
|
||||
*/
|
||||
default void onScopeRequestPrompted(String packageName) {}
|
||||
|
||||
/**
|
||||
* Callback interface for module scope request.
|
||||
* Callback when the request is approved.
|
||||
*
|
||||
* @param packageName Package name of requested app
|
||||
*/
|
||||
public interface OnScopeEventListener {
|
||||
/**
|
||||
* Callback when the request notification / window prompted.
|
||||
*
|
||||
* @param packageName Package name of requested app
|
||||
*/
|
||||
default void onScopeRequestPrompted(String packageName) {
|
||||
}
|
||||
default void onScopeRequestApproved(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 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 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) {}
|
||||
|
||||
/**
|
||||
* 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() {
|
||||
private IXposedScopeCallback asInterface() {
|
||||
return scopeCallbacks.computeIfAbsent(
|
||||
this,
|
||||
(listener) ->
|
||||
new IXposedScopeCallback.Stub() {
|
||||
@Override
|
||||
public void onScopeRequestPrompted(String packageName) {
|
||||
listener.onScopeRequestPrompted(packageName);
|
||||
listener.onScopeRequestPrompted(packageName);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onScopeRequestApproved(String packageName) {
|
||||
listener.onScopeRequestApproved(packageName);
|
||||
listener.onScopeRequestApproved(packageName);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onScopeRequestDenied(String packageName) {
|
||||
listener.onScopeRequestDenied(packageName);
|
||||
listener.onScopeRequestDenied(packageName);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onScopeRequestTimeout(String packageName) {
|
||||
listener.onScopeRequestTimeout(packageName);
|
||||
listener.onScopeRequestTimeout(packageName);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onScopeRequestFailed(String packageName, String message) {
|
||||
listener.onScopeRequestFailed(packageName, message);
|
||||
listener.onScopeRequestFailed(packageName, message);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public enum Privilege {
|
||||
/**
|
||||
* Unknown privilege value.
|
||||
*/
|
||||
FRAMEWORK_PRIVILEGE_UNKNOWN,
|
||||
public enum Privilege {
|
||||
/** Unknown privilege value. */
|
||||
FRAMEWORK_PRIVILEGE_UNKNOWN,
|
||||
|
||||
/**
|
||||
* The framework is running as root.
|
||||
*/
|
||||
FRAMEWORK_PRIVILEGE_ROOT,
|
||||
/** 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 in a container with a fake system_server. */
|
||||
FRAMEWORK_PRIVILEGE_CONTAINER,
|
||||
|
||||
/**
|
||||
* The framework is running as a different app, which may have at most shell permission.
|
||||
*/
|
||||
FRAMEWORK_PRIVILEGE_APP,
|
||||
|
||||
/**
|
||||
* The framework is embedded in the hooked app, which means {@link #getRemotePreferences} will be null and remote file is unsupported.
|
||||
*/
|
||||
FRAMEWORK_PRIVILEGE_EMBEDDED
|
||||
}
|
||||
|
||||
private final IXposedService mService;
|
||||
private final Map<String, RemotePreferences> mRemotePrefs = new HashMap<>();
|
||||
|
||||
final ReentrantReadWriteLock deletionLock = new ReentrantReadWriteLock();
|
||||
|
||||
XposedService(IXposedService service) {
|
||||
mService = service;
|
||||
}
|
||||
|
||||
IXposedService getRaw() {
|
||||
return mService;
|
||||
}
|
||||
/** The framework is running as a different app, which may have at most shell permission. */
|
||||
FRAMEWORK_PRIVILEGE_APP,
|
||||
|
||||
/**
|
||||
* Get the Xposed API version of current implementation.
|
||||
*
|
||||
* @return API version
|
||||
* @throws ServiceException If the service is dead or an error occurred
|
||||
* The framework is embedded in the hooked app, which means {@link #getRemotePreferences} will
|
||||
* be null and remote file is unsupported.
|
||||
*/
|
||||
public int getAPIVersion() {
|
||||
try {
|
||||
return mService.getAPIVersion();
|
||||
} catch (RemoteException e) {
|
||||
throw new ServiceException(e);
|
||||
}
|
||||
}
|
||||
FRAMEWORK_PRIVILEGE_EMBEDDED
|
||||
}
|
||||
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
}
|
||||
private final IXposedService mService;
|
||||
private final Map<String, RemotePreferences> mRemotePrefs = new HashMap<>();
|
||||
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
}
|
||||
final ReentrantReadWriteLock deletionLock = new ReentrantReadWriteLock();
|
||||
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
}
|
||||
XposedService(IXposedService service) {
|
||||
mService = service;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
}
|
||||
IXposedService getRaw() {
|
||||
return mService;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the application scope of current module.
|
||||
*
|
||||
* @return Module scope
|
||||
* @throws ServiceException If the service is dead or an error occurred
|
||||
*/
|
||||
@NonNull
|
||||
public List<String> getScope() {
|
||||
try {
|
||||
return mService.getScope();
|
||||
} catch (RemoteException e) {
|
||||
throw new ServiceException(e);
|
||||
}
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 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 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);
|
||||
/**
|
||||
* Get the Xposed framework version code of current implementation.
|
||||
*
|
||||
* @return Framework version code
|
||||
* @throws ServiceException If the service is dead or an error occurred
|
||||
*/
|
||||
public long getFrameworkVersionCode() {
|
||||
try {
|
||||
return mService.getFrameworkVersionCode();
|
||||
} catch (RemoteException e) {
|
||||
throw new ServiceException(e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the Xposed framework privilege of current implementation.
|
||||
*
|
||||
* @return Framework privilege
|
||||
* @throws ServiceException If the service is dead or an error occurred
|
||||
*/
|
||||
@NonNull
|
||||
public Privilege getFrameworkPrivilege() {
|
||||
try {
|
||||
int value = mService.getFrameworkPrivilege();
|
||||
return (value >= 0 && value <= 3)
|
||||
? Privilege.values()[value + 1]
|
||||
: Privilege.FRAMEWORK_PRIVILEGE_UNKNOWN;
|
||||
} catch (RemoteException e) {
|
||||
throw new ServiceException(e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the application scope of current module.
|
||||
*
|
||||
* @return Module scope
|
||||
* @throws ServiceException If the service is dead or an error occurred
|
||||
*/
|
||||
@NonNull
|
||||
public List<String> getScope() {
|
||||
try {
|
||||
return mService.getScope();
|
||||
} catch (RemoteException e) {
|
||||
throw new ServiceException(e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Request to add a new app to the module scope.
|
||||
*
|
||||
* @param packageName Package name of the app to be added
|
||||
* @param callback Callback to be invoked when the request is completed or error occurred
|
||||
* @throws ServiceException If the service is dead or an error occurred
|
||||
*/
|
||||
public void requestScope(@NonNull String packageName, @NonNull OnScopeEventListener callback) {
|
||||
try {
|
||||
mService.requestScope(packageName, callback.asInterface());
|
||||
} catch (RemoteException e) {
|
||||
throw new ServiceException(e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove an app from the module scope.
|
||||
*
|
||||
* @param packageName Package name of the app to be added
|
||||
* @return null if successful, or non-null with error message
|
||||
* @throws ServiceException If the service is dead or an error occurred
|
||||
*/
|
||||
@Nullable
|
||||
public String removeScope(@NonNull String packageName) {
|
||||
try {
|
||||
return mService.removeScope(packageName);
|
||||
} catch (RemoteException e) {
|
||||
throw new ServiceException(e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get remote preferences from Xposed framework. If the group does not exist, it will be created.
|
||||
*
|
||||
* @param group Group name
|
||||
* @return The preferences
|
||||
* @throws ServiceException If the service is dead or an error occurred
|
||||
* @throws UnsupportedOperationException If the framework is embedded
|
||||
*/
|
||||
@NonNull
|
||||
public SharedPreferences getRemotePreferences(@NonNull String group) {
|
||||
return mRemotePrefs.computeIfAbsent(
|
||||
group,
|
||||
k -> {
|
||||
try {
|
||||
RemotePreferences instance = RemotePreferences.newInstance(this, k);
|
||||
if (instance == null) {
|
||||
throw new ServiceException("Framework returns null");
|
||||
}
|
||||
return instance;
|
||||
} catch (RemoteException e) {
|
||||
if (e.getCause() instanceof UnsupportedOperationException cause) {
|
||||
throw cause;
|
||||
}
|
||||
throw new ServiceException(e);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a group of remote preferences.
|
||||
*
|
||||
* @param group Group name
|
||||
* @throws ServiceException If the service is dead or an error occurred
|
||||
* @throws UnsupportedOperationException If the framework is embedded
|
||||
*/
|
||||
public void deleteRemotePreferences(@NonNull String group) {
|
||||
deletionLock.writeLock().lock();
|
||||
try {
|
||||
mService.deleteRemotePreferences(group);
|
||||
mRemotePrefs.computeIfPresent(group, (k, v) -> {
|
||||
v.setDeleted();
|
||||
return null;
|
||||
});
|
||||
} catch (RemoteException e) {
|
||||
if (e.getCause() instanceof UnsupportedOperationException cause) {
|
||||
throw cause;
|
||||
}
|
||||
throw new ServiceException(e);
|
||||
} finally {
|
||||
deletionLock.writeLock().unlock();
|
||||
}
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,9 +2,7 @@ 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;
|
||||
@@ -12,67 +10,63 @@ import java.util.Set;
|
||||
@SuppressWarnings("unused")
|
||||
public final class XposedServiceHelper {
|
||||
|
||||
/** Callback interface for Xposed service. */
|
||||
public interface OnServiceListener {
|
||||
/**
|
||||
* Callback interface for Xposed service.
|
||||
*/
|
||||
public interface OnServiceListener {
|
||||
/**
|
||||
* Callback when the service is connected.<br/>
|
||||
* This method could be called multiple times if multiple Xposed frameworks exist.
|
||||
*
|
||||
* @param service Service instance
|
||||
*/
|
||||
void onServiceBind(@NonNull XposedService service);
|
||||
|
||||
/**
|
||||
* Callback when the service is dead.
|
||||
*/
|
||||
void onServiceDied(@NonNull XposedService service);
|
||||
}
|
||||
|
||||
private static final String TAG = "XposedServiceHelper";
|
||||
private static final Set<XposedService> mCache = new HashSet<>();
|
||||
private static OnServiceListener mListener = null;
|
||||
|
||||
static void onBinderReceived(IBinder binder) {
|
||||
if (binder == null) return;
|
||||
synchronized (mCache) {
|
||||
try {
|
||||
XposedService service = new XposedService(IXposedService.Stub.asInterface(binder));
|
||||
if (mListener == null) {
|
||||
mCache.add(service);
|
||||
} else {
|
||||
binder.linkToDeath(() -> mListener.onServiceDied(service), 0);
|
||||
mListener.onServiceBind(service);
|
||||
}
|
||||
} catch (Throwable t) {
|
||||
Log.e(TAG, "onBinderReceived", t);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a ServiceListener to receive service binders from Xposed frameworks.<br/>
|
||||
* This method should only be called once.
|
||||
* Callback when the service is connected.<br>
|
||||
* This method could be called multiple times if multiple Xposed frameworks exist.
|
||||
*
|
||||
* @param listener Listener to register
|
||||
* @param service Service instance
|
||||
*/
|
||||
public static void registerListener(OnServiceListener listener) {
|
||||
synchronized (mCache) {
|
||||
mListener = listener;
|
||||
if (!mCache.isEmpty()) {
|
||||
for (Iterator<XposedService> it = mCache.iterator(); it.hasNext(); ) {
|
||||
try {
|
||||
XposedService service = it.next();
|
||||
service.getRaw().asBinder().linkToDeath(() -> mListener.onServiceDied(service), 0);
|
||||
mListener.onServiceBind(service);
|
||||
} catch (Throwable t) {
|
||||
Log.e(TAG, "registerListener", t);
|
||||
it.remove();
|
||||
}
|
||||
}
|
||||
mCache.clear();
|
||||
}
|
||||
void onServiceBind(@NonNull XposedService service);
|
||||
|
||||
/** Callback when the service is dead. */
|
||||
void onServiceDied(@NonNull XposedService service);
|
||||
}
|
||||
|
||||
private static final String TAG = "XposedServiceHelper";
|
||||
private static final Set<XposedService> mCache = new HashSet<>();
|
||||
private static OnServiceListener mListener = null;
|
||||
|
||||
static void onBinderReceived(IBinder binder) {
|
||||
if (binder == null) return;
|
||||
synchronized (mCache) {
|
||||
try {
|
||||
XposedService service = new XposedService(IXposedService.Stub.asInterface(binder));
|
||||
if (mListener == null) {
|
||||
mCache.add(service);
|
||||
} else {
|
||||
binder.linkToDeath(() -> mListener.onServiceDied(service), 0);
|
||||
mListener.onServiceBind(service);
|
||||
}
|
||||
} catch (Throwable t) {
|
||||
Log.e(TAG, "onBinderReceived", t);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a ServiceListener to receive service binders from Xposed frameworks.<br>
|
||||
* This method should only be called once.
|
||||
*
|
||||
* @param listener Listener to register
|
||||
*/
|
||||
public static void registerListener(OnServiceListener listener) {
|
||||
synchronized (mCache) {
|
||||
mListener = listener;
|
||||
if (!mCache.isEmpty()) {
|
||||
for (Iterator<XposedService> it = mCache.iterator(); it.hasNext(); ) {
|
||||
try {
|
||||
XposedService service = it.next();
|
||||
service.getRaw().asBinder().linkToDeath(() -> mListener.onServiceDied(service), 0);
|
||||
mListener.onServiceBind(service);
|
||||
} catch (Throwable t) {
|
||||
Log.e(TAG, "registerListener", t);
|
||||
it.remove();
|
||||
}
|
||||
}
|
||||
mCache.clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,8 +18,8 @@ import io.nekohasekai.sfa.bg.UpdateProfileWork
|
||||
import io.nekohasekai.sfa.constant.Bugs
|
||||
import io.nekohasekai.sfa.utils.AppLifecycleObserver
|
||||
import io.nekohasekai.sfa.utils.HookModuleUpdateNotifier
|
||||
import io.nekohasekai.sfa.utils.PrivilegeSettingsClient
|
||||
import io.nekohasekai.sfa.utils.HookStatusClient
|
||||
import io.nekohasekai.sfa.utils.PrivilegeSettingsClient
|
||||
import io.nekohasekai.sfa.vendor.Vendor
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
|
||||
@@ -8,7 +8,6 @@ import android.provider.DocumentsContract
|
||||
import android.provider.DocumentsProvider
|
||||
import android.webkit.MimeTypeMap
|
||||
import java.io.File
|
||||
import java.io.FileNotFoundException
|
||||
|
||||
class WorkingDirectoryProvider : DocumentsProvider() {
|
||||
|
||||
@@ -47,7 +46,7 @@ class WorkingDirectoryProvider : DocumentsProvider() {
|
||||
add(
|
||||
DocumentsContract.Root.COLUMN_FLAGS,
|
||||
DocumentsContract.Root.FLAG_SUPPORTS_CREATE or
|
||||
DocumentsContract.Root.FLAG_SUPPORTS_IS_CHILD
|
||||
DocumentsContract.Root.FLAG_SUPPORTS_IS_CHILD,
|
||||
)
|
||||
add(DocumentsContract.Root.COLUMN_ICON, R.mipmap.ic_launcher)
|
||||
add(DocumentsContract.Root.COLUMN_TITLE, context!!.getString(R.string.app_name))
|
||||
@@ -64,11 +63,7 @@ class WorkingDirectoryProvider : DocumentsProvider() {
|
||||
return result
|
||||
}
|
||||
|
||||
override fun queryChildDocuments(
|
||||
parentDocumentId: String,
|
||||
projection: Array<out String>?,
|
||||
sortOrder: String?
|
||||
): Cursor {
|
||||
override fun queryChildDocuments(parentDocumentId: String, projection: Array<out String>?, sortOrder: String?): Cursor {
|
||||
val result = MatrixCursor(projection ?: DEFAULT_DOCUMENT_PROJECTION)
|
||||
val parent = getFileForDocId(parentDocumentId)
|
||||
parent.listFiles()?.forEach { file ->
|
||||
@@ -77,21 +72,13 @@ class WorkingDirectoryProvider : DocumentsProvider() {
|
||||
return result
|
||||
}
|
||||
|
||||
override fun openDocument(
|
||||
documentId: String,
|
||||
mode: String,
|
||||
signal: CancellationSignal?
|
||||
): ParcelFileDescriptor {
|
||||
override fun openDocument(documentId: String, mode: String, signal: CancellationSignal?): ParcelFileDescriptor {
|
||||
val file = getFileForDocId(documentId)
|
||||
val accessMode = ParcelFileDescriptor.parseMode(mode)
|
||||
return ParcelFileDescriptor.open(file, accessMode)
|
||||
}
|
||||
|
||||
override fun createDocument(
|
||||
parentDocumentId: String,
|
||||
mimeType: String,
|
||||
displayName: String
|
||||
): String {
|
||||
override fun createDocument(parentDocumentId: String, mimeType: String, displayName: String): String {
|
||||
val parent = getFileForDocId(parentDocumentId)
|
||||
val file = File(parent, displayName)
|
||||
|
||||
|
||||
@@ -8,8 +8,8 @@ import android.os.Build
|
||||
import android.util.Log
|
||||
import android.widget.Toast
|
||||
import io.nekohasekai.sfa.R
|
||||
import io.nekohasekai.sfa.database.Settings
|
||||
import io.nekohasekai.sfa.compose.screen.profileoverride.PerAppProxyScanner
|
||||
import io.nekohasekai.sfa.database.Settings
|
||||
import io.nekohasekai.sfa.vendor.PackageQueryManager
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
@@ -21,10 +21,7 @@ class AppChangeReceiver : BroadcastReceiver() {
|
||||
private const val TAG = "AppChangeReceiver"
|
||||
}
|
||||
|
||||
override fun onReceive(
|
||||
context: Context,
|
||||
intent: Intent,
|
||||
) {
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
Log.d(TAG, "onReceive: ${intent.action}")
|
||||
if (!Settings.perAppProxyEnabled) {
|
||||
Log.d(TAG, "per app proxy disabled")
|
||||
|
||||
@@ -12,10 +12,7 @@ import kotlinx.coroutines.withContext
|
||||
|
||||
class BootReceiver : BroadcastReceiver() {
|
||||
@OptIn(DelicateCoroutinesApi::class)
|
||||
override fun onReceive(
|
||||
context: Context,
|
||||
intent: Intent,
|
||||
) {
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
when (intent.action) {
|
||||
Intent.ACTION_BOOT_COMPLETED, Intent.ACTION_MY_PACKAGE_REPLACED -> {
|
||||
}
|
||||
|
||||
@@ -28,13 +28,13 @@ import io.nekohasekai.libbox.PlatformInterface
|
||||
import io.nekohasekai.libbox.SystemProxyStatus
|
||||
import io.nekohasekai.sfa.Application
|
||||
import io.nekohasekai.sfa.R
|
||||
import io.nekohasekai.sfa.compose.MainActivity
|
||||
import io.nekohasekai.sfa.constant.Action
|
||||
import io.nekohasekai.sfa.constant.Alert
|
||||
import io.nekohasekai.sfa.constant.Status
|
||||
import io.nekohasekai.sfa.database.ProfileManager
|
||||
import io.nekohasekai.sfa.database.Settings
|
||||
import io.nekohasekai.sfa.ktx.hasPermission
|
||||
import io.nekohasekai.sfa.compose.MainActivity
|
||||
import io.nekohasekai.sfa.vendor.Vendor
|
||||
import kotlinx.coroutines.DelicateCoroutinesApi
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
@@ -44,10 +44,7 @@ import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.io.File
|
||||
|
||||
class BoxService(
|
||||
private val service: Service,
|
||||
private val platformInterface: PlatformInterface,
|
||||
) : CommandServerHandler {
|
||||
class BoxService(private val service: Service, private val platformInterface: PlatformInterface) : CommandServerHandler {
|
||||
companion object {
|
||||
private const val PROFILE_UPDATE_INTERVAL = 15L * 60 * 1000 // 15 minutes in milliseconds
|
||||
private const val TAG = "BoxService"
|
||||
@@ -81,10 +78,7 @@ class BoxService(
|
||||
private var receiverRegistered = false
|
||||
private val receiver =
|
||||
object : BroadcastReceiver() {
|
||||
override fun onReceive(
|
||||
context: Context,
|
||||
intent: Intent,
|
||||
) {
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
when (intent.action) {
|
||||
Action.SERVICE_CLOSE -> {
|
||||
stopService()
|
||||
@@ -316,10 +310,7 @@ class BoxService(
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun stopAndAlert(
|
||||
type: Alert,
|
||||
message: String? = null,
|
||||
) {
|
||||
private suspend fun stopAndAlert(type: Alert, message: String? = null) {
|
||||
Settings.startedByUser = false
|
||||
withContext(Dispatchers.Main) {
|
||||
if (receiverRegistered) {
|
||||
@@ -368,9 +359,7 @@ class BoxService(
|
||||
return Service.START_NOT_STICKY
|
||||
}
|
||||
|
||||
internal fun onBind(): IBinder {
|
||||
return binder
|
||||
}
|
||||
internal fun onBind(): IBinder = binder
|
||||
|
||||
internal fun onDestroy() {
|
||||
binder.close()
|
||||
|
||||
@@ -13,7 +13,6 @@ 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
|
||||
|
||||
@@ -134,11 +133,7 @@ object DebugInfoExporter {
|
||||
return count
|
||||
}
|
||||
|
||||
private fun addLogEntries(
|
||||
zip: ZipOutputStream,
|
||||
warnings: MutableList<String>,
|
||||
context: Context,
|
||||
): Int {
|
||||
private fun addLogEntries(zip: ZipOutputStream, warnings: MutableList<String>, context: Context): Int {
|
||||
var count = 0
|
||||
if (streamCommandToZip(zip, "logs/logcat.txt", warnings, listOf("logcat", "-d", "-b", "all")) != null) count++
|
||||
if (streamCommandToZip(zip, "logs/dmesg.txt", warnings, listOf("dmesg")) != null) count++
|
||||
@@ -185,11 +180,7 @@ object DebugInfoExporter {
|
||||
}
|
||||
}
|
||||
|
||||
private fun addSystemEntries(
|
||||
zip: ZipOutputStream,
|
||||
warnings: MutableList<String>,
|
||||
packageName: String,
|
||||
): Int {
|
||||
private fun addSystemEntries(zip: ZipOutputStream, warnings: MutableList<String>, packageName: String): Int {
|
||||
var count = 0
|
||||
if (streamCommandToZip(zip, "system/getprop.txt", warnings, listOf("getprop")) != null) count++
|
||||
if (streamCommandToZip(zip, "system/uname.txt", warnings, listOf("uname", "-a")) != null) count++
|
||||
@@ -210,27 +201,28 @@ object DebugInfoExporter {
|
||||
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++
|
||||
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++
|
||||
zip,
|
||||
"system/dumpsys_package_$packageName.txt",
|
||||
warnings,
|
||||
listOf("dumpsys", "package", packageName),
|
||||
) != null
|
||||
) {
|
||||
count++
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
private fun addFileEntry(
|
||||
zip: ZipOutputStream,
|
||||
file: File,
|
||||
entryName: String,
|
||||
warnings: MutableList<String>,
|
||||
): Boolean {
|
||||
private fun addFileEntry(zip: ZipOutputStream, file: File, entryName: String, warnings: MutableList<String>): Boolean {
|
||||
if (!file.isFile) {
|
||||
warnings.add("missing file: ${file.path}")
|
||||
return false
|
||||
@@ -262,51 +254,40 @@ object DebugInfoExporter {
|
||||
zip.closeEntry()
|
||||
}
|
||||
|
||||
private data class CommandResult(
|
||||
val exitCode: Int,
|
||||
val bytes: Long,
|
||||
)
|
||||
private data class CommandResult(val exitCode: Int, val bytes: Long)
|
||||
|
||||
private fun streamCommandToZip(
|
||||
zip: ZipOutputStream,
|
||||
entryName: String,
|
||||
warnings: MutableList<String>,
|
||||
command: List<String>,
|
||||
): CommandResult? {
|
||||
return try {
|
||||
val process = ProcessBuilder(command).redirectErrorStream(true).start()
|
||||
val entry = ZipEntry(entryName)
|
||||
zip.putNextEntry(entry)
|
||||
var bytes = 0L
|
||||
process.inputStream.use { input ->
|
||||
val buffer = ByteArray(16 * 1024)
|
||||
while (true) {
|
||||
val read = input.read(buffer)
|
||||
if (read <= 0) break
|
||||
zip.write(buffer, 0, read)
|
||||
bytes += read
|
||||
}
|
||||
): CommandResult? = 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
|
||||
}
|
||||
zip.closeEntry()
|
||||
val code = process.waitFor()
|
||||
if (code != 0) {
|
||||
warnings.add("command failed (${command.joinToString(" ")}): exit=$code")
|
||||
}
|
||||
CommandResult(code, bytes)
|
||||
} catch (e: Throwable) {
|
||||
warnings.add("command failed (${command.joinToString(" ")}): ${e.message}")
|
||||
runCatching { zip.closeEntry() }
|
||||
null
|
||||
}
|
||||
|
||||
private fun buildError(
|
||||
stage: String,
|
||||
detail: String,
|
||||
throwable: Throwable?,
|
||||
warnings: List<String>,
|
||||
outputPath: String?,
|
||||
): String {
|
||||
private fun buildError(stage: String, detail: String, throwable: Throwable?, warnings: List<String>, outputPath: String?): String {
|
||||
val sb = StringBuilder()
|
||||
sb.append("stage=").append(stage).append('\n')
|
||||
if (!outputPath.isNullOrBlank()) {
|
||||
|
||||
@@ -60,103 +60,102 @@ object DefaultNetworkListener {
|
||||
val listeners = mutableMapOf<Any, (Network?) -> Unit>()
|
||||
var network: Network? = null
|
||||
val pendingRequests = arrayListOf<NetworkMessage.Get>()
|
||||
for (message in channel) when (message) {
|
||||
is NetworkMessage.Start -> {
|
||||
if (listeners.isEmpty()) register()
|
||||
listeners[message.key] = message.listener
|
||||
if (network != null) message.listener(network)
|
||||
}
|
||||
|
||||
is NetworkMessage.Get -> {
|
||||
check(listeners.isNotEmpty()) { "Getting network without any listeners is not supported" }
|
||||
if (network == null) {
|
||||
pendingRequests += message
|
||||
} else {
|
||||
message.response.complete(
|
||||
network,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
is NetworkMessage.Stop ->
|
||||
if (listeners.isNotEmpty() && // was not empty
|
||||
listeners.remove(message.key) != null && listeners.isEmpty()
|
||||
) {
|
||||
network = null
|
||||
unregister()
|
||||
for (message in channel) {
|
||||
when (message) {
|
||||
is NetworkMessage.Start -> {
|
||||
if (listeners.isEmpty()) register()
|
||||
listeners[message.key] = message.listener
|
||||
if (network != null) message.listener(network)
|
||||
}
|
||||
|
||||
is NetworkMessage.Put -> {
|
||||
network = message.network
|
||||
pendingRequests.forEach { it.response.complete(message.network) }
|
||||
pendingRequests.clear()
|
||||
listeners.values.forEach { it(network) }
|
||||
}
|
||||
|
||||
is NetworkMessage.Update ->
|
||||
if (network == message.network) {
|
||||
listeners.values.forEach {
|
||||
it(
|
||||
is NetworkMessage.Get -> {
|
||||
check(listeners.isNotEmpty()) { "Getting network without any listeners is not supported" }
|
||||
if (network == null) {
|
||||
pendingRequests += message
|
||||
} else {
|
||||
message.response.complete(
|
||||
network,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
is NetworkMessage.Lost ->
|
||||
if (network == message.network) {
|
||||
network = null
|
||||
listeners.values.forEach { it(null) }
|
||||
is NetworkMessage.Stop ->
|
||||
if (listeners.isNotEmpty() &&
|
||||
// was not empty
|
||||
listeners.remove(message.key) != null &&
|
||||
listeners.isEmpty()
|
||||
) {
|
||||
network = null
|
||||
unregister()
|
||||
}
|
||||
|
||||
is NetworkMessage.Put -> {
|
||||
network = message.network
|
||||
pendingRequests.forEach { it.response.complete(message.network) }
|
||||
pendingRequests.clear()
|
||||
listeners.values.forEach { it(network) }
|
||||
}
|
||||
|
||||
is NetworkMessage.Update ->
|
||||
if (network == message.network) {
|
||||
listeners.values.forEach {
|
||||
it(
|
||||
network,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
is NetworkMessage.Lost ->
|
||||
if (network == message.network) {
|
||||
network = null
|
||||
listeners.values.forEach { it(null) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun start(
|
||||
key: Any,
|
||||
listener: (Network?) -> Unit,
|
||||
) = networkActor.send(
|
||||
suspend fun start(key: Any, listener: (Network?) -> Unit) = networkActor.send(
|
||||
NetworkMessage.Start(
|
||||
key,
|
||||
listener,
|
||||
),
|
||||
)
|
||||
|
||||
suspend fun get(): Network = if (fallback) @TargetApi(23) {
|
||||
suspend fun get(): Network = if (fallback) {
|
||||
@TargetApi(23)
|
||||
Application.connectivity.activeNetwork
|
||||
?: error("missing default network") // failed to listen, return current if available
|
||||
} else NetworkMessage.Get().run {
|
||||
networkActor.send(this)
|
||||
response.await()
|
||||
} else {
|
||||
NetworkMessage.Get().run {
|
||||
networkActor.send(this)
|
||||
response.await()
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun stop(key: Any) = networkActor.send(NetworkMessage.Stop(key))
|
||||
|
||||
// NB: this runs in ConnectivityThread, and this behavior cannot be changed until API 26
|
||||
private object Callback : ConnectivityManager.NetworkCallback() {
|
||||
override fun onAvailable(network: Network) =
|
||||
runBlocking {
|
||||
networkActor.send(
|
||||
NetworkMessage.Put(
|
||||
network,
|
||||
),
|
||||
)
|
||||
}
|
||||
override fun onAvailable(network: Network) = runBlocking {
|
||||
networkActor.send(
|
||||
NetworkMessage.Put(
|
||||
network,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
override fun onCapabilitiesChanged(
|
||||
network: Network,
|
||||
networkCapabilities: NetworkCapabilities,
|
||||
) {
|
||||
override fun onCapabilitiesChanged(network: Network, networkCapabilities: NetworkCapabilities) {
|
||||
// it's a good idea to refresh capabilities
|
||||
runBlocking { networkActor.send(NetworkMessage.Update(network)) }
|
||||
}
|
||||
|
||||
override fun onLost(network: Network) =
|
||||
runBlocking {
|
||||
networkActor.send(
|
||||
NetworkMessage.Lost(
|
||||
network,
|
||||
),
|
||||
)
|
||||
}
|
||||
override fun onLost(network: Network) = runBlocking {
|
||||
networkActor.send(
|
||||
NetworkMessage.Lost(
|
||||
network,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private var fallback = false
|
||||
|
||||
@@ -40,9 +40,7 @@ object DefaultNetworkMonitor {
|
||||
checkDefaultInterfaceUpdate(defaultNetwork)
|
||||
}
|
||||
|
||||
private fun checkDefaultInterfaceUpdate(
|
||||
newNetwork: Network?
|
||||
) {
|
||||
private fun checkDefaultInterfaceUpdate(newNetwork: Network?) {
|
||||
val listener = listener ?: return
|
||||
if (newNetwork != null) {
|
||||
val interfaceName =
|
||||
@@ -61,5 +59,4 @@ object DefaultNetworkMonitor {
|
||||
listener.updateDefaultInterface("", -1, false, false)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,15 +19,10 @@ import kotlin.coroutines.suspendCoroutine
|
||||
object LocalResolver : LocalDNSTransport {
|
||||
private const val RCODE_NXDOMAIN = 3
|
||||
|
||||
override fun raw(): Boolean {
|
||||
return Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q
|
||||
}
|
||||
override fun raw(): Boolean = Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.Q)
|
||||
override fun exchange(
|
||||
ctx: ExchangeContext,
|
||||
message: ByteArray,
|
||||
) {
|
||||
override fun exchange(ctx: ExchangeContext, message: ByteArray) {
|
||||
return runBlocking {
|
||||
val defaultNetwork = DefaultNetworkMonitor.require()
|
||||
suspendCoroutine { continuation ->
|
||||
@@ -35,10 +30,7 @@ object LocalResolver : LocalDNSTransport {
|
||||
ctx.onCancel(signal::cancel)
|
||||
val callback =
|
||||
object : DnsResolver.Callback<ByteArray> {
|
||||
override fun onAnswer(
|
||||
answer: ByteArray,
|
||||
rcode: Int,
|
||||
) {
|
||||
override fun onAnswer(answer: ByteArray, rcode: Int) {
|
||||
if (rcode == 0) {
|
||||
ctx.rawSuccess(answer)
|
||||
} else {
|
||||
@@ -70,11 +62,7 @@ object LocalResolver : LocalDNSTransport {
|
||||
}
|
||||
}
|
||||
|
||||
override fun lookup(
|
||||
ctx: ExchangeContext,
|
||||
network: String,
|
||||
domain: String,
|
||||
) {
|
||||
override fun lookup(ctx: ExchangeContext, network: String, domain: String) {
|
||||
return runBlocking {
|
||||
val defaultNetwork = DefaultNetworkMonitor.require()
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
@@ -84,10 +72,7 @@ object LocalResolver : LocalDNSTransport {
|
||||
val callback =
|
||||
object : DnsResolver.Callback<Collection<InetAddress>> {
|
||||
@Suppress("ThrowableNotThrown")
|
||||
override fun onAnswer(
|
||||
answer: Collection<InetAddress>,
|
||||
rcode: Int,
|
||||
) {
|
||||
override fun onAnswer(answer: Collection<InetAddress>, rcode: Int) {
|
||||
if (rcode == 0) {
|
||||
ctx.success(
|
||||
(answer as Collection<InetAddress?>).mapNotNull { it?.hostAddress }
|
||||
|
||||
@@ -2,64 +2,66 @@ 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 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 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;
|
||||
}
|
||||
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();
|
||||
}
|
||||
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 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;
|
||||
}
|
||||
@Override
|
||||
public int describeContents() {
|
||||
return 0;
|
||||
}
|
||||
|
||||
public static final Creator<LogEntry> CREATOR = new Creator<>() {
|
||||
public static final Creator<LogEntry> CREATOR =
|
||||
new Creator<>() {
|
||||
@Override
|
||||
public LogEntry createFromParcel(Parcel in) {
|
||||
return new LogEntry(in);
|
||||
return new LogEntry(in);
|
||||
}
|
||||
|
||||
@Override
|
||||
public LogEntry[] newArray(int size) {
|
||||
return new LogEntry[size];
|
||||
return new LogEntry[size];
|
||||
}
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2,40 +2,39 @@ 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;
|
||||
@NonNull public final String packageName;
|
||||
|
||||
public PackageEntry(@NonNull String packageName) {
|
||||
this.packageName = packageName;
|
||||
}
|
||||
public PackageEntry(@NonNull String packageName) {
|
||||
this.packageName = packageName;
|
||||
}
|
||||
|
||||
protected PackageEntry(Parcel in) {
|
||||
packageName = in.readString();
|
||||
}
|
||||
protected PackageEntry(Parcel in) {
|
||||
packageName = in.readString();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void writeToParcel(@NonNull Parcel dest, int flags) {
|
||||
dest.writeString(packageName);
|
||||
}
|
||||
@Override
|
||||
public void writeToParcel(@NonNull Parcel dest, int flags) {
|
||||
dest.writeString(packageName);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int describeContents() {
|
||||
return 0;
|
||||
}
|
||||
@Override
|
||||
public int describeContents() {
|
||||
return 0;
|
||||
}
|
||||
|
||||
public static final Creator<PackageEntry> CREATOR = new Creator<>() {
|
||||
public static final Creator<PackageEntry> CREATOR =
|
||||
new Creator<>() {
|
||||
@Override
|
||||
public PackageEntry createFromParcel(Parcel in) {
|
||||
return new PackageEntry(in);
|
||||
return new PackageEntry(in);
|
||||
}
|
||||
|
||||
@Override
|
||||
public PackageEntry[] newArray(int size) {
|
||||
return new PackageEntry[size];
|
||||
return new PackageEntry[size];
|
||||
}
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
@@ -21,132 +21,132 @@ import android.os.IBinder;
|
||||
import android.os.Parcel;
|
||||
import android.os.Parcelable;
|
||||
import android.os.RemoteException;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
public class ParceledListSlice<T extends Parcelable> implements Parcelable {
|
||||
private static final int MAX_IPC_SIZE = 64 * 1024;
|
||||
private static final int MAX_IPC_SIZE = 64 * 1024;
|
||||
|
||||
private final List<T> mList;
|
||||
private final List<T> mList;
|
||||
|
||||
public ParceledListSlice(List<T> list) {
|
||||
mList = list;
|
||||
public ParceledListSlice(List<T> list) {
|
||||
mList = list;
|
||||
}
|
||||
|
||||
private ParceledListSlice(Parcel in, ClassLoader loader) {
|
||||
final int n = in.readInt();
|
||||
mList = new ArrayList<>(n);
|
||||
if (n <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
public List<T> getList() {
|
||||
return mList;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int describeContents() {
|
||||
int contents = 0;
|
||||
for (int i = 0; i < mList.size(); i++) {
|
||||
contents |= mList.get(i).describeContents();
|
||||
}
|
||||
return contents;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void writeToParcel(Parcel dest, int flags) {
|
||||
final int n = mList.size();
|
||||
dest.writeInt(n);
|
||||
if (n <= 0) {
|
||||
return;
|
||||
}
|
||||
int i = 0;
|
||||
while (i < n && dest.dataSize() < MAX_IPC_SIZE) {
|
||||
dest.writeInt(1);
|
||||
dest.writeParcelable(mList.get(i), flags);
|
||||
i++;
|
||||
}
|
||||
if (i < n) {
|
||||
dest.writeInt(0);
|
||||
final int start = i;
|
||||
Binder retriever =
|
||||
new Binder() {
|
||||
@Override
|
||||
protected boolean onTransact(int code, Parcel data, Parcel reply, int flags)
|
||||
throws RemoteException {
|
||||
if (code != FIRST_CALL_TRANSACTION) {
|
||||
return super.onTransact(code, data, reply, flags);
|
||||
}
|
||||
int i = data.readInt();
|
||||
if (i < start || i > n) {
|
||||
return false;
|
||||
}
|
||||
while (i < n && reply.dataSize() < MAX_IPC_SIZE) {
|
||||
reply.writeInt(1);
|
||||
reply.writeParcelable(mList.get(i), flags);
|
||||
i++;
|
||||
}
|
||||
if (i < n) {
|
||||
reply.writeInt(0);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
reply.recycle();
|
||||
data.recycle();
|
||||
}
|
||||
};
|
||||
dest.writeStrongBinder(retriever);
|
||||
}
|
||||
}
|
||||
|
||||
public List<T> getList() {
|
||||
return mList;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int describeContents() {
|
||||
int contents = 0;
|
||||
for (int i = 0; i < mList.size(); i++) {
|
||||
contents |= mList.get(i).describeContents();
|
||||
public static final Parcelable.ClassLoaderCreator<ParceledListSlice> CREATOR =
|
||||
new Parcelable.ClassLoaderCreator<ParceledListSlice>() {
|
||||
@Override
|
||||
public ParceledListSlice createFromParcel(Parcel in) {
|
||||
return new ParceledListSlice(in, null);
|
||||
}
|
||||
return contents;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void writeToParcel(Parcel dest, int flags) {
|
||||
final int n = mList.size();
|
||||
dest.writeInt(n);
|
||||
if (n <= 0) {
|
||||
return;
|
||||
@Override
|
||||
public ParceledListSlice createFromParcel(Parcel in, ClassLoader loader) {
|
||||
return new ParceledListSlice(in, loader);
|
||||
}
|
||||
int i = 0;
|
||||
while (i < n && dest.dataSize() < MAX_IPC_SIZE) {
|
||||
dest.writeInt(1);
|
||||
dest.writeParcelable(mList.get(i), flags);
|
||||
i++;
|
||||
|
||||
@Override
|
||||
public ParceledListSlice[] newArray(int size) {
|
||||
return new ParceledListSlice[size];
|
||||
}
|
||||
if (i < n) {
|
||||
dest.writeInt(0);
|
||||
final int start = i;
|
||||
Binder retriever = new Binder() {
|
||||
@Override
|
||||
protected boolean onTransact(int code, Parcel data, Parcel reply, int flags)
|
||||
throws RemoteException {
|
||||
if (code != FIRST_CALL_TRANSACTION) {
|
||||
return super.onTransact(code, data, reply, flags);
|
||||
}
|
||||
int i = data.readInt();
|
||||
if (i < start || i > n) {
|
||||
return false;
|
||||
}
|
||||
while (i < n && reply.dataSize() < MAX_IPC_SIZE) {
|
||||
reply.writeInt(1);
|
||||
reply.writeParcelable(mList.get(i), flags);
|
||||
i++;
|
||||
}
|
||||
if (i < n) {
|
||||
reply.writeInt(0);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
};
|
||||
dest.writeStrongBinder(retriever);
|
||||
}
|
||||
}
|
||||
|
||||
public static final Parcelable.ClassLoaderCreator<ParceledListSlice> CREATOR =
|
||||
new Parcelable.ClassLoaderCreator<ParceledListSlice>() {
|
||||
@Override
|
||||
public ParceledListSlice createFromParcel(Parcel in) {
|
||||
return new ParceledListSlice(in, null);
|
||||
}
|
||||
|
||||
@Override
|
||||
public ParceledListSlice createFromParcel(Parcel in, ClassLoader loader) {
|
||||
return new ParceledListSlice(in, loader);
|
||||
}
|
||||
|
||||
@Override
|
||||
public ParceledListSlice[] newArray(int size) {
|
||||
return new ParceledListSlice[size];
|
||||
}
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
@@ -27,9 +27,7 @@ import kotlin.io.encoding.ExperimentalEncodingApi
|
||||
import io.nekohasekai.libbox.NetworkInterface as LibboxNetworkInterface
|
||||
|
||||
interface PlatformInterfaceWrapper : PlatformInterface {
|
||||
override fun usePlatformAutoDetectInterfaceControl(): Boolean {
|
||||
return true
|
||||
}
|
||||
override fun usePlatformAutoDetectInterfaceControl(): Boolean = true
|
||||
|
||||
override fun autoDetectInterfaceControl(fd: Int) {
|
||||
}
|
||||
@@ -38,9 +36,7 @@ interface PlatformInterfaceWrapper : PlatformInterface {
|
||||
error("invalid argument")
|
||||
}
|
||||
|
||||
override fun useProcFS(): Boolean {
|
||||
return Build.VERSION.SDK_INT < Build.VERSION_CODES.Q
|
||||
}
|
||||
override fun useProcFS(): Boolean = Build.VERSION.SDK_INT < Build.VERSION_CODES.Q
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.Q)
|
||||
override fun findConnectionOwner(
|
||||
@@ -136,13 +132,9 @@ interface PlatformInterfaceWrapper : PlatformInterface {
|
||||
return InterfaceArray(interfaces.iterator())
|
||||
}
|
||||
|
||||
override fun underNetworkExtension(): Boolean {
|
||||
return false
|
||||
}
|
||||
override fun underNetworkExtension(): Boolean = false
|
||||
|
||||
override fun includeAllNetworks(): Boolean {
|
||||
return false
|
||||
}
|
||||
override fun includeAllNetworks(): Boolean = false
|
||||
|
||||
override fun clearDNSCache() {
|
||||
}
|
||||
@@ -161,9 +153,7 @@ interface PlatformInterfaceWrapper : PlatformInterface {
|
||||
return WIFIState(ssid, wifiInfo.bssid)
|
||||
}
|
||||
|
||||
override fun localDNSTransport(): LocalDNSTransport? {
|
||||
return LocalResolver
|
||||
}
|
||||
override fun localDNSTransport(): LocalDNSTransport? = LocalResolver
|
||||
|
||||
@OptIn(ExperimentalEncodingApi::class)
|
||||
override fun systemCertificates(): StringIterator {
|
||||
@@ -182,15 +172,10 @@ interface PlatformInterfaceWrapper : PlatformInterface {
|
||||
return StringArray(certificates.iterator())
|
||||
}
|
||||
|
||||
private class InterfaceArray(private val iterator: Iterator<LibboxNetworkInterface>) :
|
||||
NetworkInterfaceIterator {
|
||||
override fun hasNext(): Boolean {
|
||||
return iterator.hasNext()
|
||||
}
|
||||
private class InterfaceArray(private val iterator: Iterator<LibboxNetworkInterface>) : NetworkInterfaceIterator {
|
||||
override fun hasNext(): Boolean = iterator.hasNext()
|
||||
|
||||
override fun next(): LibboxNetworkInterface {
|
||||
return iterator.next()
|
||||
}
|
||||
override fun next(): LibboxNetworkInterface = iterator.next()
|
||||
}
|
||||
|
||||
class StringArray(private val iterator: Iterator<String>) : StringIterator {
|
||||
@@ -199,21 +184,15 @@ interface PlatformInterfaceWrapper : PlatformInterface {
|
||||
return 0
|
||||
}
|
||||
|
||||
override fun hasNext(): Boolean {
|
||||
return iterator.hasNext()
|
||||
}
|
||||
override fun hasNext(): Boolean = iterator.hasNext()
|
||||
|
||||
override fun next(): String {
|
||||
return iterator.next()
|
||||
}
|
||||
override fun next(): String = iterator.next()
|
||||
}
|
||||
|
||||
private fun InterfaceAddress.toPrefix(): String {
|
||||
return if (address is Inet6Address) {
|
||||
"${Inet6Address.getByAddress(address.address).hostAddress}/$networkPrefixLength"
|
||||
} else {
|
||||
"${address.hostAddress}/$networkPrefixLength"
|
||||
}
|
||||
private fun InterfaceAddress.toPrefix(): String = if (address is Inet6Address) {
|
||||
"${Inet6Address.getByAddress(address.address).hostAddress}/$networkPrefixLength"
|
||||
} else {
|
||||
"${address.hostAddress}/$networkPrefixLength"
|
||||
}
|
||||
|
||||
private val NetworkInterface.flags: Int
|
||||
|
||||
@@ -4,14 +4,12 @@ import android.app.Service
|
||||
import android.content.Intent
|
||||
import io.nekohasekai.libbox.Notification
|
||||
|
||||
class ProxyService : Service(), PlatformInterfaceWrapper {
|
||||
class ProxyService :
|
||||
Service(),
|
||||
PlatformInterfaceWrapper {
|
||||
private val service = BoxService(this, this)
|
||||
|
||||
override fun onStartCommand(
|
||||
intent: Intent?,
|
||||
flags: Int,
|
||||
startId: Int,
|
||||
) = service.onStartCommand()
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int) = service.onStartCommand()
|
||||
|
||||
override fun onBind(intent: Intent) = service.onBind()
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@ object RootClient {
|
||||
Shell.setDefaultBuilder(
|
||||
Shell.Builder.create()
|
||||
.setFlags(Shell.FLAG_MOUNT_MASTER)
|
||||
.setTimeout(10)
|
||||
.setTimeout(10),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -95,6 +95,7 @@ object RootClient {
|
||||
val svc = bindService()
|
||||
return try {
|
||||
val slice = svc.getInstalledPackages(flags, userId)
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
val list = slice.list as List<PackageInfo>
|
||||
list
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
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 io.nekohasekai.sfa.vendor.PrivilegedServiceUtils
|
||||
import java.io.IOException
|
||||
|
||||
class RootServer : RootService() {
|
||||
@@ -17,10 +16,7 @@ class RootServer : RootService() {
|
||||
stopSelf()
|
||||
}
|
||||
|
||||
override fun getInstalledPackages(
|
||||
flags: Int,
|
||||
userId: Int
|
||||
): ParceledListSlice<PackageInfo> {
|
||||
override fun getInstalledPackages(flags: Int, userId: Int): ParceledListSlice<PackageInfo> {
|
||||
val allPackages = PrivilegedServiceUtils.getInstalledPackages(flags, userId)
|
||||
return ParceledListSlice(allPackages)
|
||||
}
|
||||
@@ -30,16 +26,12 @@ class RootServer : RootService() {
|
||||
PrivilegedServiceUtils.installPackage(apk, size, userId)
|
||||
}
|
||||
|
||||
override fun exportDebugInfo(outputPath: String?): String {
|
||||
return DebugInfoExporter.export(
|
||||
this@RootServer,
|
||||
outputPath!!,
|
||||
BuildConfig.APPLICATION_ID
|
||||
)
|
||||
}
|
||||
override fun exportDebugInfo(outputPath: String?): String = DebugInfoExporter.export(
|
||||
this@RootServer,
|
||||
outputPath!!,
|
||||
BuildConfig.APPLICATION_ID,
|
||||
)
|
||||
}
|
||||
|
||||
override fun onBind(intent: Intent): IBinder {
|
||||
return binder
|
||||
}
|
||||
override fun onBind(intent: Intent): IBinder = binder
|
||||
}
|
||||
|
||||
@@ -43,9 +43,7 @@ class ServiceBinder(private val status: MutableLiveData<Status>) : IService.Stub
|
||||
}
|
||||
}
|
||||
|
||||
override fun getStatus(): Int {
|
||||
return (status.value ?: Status.Stopped).ordinal
|
||||
}
|
||||
override fun getStatus(): Int = (status.value ?: Status.Stopped).ordinal
|
||||
|
||||
override fun registerCallback(callback: IServiceCallback) {
|
||||
callbacks.register(callback)
|
||||
|
||||
@@ -18,11 +18,7 @@ import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
class ServiceConnection(
|
||||
private val context: Context,
|
||||
callback: Callback,
|
||||
private val register: Boolean = true,
|
||||
) : ServiceConnection {
|
||||
class ServiceConnection(private val context: Context, callback: Callback, private val register: Boolean = true) : ServiceConnection {
|
||||
companion object {
|
||||
private const val TAG = "ServiceConnection"
|
||||
}
|
||||
@@ -66,10 +62,7 @@ class ServiceConnection(
|
||||
Log.d(TAG, "request reconnect")
|
||||
}
|
||||
|
||||
override fun onServiceConnected(
|
||||
name: ComponentName,
|
||||
binder: IBinder,
|
||||
) {
|
||||
override fun onServiceConnected(name: ComponentName, binder: IBinder) {
|
||||
val service = IService.Stub.asInterface(binder)
|
||||
this.service = service
|
||||
try {
|
||||
@@ -98,10 +91,7 @@ class ServiceConnection(
|
||||
interface Callback {
|
||||
fun onServiceStatusChanged(status: Status)
|
||||
|
||||
fun onServiceAlert(
|
||||
type: Alert,
|
||||
message: String?,
|
||||
) {
|
||||
fun onServiceAlert(type: Alert, message: String?) {
|
||||
}
|
||||
}
|
||||
|
||||
@@ -110,10 +100,7 @@ class ServiceConnection(
|
||||
callback.onServiceStatusChanged(Status.values()[status])
|
||||
}
|
||||
|
||||
override fun onServiceAlert(
|
||||
type: Int,
|
||||
message: String?,
|
||||
) {
|
||||
override fun onServiceAlert(type: Int, message: String?) {
|
||||
callback.onServiceAlert(Alert.values()[type], message)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,8 +16,8 @@ import androidx.lifecycle.MutableLiveData
|
||||
import io.nekohasekai.libbox.Libbox
|
||||
import io.nekohasekai.libbox.StatusMessage
|
||||
import io.nekohasekai.sfa.Application
|
||||
import io.nekohasekai.sfa.compose.MainActivity
|
||||
import io.nekohasekai.sfa.R
|
||||
import io.nekohasekai.sfa.compose.MainActivity
|
||||
import io.nekohasekai.sfa.constant.Action
|
||||
import io.nekohasekai.sfa.constant.Status
|
||||
import io.nekohasekai.sfa.database.Settings
|
||||
@@ -27,10 +27,9 @@ import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
class ServiceNotification(
|
||||
private val status: MutableLiveData<Status>,
|
||||
private val service: Service,
|
||||
) : BroadcastReceiver(), CommandClient.Handler {
|
||||
class ServiceNotification(private val status: MutableLiveData<Status>, private val service: Service) :
|
||||
BroadcastReceiver(),
|
||||
CommandClient.Handler {
|
||||
companion object {
|
||||
private const val notificationId = 1
|
||||
private const val notificationChannel = "service"
|
||||
@@ -82,10 +81,7 @@ class ServiceNotification(
|
||||
}
|
||||
}
|
||||
|
||||
fun show(
|
||||
lastProfileName: String,
|
||||
@StringRes contentTextId: Int,
|
||||
) {
|
||||
fun show(lastProfileName: String, @StringRes contentTextId: Int) {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
Application.notification.createNotificationChannel(
|
||||
NotificationChannel(
|
||||
@@ -132,10 +128,7 @@ class ServiceNotification(
|
||||
)
|
||||
}
|
||||
|
||||
override fun onReceive(
|
||||
context: Context,
|
||||
intent: Intent,
|
||||
) {
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
when (intent.action) {
|
||||
Intent.ACTION_SCREEN_ON -> {
|
||||
commandClient.connect()
|
||||
|
||||
@@ -8,7 +8,9 @@ import androidx.annotation.RequiresApi
|
||||
import io.nekohasekai.sfa.constant.Status
|
||||
|
||||
@RequiresApi(24)
|
||||
class TileService : TileService(), ServiceConnection.Callback {
|
||||
class TileService :
|
||||
TileService(),
|
||||
ServiceConnection.Callback {
|
||||
private val connection = ServiceConnection(this, this)
|
||||
|
||||
override fun onServiceStatusChanged(status: Status) {
|
||||
|
||||
@@ -59,10 +59,7 @@ class UpdateProfileWork {
|
||||
}
|
||||
}
|
||||
|
||||
class UpdateTask(
|
||||
appContext: Context,
|
||||
params: WorkerParameters,
|
||||
) : CoroutineWorker(appContext, params) {
|
||||
class UpdateTask(appContext: Context, params: WorkerParameters) : CoroutineWorker(appContext, params) {
|
||||
override suspend fun doWork(): Result {
|
||||
var selectedProfileUpdated = false
|
||||
val remoteProfiles =
|
||||
|
||||
@@ -15,18 +15,16 @@ import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
class VPNService : VpnService(), PlatformInterfaceWrapper {
|
||||
class VPNService :
|
||||
VpnService(),
|
||||
PlatformInterfaceWrapper {
|
||||
companion object {
|
||||
private const val TAG = "VPNService"
|
||||
}
|
||||
|
||||
private val service = BoxService(this, this)
|
||||
|
||||
override fun onStartCommand(
|
||||
intent: Intent?,
|
||||
flags: Int,
|
||||
startId: Int,
|
||||
) = service.onStartCommand()
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int) = service.onStartCommand()
|
||||
|
||||
override fun onBind(intent: Intent): IBinder {
|
||||
val binder = super.onBind(intent)
|
||||
|
||||
@@ -41,9 +41,9 @@ fun LineChart(
|
||||
|
||||
Canvas(
|
||||
modifier =
|
||||
modifier
|
||||
.fillMaxWidth()
|
||||
.height(80.dp),
|
||||
modifier
|
||||
.fillMaxWidth()
|
||||
.height(80.dp),
|
||||
) {
|
||||
val width = size.width
|
||||
val height = size.height
|
||||
@@ -96,11 +96,11 @@ fun LineChart(
|
||||
path = path,
|
||||
color = lineColor,
|
||||
style =
|
||||
Stroke(
|
||||
width = 2.dp.toPx(),
|
||||
cap = StrokeCap.Round,
|
||||
join = StrokeJoin.Round,
|
||||
),
|
||||
Stroke(
|
||||
width = 2.dp.toPx(),
|
||||
cap = StrokeCap.Round,
|
||||
join = StrokeJoin.Round,
|
||||
),
|
||||
)
|
||||
|
||||
// Draw gradient fill under the line
|
||||
|
||||
@@ -16,34 +16,29 @@ import androidx.compose.animation.scaleIn
|
||||
import androidx.compose.animation.scaleOut
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.PlayArrow
|
||||
import androidx.compose.material.icons.filled.Stop
|
||||
import androidx.compose.material.icons.filled.UnfoldLess
|
||||
import androidx.compose.material.icons.filled.UnfoldMore
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.material3.FloatingActionButton
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import dev.jeziellago.compose.markdowntext.MarkdownText
|
||||
import androidx.compose.material3.Badge
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kotlinx.coroutines.Job
|
||||
import androidx.compose.material3.BadgedBox
|
||||
import androidx.compose.material3.ExtendedFloatingActionButton
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.ExtendedFloatingActionButton
|
||||
import androidx.compose.material3.FloatingActionButton
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
@@ -53,9 +48,9 @@ import androidx.compose.material3.NavigationBarItem
|
||||
import androidx.compose.material3.NavigationRail
|
||||
import androidx.compose.material3.NavigationRailItem
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.SnackbarHost
|
||||
import androidx.compose.material3.SnackbarHostState
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.rememberModalBottomSheetState
|
||||
@@ -69,9 +64,12 @@ 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.platform.LocalConfiguration
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
@@ -81,6 +79,7 @@ import androidx.navigation.NavDestination.Companion.hierarchy
|
||||
import androidx.navigation.NavGraph.Companion.findStartDestination
|
||||
import androidx.navigation.compose.currentBackStackEntryAsState
|
||||
import androidx.navigation.compose.rememberNavController
|
||||
import dev.jeziellago.compose.markdowntext.MarkdownText
|
||||
import io.nekohasekai.libbox.Libbox
|
||||
import io.nekohasekai.sfa.Application
|
||||
import io.nekohasekai.sfa.BuildConfig
|
||||
@@ -91,25 +90,24 @@ 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.component.UptimeText
|
||||
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
|
||||
import io.nekohasekai.sfa.compose.screen.connections.ConnectionDetailsScreen
|
||||
import io.nekohasekai.sfa.compose.screen.connections.ConnectionsPage
|
||||
import io.nekohasekai.sfa.compose.screen.connections.ConnectionsViewModel
|
||||
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.log.LogViewModel
|
||||
import io.nekohasekai.sfa.compose.theme.SFATheme
|
||||
import io.nekohasekai.sfa.compose.topbar.LocalTopBarController
|
||||
import io.nekohasekai.sfa.compose.topbar.TopBarController
|
||||
import io.nekohasekai.sfa.compose.topbar.TopBarEntry
|
||||
import io.nekohasekai.sfa.constant.Alert
|
||||
import io.nekohasekai.sfa.constant.ServiceMode
|
||||
import io.nekohasekai.sfa.constant.Status
|
||||
@@ -119,10 +117,13 @@ import io.nekohasekai.sfa.ktx.launchCustomTab
|
||||
import io.nekohasekai.sfa.update.UpdateState
|
||||
import io.nekohasekai.sfa.vendor.Vendor
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
class MainActivity : ComponentActivity(), ServiceConnection.Callback {
|
||||
class MainActivity :
|
||||
ComponentActivity(),
|
||||
ServiceConnection.Callback {
|
||||
private val connection = ServiceConnection(this, this)
|
||||
private lateinit var dashboardViewModel: DashboardViewModel
|
||||
private var currentServiceStatus by mutableStateOf(Status.Stopped)
|
||||
@@ -253,21 +254,20 @@ class MainActivity : ComponentActivity(), ServiceConnection.Callback {
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun prepare() =
|
||||
withContext(Dispatchers.Main) {
|
||||
try {
|
||||
val intent = VpnService.prepare(this@MainActivity)
|
||||
if (intent != null) {
|
||||
prepareLauncher.launch(intent)
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
onServiceAlert(Alert.RequestVPNPermission, e.message)
|
||||
private suspend fun prepare() = withContext(Dispatchers.Main) {
|
||||
try {
|
||||
val intent = VpnService.prepare(this@MainActivity)
|
||||
if (intent != null) {
|
||||
prepareLauncher.launch(intent)
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
onServiceAlert(Alert.RequestVPNPermission, e.message)
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
@@ -388,8 +388,11 @@ class MainActivity : ComponentActivity(), ServiceConnection.Callback {
|
||||
text = {
|
||||
MarkdownText(
|
||||
markdown = stringResource(
|
||||
if (BuildConfig.FLAVOR == "play") R.string.check_update_prompt_play
|
||||
else R.string.check_update_prompt_github
|
||||
if (BuildConfig.FLAVOR == "play") {
|
||||
R.string.check_update_prompt_play
|
||||
} else {
|
||||
R.string.check_update_prompt_github
|
||||
},
|
||||
),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
@@ -534,7 +537,7 @@ class MainActivity : ComponentActivity(), ServiceConnection.Callback {
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
return GroupsViewModel(dashboardViewModel.commandClient) as T
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
} else {
|
||||
null
|
||||
@@ -729,17 +732,17 @@ class MainActivity : ComponentActivity(), ServiceConnection.Callback {
|
||||
icon = {
|
||||
Icon(
|
||||
imageVector =
|
||||
if (isRunning || isStopping) {
|
||||
Icons.Default.Stop
|
||||
} else {
|
||||
Icons.Default.PlayArrow
|
||||
},
|
||||
if (isRunning || isStopping) {
|
||||
Icons.Default.Stop
|
||||
} else {
|
||||
Icons.Default.PlayArrow
|
||||
},
|
||||
contentDescription =
|
||||
if (isRunning || isStopping) {
|
||||
stringResource(R.string.stop)
|
||||
} else {
|
||||
stringResource(R.string.action_start)
|
||||
},
|
||||
if (isRunning || isStopping) {
|
||||
stringResource(R.string.stop)
|
||||
} else {
|
||||
stringResource(R.string.action_start)
|
||||
},
|
||||
)
|
||||
},
|
||||
text = {
|
||||
@@ -873,9 +876,9 @@ class MainActivity : ComponentActivity(), ServiceConnection.Callback {
|
||||
},
|
||||
label = { Text(stringResource(screen.titleRes)) },
|
||||
selected =
|
||||
currentDestination?.hierarchy?.any {
|
||||
it.route == screen.route
|
||||
} == true,
|
||||
currentDestination?.hierarchy?.any {
|
||||
it.route == screen.route
|
||||
} == true,
|
||||
onClick = {
|
||||
navController.navigate(screen.route) {
|
||||
// Pop up to the start destination of the graph to
|
||||
@@ -909,7 +912,7 @@ class MainActivity : ComponentActivity(), ServiceConnection.Callback {
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
return GroupsViewModel(dashboardViewModel.commandClient) as T
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
val groupsUiState by groupsViewModel.uiState.collectAsState()
|
||||
val allCollapsed = groupsUiState.expandedGroups.isEmpty()
|
||||
@@ -943,12 +946,16 @@ class MainActivity : ComponentActivity(), ServiceConnection.Callback {
|
||||
if (groupsUiState.groups.isNotEmpty()) {
|
||||
IconButton(onClick = { groupsViewModel.toggleAllGroups() }) {
|
||||
Icon(
|
||||
imageVector = if (allCollapsed) Icons.Default.UnfoldMore
|
||||
else Icons.Default.UnfoldLess,
|
||||
contentDescription = if (allCollapsed)
|
||||
imageVector = if (allCollapsed) {
|
||||
Icons.Default.UnfoldMore
|
||||
} else {
|
||||
Icons.Default.UnfoldLess
|
||||
},
|
||||
contentDescription = if (allCollapsed) {
|
||||
stringResource(R.string.expand_all)
|
||||
else
|
||||
stringResource(R.string.collapse_all),
|
||||
} else {
|
||||
stringResource(R.string.collapse_all)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1032,10 +1039,7 @@ class MainActivity : ComponentActivity(), ServiceConnection.Callback {
|
||||
connection.reconnect()
|
||||
}
|
||||
|
||||
override fun onServiceAlert(
|
||||
type: Alert,
|
||||
message: String?,
|
||||
) {
|
||||
override fun onServiceAlert(type: Alert, message: String?) {
|
||||
when (type) {
|
||||
Alert.RequestLocationPermission -> {
|
||||
return requestLocationPermission()
|
||||
@@ -1071,11 +1075,7 @@ class MainActivity : ComponentActivity(), ServiceConnection.Callback {
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ServiceAlertDialog(
|
||||
alertType: Alert,
|
||||
message: String?,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
private fun ServiceAlertDialog(alertType: Alert, message: String?, onDismiss: () -> Unit) {
|
||||
val title =
|
||||
when (alertType) {
|
||||
Alert.RequestNotificationPermission -> stringResource(R.string.notification_permission_title)
|
||||
@@ -1106,10 +1106,7 @@ class MainActivity : ComponentActivity(), ServiceConnection.Callback {
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LocationPermissionDialog(
|
||||
onConfirm: () -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
private fun LocationPermissionDialog(onConfirm: () -> Unit, onDismiss: () -> Unit) {
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text(stringResource(R.string.location_permission_title)) },
|
||||
@@ -1128,10 +1125,7 @@ class MainActivity : ComponentActivity(), ServiceConnection.Callback {
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun BackgroundLocationPermissionDialog(
|
||||
onConfirm: () -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
private fun BackgroundLocationPermissionDialog(onConfirm: () -> Unit, onDismiss: () -> Unit) {
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text(stringResource(R.string.location_permission_title)) },
|
||||
|
||||
@@ -56,10 +56,7 @@ abstract class BaseViewModel<State, Event> : ViewModel() {
|
||||
sendGlobalEvent(UiEvent.ErrorMessage(message))
|
||||
}
|
||||
|
||||
protected fun launch(
|
||||
onError: ((Throwable) -> Unit)? = null,
|
||||
block: suspend CoroutineScope.() -> Unit,
|
||||
) {
|
||||
protected fun launch(onError: ((Throwable) -> Unit)? = null, block: suspend CoroutineScope.() -> Unit) {
|
||||
val errorHandler =
|
||||
CoroutineExceptionHandler { _, throwable ->
|
||||
onError?.invoke(throwable) ?: sendError(throwable)
|
||||
|
||||
@@ -29,7 +29,5 @@ object GlobalEventBus {
|
||||
* Try to emit an event without suspending.
|
||||
* Returns true if the event was emitted successfully.
|
||||
*/
|
||||
fun tryEmit(event: UiEvent): Boolean {
|
||||
return _events.tryEmit(event)
|
||||
}
|
||||
fun tryEmit(event: UiEvent): Boolean = _events.tryEmit(event)
|
||||
}
|
||||
|
||||
@@ -19,11 +19,7 @@ import androidx.compose.ui.unit.dp
|
||||
import io.nekohasekai.sfa.R
|
||||
|
||||
@Composable
|
||||
fun SelectableMessageDialog(
|
||||
title: String,
|
||||
message: String,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
fun SelectableMessageDialog(title: String, message: String, onDismiss: () -> Unit) {
|
||||
val clipboard = LocalClipboardManager.current
|
||||
val context = LocalContext.current
|
||||
val scrollState = rememberScrollState()
|
||||
|
||||
@@ -8,8 +8,4 @@ sealed class UiState<out T> {
|
||||
data class Error(val exception: Throwable, val message: String? = null) : UiState<Nothing>()
|
||||
}
|
||||
|
||||
data class BaseUiState<T>(
|
||||
val isLoading: Boolean = false,
|
||||
val data: T? = null,
|
||||
val error: String? = null,
|
||||
)
|
||||
data class BaseUiState<T>(val isLoading: Boolean = false, val data: T? = null, val error: String? = null)
|
||||
|
||||
@@ -65,9 +65,9 @@ fun ServiceStatusBar(
|
||||
) {
|
||||
Row(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
@@ -85,11 +85,11 @@ fun ServiceStatusBar(
|
||||
// Connections button
|
||||
Row(
|
||||
modifier =
|
||||
Modifier
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.background(MaterialTheme.colorScheme.secondaryContainer)
|
||||
.clickable(onClick = onConnectionsClick)
|
||||
.padding(horizontal = 12.dp, vertical = 8.dp),
|
||||
Modifier
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.background(MaterialTheme.colorScheme.secondaryContainer)
|
||||
.clickable(onClick = onConnectionsClick)
|
||||
.padding(horizontal = 12.dp, vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
) {
|
||||
@@ -112,11 +112,11 @@ fun ServiceStatusBar(
|
||||
if (hasGroups) {
|
||||
Row(
|
||||
modifier =
|
||||
Modifier
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.background(MaterialTheme.colorScheme.secondaryContainer)
|
||||
.clickable(onClick = onGroupsClick)
|
||||
.padding(horizontal = 12.dp, vertical = 8.dp),
|
||||
Modifier
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.background(MaterialTheme.colorScheme.secondaryContainer)
|
||||
.clickable(onClick = onGroupsClick)
|
||||
.padding(horizontal = 12.dp, vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
) {
|
||||
@@ -139,11 +139,11 @@ fun ServiceStatusBar(
|
||||
// Stop button
|
||||
Row(
|
||||
modifier =
|
||||
Modifier
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.background(MaterialTheme.colorScheme.primaryContainer)
|
||||
.clickable(onClick = onStopClick)
|
||||
.padding(horizontal = 12.dp, vertical = 8.dp),
|
||||
Modifier
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.background(MaterialTheme.colorScheme.primaryContainer)
|
||||
.clickable(onClick = onStopClick)
|
||||
.padding(horizontal = 12.dp, vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
) {
|
||||
@@ -164,10 +164,7 @@ fun ServiceStatusBar(
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun StatusItem(
|
||||
text: String,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
private fun StatusItem(text: String, modifier: Modifier = Modifier) {
|
||||
Text(
|
||||
text = text,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
@@ -178,10 +175,7 @@ private fun StatusItem(
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun UptimeText(
|
||||
startTime: Long,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
fun UptimeText(startTime: Long, modifier: Modifier = Modifier) {
|
||||
var currentTime by remember { mutableLongStateOf(System.currentTimeMillis()) }
|
||||
|
||||
LaunchedEffect(startTime) {
|
||||
|
||||
@@ -27,11 +27,7 @@ import org.kodein.emoji.EmojiTemplateCatalog
|
||||
import org.kodein.emoji.all
|
||||
|
||||
@Composable
|
||||
fun UpdateAvailableDialog(
|
||||
updateInfo: UpdateInfo,
|
||||
onDismiss: () -> Unit,
|
||||
onUpdate: () -> Unit,
|
||||
) {
|
||||
fun UpdateAvailableDialog(updateInfo: UpdateInfo, onDismiss: () -> Unit, onUpdate: () -> Unit) {
|
||||
val context = LocalContext.current
|
||||
val emojiCatalog = remember { EmojiTemplateCatalog(Emoji.all()) }
|
||||
|
||||
|
||||
@@ -7,7 +7,6 @@ import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.aspectRatio
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.wrapContentHeight
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Card
|
||||
@@ -24,24 +23,21 @@ import androidx.compose.ui.window.Dialog
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
|
||||
@Composable
|
||||
fun QRCodeDialog(
|
||||
bitmap: Bitmap,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
fun QRCodeDialog(bitmap: Bitmap, onDismiss: () -> Unit) {
|
||||
Dialog(
|
||||
onDismissRequest = onDismiss,
|
||||
properties = DialogProperties(usePlatformDefaultWidth = false),
|
||||
) {
|
||||
Card(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth(0.9f)
|
||||
.wrapContentHeight(),
|
||||
Modifier
|
||||
.fillMaxWidth(0.9f)
|
||||
.wrapContentHeight(),
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
colors =
|
||||
CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surface,
|
||||
),
|
||||
CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surface,
|
||||
),
|
||||
) {
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
@@ -52,9 +48,9 @@ fun QRCodeDialog(
|
||||
) {
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.background(MaterialTheme.colorScheme.surface),
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.background(MaterialTheme.colorScheme.surface),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Image(
|
||||
|
||||
@@ -33,6 +33,7 @@ class QRSBitmapGenerator(
|
||||
private val actualBufferSize = bufferSize.coerceAtMost(frames.size)
|
||||
private val bitmapBuffer = arrayOfNulls<Bitmap>(actualBufferSize)
|
||||
private var generationJob: Job? = null
|
||||
|
||||
@Volatile
|
||||
private var currentFrameIndex = 0
|
||||
private var generatedUpTo = -1
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
package io.nekohasekai.sfa.compose.component.qr
|
||||
|
||||
import android.content.Intent
|
||||
import android.content.res.Configuration
|
||||
import android.graphics.Color
|
||||
import android.net.Uri
|
||||
import android.content.res.Configuration
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
@@ -58,11 +58,7 @@ import io.nekohasekai.sfa.qrs.QRSEncoder
|
||||
import kotlinx.coroutines.delay
|
||||
|
||||
@Composable
|
||||
fun QRSDialog(
|
||||
profileData: ByteArray,
|
||||
profileName: String,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
fun QRSDialog(profileData: ByteArray, profileName: String, onDismiss: () -> Unit) {
|
||||
val context = LocalContext.current
|
||||
val configuration = LocalConfiguration.current
|
||||
val isTablet = configuration.isLayoutSizeAtLeast(Configuration.SCREENLAYOUT_SIZE_LARGE)
|
||||
@@ -126,16 +122,16 @@ fun QRSDialog(
|
||||
) {
|
||||
Card(
|
||||
modifier =
|
||||
if (isTablet) {
|
||||
Modifier
|
||||
.fillMaxWidth(0.85f)
|
||||
.sizeIn(maxWidth = 960.dp)
|
||||
.wrapContentHeight()
|
||||
} else {
|
||||
Modifier
|
||||
.fillMaxWidth(0.9f)
|
||||
.wrapContentHeight()
|
||||
},
|
||||
if (isTablet) {
|
||||
Modifier
|
||||
.fillMaxWidth(0.85f)
|
||||
.sizeIn(maxWidth = 960.dp)
|
||||
.wrapContentHeight()
|
||||
} else {
|
||||
Modifier
|
||||
.fillMaxWidth(0.9f)
|
||||
.wrapContentHeight()
|
||||
},
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surface,
|
||||
|
||||
@@ -40,8 +40,8 @@ import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.geometry.Rect
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.geometry.Rect
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
@@ -54,18 +54,14 @@ import androidx.core.content.ContextCompat
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import io.nekohasekai.sfa.R
|
||||
import io.nekohasekai.sfa.compose.screen.qrscan.QRCodeCropArea
|
||||
import io.nekohasekai.sfa.compose.screen.qrscan.QRScanResult
|
||||
import io.nekohasekai.sfa.compose.screen.qrscan.QRScanViewModel
|
||||
import io.nekohasekai.sfa.compose.screen.qrscan.QRCodeCropArea
|
||||
import kotlin.math.max
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun QRScanSheet(
|
||||
onDismiss: () -> Unit,
|
||||
onScanResult: (QRScanResult) -> Unit,
|
||||
viewModel: QRScanViewModel = viewModel(),
|
||||
) {
|
||||
fun QRScanSheet(onDismiss: () -> Unit, onScanResult: (QRScanResult) -> Unit, viewModel: QRScanViewModel = viewModel()) {
|
||||
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
|
||||
val context = LocalContext.current
|
||||
val lifecycleOwner = LocalLifecycleOwner.current
|
||||
@@ -74,12 +70,12 @@ fun QRScanSheet(
|
||||
var hasPermission by remember {
|
||||
mutableStateOf(
|
||||
ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) ==
|
||||
PackageManager.PERMISSION_GRANTED
|
||||
PackageManager.PERMISSION_GRANTED,
|
||||
)
|
||||
}
|
||||
|
||||
val permissionLauncher = rememberLauncherForActivityResult(
|
||||
contract = ActivityResultContracts.RequestPermission()
|
||||
contract = ActivityResultContracts.RequestPermission(),
|
||||
) { isGranted ->
|
||||
if (isGranted) {
|
||||
hasPermission = true
|
||||
@@ -113,7 +109,7 @@ fun QRScanSheet(
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.fillMaxHeight(0.9f)
|
||||
.fillMaxHeight(0.9f),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
@@ -138,44 +134,44 @@ fun QRScanSheet(
|
||||
}
|
||||
DropdownMenu(
|
||||
expanded = showMenu,
|
||||
onDismissRequest = { showMenu = false }
|
||||
onDismissRequest = { showMenu = false },
|
||||
) {
|
||||
DropdownMenuItem(
|
||||
text = {
|
||||
Text(
|
||||
(if (uiState.useFrontCamera) "✓ " else " ") +
|
||||
stringResource(R.string.profile_add_scan_use_front_camera)
|
||||
stringResource(R.string.profile_add_scan_use_front_camera),
|
||||
)
|
||||
},
|
||||
onClick = {
|
||||
viewModel.toggleFrontCamera(lifecycleOwner)
|
||||
showMenu = false
|
||||
}
|
||||
},
|
||||
)
|
||||
DropdownMenuItem(
|
||||
text = {
|
||||
Text(
|
||||
(if (uiState.torchEnabled) "✓ " else " ") +
|
||||
stringResource(R.string.profile_add_scan_enable_torch)
|
||||
stringResource(R.string.profile_add_scan_enable_torch),
|
||||
)
|
||||
},
|
||||
onClick = {
|
||||
viewModel.toggleTorch()
|
||||
showMenu = false
|
||||
}
|
||||
},
|
||||
)
|
||||
if (uiState.vendorAnalyzerAvailable) {
|
||||
DropdownMenuItem(
|
||||
text = {
|
||||
Text(
|
||||
(if (uiState.useVendorAnalyzer) "✓ " else " ") +
|
||||
stringResource(R.string.profile_add_scan_use_vendor_analyzer)
|
||||
stringResource(R.string.profile_add_scan_use_vendor_analyzer),
|
||||
)
|
||||
},
|
||||
onClick = {
|
||||
viewModel.toggleVendorAnalyzer()
|
||||
showMenu = false
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -185,7 +181,7 @@ fun QRScanSheet(
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.weight(1f)
|
||||
.weight(1f),
|
||||
) {
|
||||
if (hasPermission) {
|
||||
CameraPreview(
|
||||
@@ -201,7 +197,7 @@ fun QRScanSheet(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(MaterialTheme.colorScheme.surface),
|
||||
contentAlignment = Alignment.Center
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
CircularProgressIndicator()
|
||||
}
|
||||
@@ -214,7 +210,7 @@ fun QRScanSheet(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(Color.Black.copy(alpha = 0.5f)),
|
||||
contentAlignment = Alignment.Center
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Box(contentAlignment = Alignment.Center) {
|
||||
CircularProgressIndicator(
|
||||
@@ -228,18 +224,18 @@ fun QRScanSheet(
|
||||
Text(
|
||||
text = "${minOf(99, (progress * 100).toInt())}%",
|
||||
style = MaterialTheme.typography.titleLarge.copy(
|
||||
fontWeight = FontWeight.SemiBold
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
),
|
||||
color = Color.White
|
||||
color = Color.White,
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = "QRS",
|
||||
style = MaterialTheme.typography.headlineLarge.copy(
|
||||
fontWeight = FontWeight.Bold
|
||||
fontWeight = FontWeight.Bold,
|
||||
),
|
||||
color = Color.White,
|
||||
modifier = Modifier.offset(y = (-88).dp)
|
||||
modifier = Modifier.offset(y = (-88).dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -257,7 +253,7 @@ fun QRScanSheet(
|
||||
TextButton(onClick = { viewModel.dismissError() }) {
|
||||
Text(stringResource(android.R.string.ok))
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -294,7 +290,7 @@ private fun CameraPreview(
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
Canvas(modifier = Modifier.fillMaxSize()) {
|
||||
@@ -309,11 +305,7 @@ private fun CameraPreview(
|
||||
}
|
||||
}
|
||||
|
||||
private fun mapCropAreaToPreview(
|
||||
area: QRCodeCropArea,
|
||||
viewWidth: Float,
|
||||
viewHeight: Float,
|
||||
): Rect? {
|
||||
private fun mapCropAreaToPreview(area: QRCodeCropArea, viewWidth: Float, viewHeight: Float): Rect? {
|
||||
if (viewWidth <= 0f || viewHeight <= 0f) return null
|
||||
|
||||
val rotation = ((area.rotationDegrees % 360) + 360) % 360
|
||||
|
||||
@@ -1,18 +1,12 @@
|
||||
package io.nekohasekai.sfa.compose.model
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import io.nekohasekai.sfa.ktx.toList
|
||||
import io.nekohasekai.libbox.Connection as LibboxConnection
|
||||
import io.nekohasekai.libbox.ProcessInfo as LibboxProcessInfo
|
||||
import io.nekohasekai.sfa.ktx.toList
|
||||
|
||||
@Immutable
|
||||
data class ProcessInfo(
|
||||
val processId: Long,
|
||||
val userId: Int,
|
||||
val userName: String,
|
||||
val processPath: String,
|
||||
val packageName: String,
|
||||
) {
|
||||
data class ProcessInfo(val processId: Long, val userId: Int, val userName: String, val processPath: String, val packageName: String) {
|
||||
companion object {
|
||||
fun from(processInfo: LibboxProcessInfo?): ProcessInfo? {
|
||||
if (processInfo == null) return null
|
||||
@@ -68,59 +62,53 @@ data class Connection(
|
||||
return true
|
||||
}
|
||||
|
||||
private fun performSearchPlain(content: String): Boolean {
|
||||
return destination.contains(content, ignoreCase = true) ||
|
||||
domain.contains(content, ignoreCase = true) ||
|
||||
outbound.contains(content, ignoreCase = true) ||
|
||||
rule.contains(content, ignoreCase = true) ||
|
||||
processInfo?.packageName?.contains(content, ignoreCase = true) == true
|
||||
}
|
||||
private fun performSearchPlain(content: String): Boolean = destination.contains(content, ignoreCase = true) ||
|
||||
domain.contains(content, ignoreCase = true) ||
|
||||
outbound.contains(content, ignoreCase = true) ||
|
||||
rule.contains(content, ignoreCase = true) ||
|
||||
processInfo?.packageName?.contains(content, ignoreCase = true) == true
|
||||
|
||||
private fun performSearchType(type: String, value: String): Boolean {
|
||||
return when (type) {
|
||||
"network" -> network.equals(value, ignoreCase = true)
|
||||
"inbound" -> inbound.contains(value, ignoreCase = true)
|
||||
"inbound.type" -> inboundType.equals(value, ignoreCase = true)
|
||||
"source" -> source.contains(value, ignoreCase = true)
|
||||
"destination" -> destination.contains(value, ignoreCase = true)
|
||||
"outbound" -> outbound.contains(value, ignoreCase = true)
|
||||
"outbound.type" -> outboundType.equals(value, ignoreCase = true)
|
||||
"rule" -> rule.contains(value, ignoreCase = true)
|
||||
"protocol" -> protocolName.equals(value, ignoreCase = true)
|
||||
"user" -> user.contains(value, ignoreCase = true)
|
||||
"package" -> processInfo?.packageName?.contains(value, ignoreCase = true) == true
|
||||
"chain" -> chain.any { it.contains(value, ignoreCase = true) }
|
||||
else -> false
|
||||
}
|
||||
private fun performSearchType(type: String, value: String): Boolean = when (type) {
|
||||
"network" -> network.equals(value, ignoreCase = true)
|
||||
"inbound" -> inbound.contains(value, ignoreCase = true)
|
||||
"inbound.type" -> inboundType.equals(value, ignoreCase = true)
|
||||
"source" -> source.contains(value, ignoreCase = true)
|
||||
"destination" -> destination.contains(value, ignoreCase = true)
|
||||
"outbound" -> outbound.contains(value, ignoreCase = true)
|
||||
"outbound.type" -> outboundType.equals(value, ignoreCase = true)
|
||||
"rule" -> rule.contains(value, ignoreCase = true)
|
||||
"protocol" -> protocolName.equals(value, ignoreCase = true)
|
||||
"user" -> user.contains(value, ignoreCase = true)
|
||||
"package" -> processInfo?.packageName?.contains(value, ignoreCase = true) == true
|
||||
"chain" -> chain.any { it.contains(value, ignoreCase = true) }
|
||||
else -> false
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun from(connection: LibboxConnection): Connection {
|
||||
return Connection(
|
||||
id = connection.id,
|
||||
inbound = connection.inbound,
|
||||
inboundType = connection.inboundType,
|
||||
ipVersion = connection.ipVersion,
|
||||
network = connection.network,
|
||||
source = connection.source,
|
||||
destination = connection.destination,
|
||||
domain = connection.domain,
|
||||
displayDestination = connection.displayDestination(),
|
||||
protocolName = connection.protocol,
|
||||
user = connection.user,
|
||||
fromOutbound = connection.fromOutbound,
|
||||
createdAt = connection.createdAt,
|
||||
closedAt = if (connection.closedAt > 0) connection.closedAt else null,
|
||||
upload = connection.uplink,
|
||||
download = connection.downlink,
|
||||
uploadTotal = connection.uplinkTotal,
|
||||
downloadTotal = connection.downlinkTotal,
|
||||
rule = connection.rule,
|
||||
outbound = connection.outbound,
|
||||
outboundType = connection.outboundType,
|
||||
chain = connection.chain().toList(),
|
||||
processInfo = ProcessInfo.from(connection.processInfo),
|
||||
)
|
||||
}
|
||||
fun from(connection: LibboxConnection): Connection = Connection(
|
||||
id = connection.id,
|
||||
inbound = connection.inbound,
|
||||
inboundType = connection.inboundType,
|
||||
ipVersion = connection.ipVersion,
|
||||
network = connection.network,
|
||||
source = connection.source,
|
||||
destination = connection.destination,
|
||||
domain = connection.domain,
|
||||
displayDestination = connection.displayDestination(),
|
||||
protocolName = connection.protocol,
|
||||
user = connection.user,
|
||||
fromOutbound = connection.fromOutbound,
|
||||
createdAt = connection.createdAt,
|
||||
closedAt = if (connection.closedAt > 0) connection.closedAt else null,
|
||||
upload = connection.uplink,
|
||||
download = connection.downlink,
|
||||
uploadTotal = connection.uplinkTotal,
|
||||
downloadTotal = connection.downlinkTotal,
|
||||
rule = connection.rule,
|
||||
outbound = connection.outbound,
|
||||
outboundType = connection.outboundType,
|
||||
chain = connection.chain().toList(),
|
||||
processInfo = ProcessInfo.from(connection.processInfo),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,12 +24,7 @@ data class Group(
|
||||
}
|
||||
|
||||
@Immutable
|
||||
data class GroupItem(
|
||||
val tag: String,
|
||||
val type: String,
|
||||
val urlTestTime: Long,
|
||||
val urlTestDelay: Int,
|
||||
) {
|
||||
data class GroupItem(val tag: String, val type: String, val urlTestTime: Long, val urlTestDelay: Int) {
|
||||
constructor(item: OutboundGroupItem) : this(
|
||||
item.tag,
|
||||
item.type,
|
||||
|
||||
@@ -10,11 +10,7 @@ import androidx.compose.material.icons.filled.SwapVert
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import io.nekohasekai.sfa.R
|
||||
|
||||
sealed class Screen(
|
||||
val route: String,
|
||||
@StringRes val titleRes: Int,
|
||||
val icon: ImageVector,
|
||||
) {
|
||||
sealed class Screen(val route: String, @StringRes val titleRes: Int, val icon: ImageVector) {
|
||||
object Dashboard : Screen(
|
||||
route = "dashboard",
|
||||
titleRes = R.string.title_dashboard,
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
package io.nekohasekai.sfa.compose.navigation
|
||||
|
||||
data class NewProfileArgs(
|
||||
val importName: String? = null,
|
||||
val importUrl: String? = null,
|
||||
val qrsData: ByteArray? = null,
|
||||
)
|
||||
data class NewProfileArgs(val importName: String? = null, val importUrl: String? = null, val qrsData: ByteArray? = null)
|
||||
|
||||
object ProfileRoutes {
|
||||
const val NewProfile = "profile/new"
|
||||
|
||||
@@ -12,18 +12,20 @@ import androidx.navigation.NavType
|
||||
import androidx.navigation.compose.NavHost
|
||||
import androidx.navigation.compose.composable
|
||||
import androidx.navigation.navArgument
|
||||
import io.nekohasekai.sfa.compose.screen.configuration.NewProfileScreen
|
||||
import io.nekohasekai.sfa.compose.screen.connections.ConnectionDetailsRoute
|
||||
import io.nekohasekai.sfa.compose.screen.connections.ConnectionsPage
|
||||
import io.nekohasekai.sfa.compose.screen.connections.ConnectionsViewModel
|
||||
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.privilegesettings.PrivilegeSettingsManageScreen
|
||||
import io.nekohasekai.sfa.compose.screen.profile.EditProfileRoute
|
||||
import io.nekohasekai.sfa.compose.screen.profileoverride.PerAppProxyScreen
|
||||
import io.nekohasekai.sfa.compose.screen.settings.AppSettingsScreen
|
||||
import io.nekohasekai.sfa.compose.screen.settings.CoreSettingsScreen
|
||||
import io.nekohasekai.sfa.compose.screen.settings.PrivilegeSettingsScreen
|
||||
@@ -31,8 +33,6 @@ 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))
|
||||
|
||||
@@ -15,7 +15,6 @@ 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
|
||||
@@ -60,6 +59,7 @@ import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.unit.dp
|
||||
@@ -149,9 +149,9 @@ fun NewProfileScreen(
|
||||
}
|
||||
},
|
||||
colors =
|
||||
TopAppBarDefaults.topAppBarColors(
|
||||
containerColor = MaterialTheme.colorScheme.surface,
|
||||
),
|
||||
TopAppBarDefaults.topAppBarColors(
|
||||
containerColor = MaterialTheme.colorScheme.surface,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -164,20 +164,20 @@ fun NewProfileScreen(
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
Column(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(16.dp)
|
||||
.padding(bottom = bottomBarPadding),
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(16.dp)
|
||||
.padding(bottom = bottomBarPadding),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
// Profile Name
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors =
|
||||
CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f),
|
||||
),
|
||||
CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f),
|
||||
),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
@@ -211,9 +211,9 @@ fun NewProfileScreen(
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors =
|
||||
CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f),
|
||||
),
|
||||
CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f),
|
||||
),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
@@ -233,30 +233,30 @@ fun NewProfileScreen(
|
||||
onClick = { viewModel.updateProfileType(ProfileType.Local) },
|
||||
modifier = Modifier.weight(1f),
|
||||
shape =
|
||||
RoundedCornerShape(
|
||||
topStart = 12.dp,
|
||||
bottomStart = 12.dp,
|
||||
topEnd = 0.dp,
|
||||
bottomEnd = 0.dp,
|
||||
),
|
||||
RoundedCornerShape(
|
||||
topStart = 12.dp,
|
||||
bottomStart = 12.dp,
|
||||
topEnd = 0.dp,
|
||||
bottomEnd = 0.dp,
|
||||
),
|
||||
colors =
|
||||
if (uiState.profileType == ProfileType.Local) {
|
||||
ButtonDefaults.buttonColors(
|
||||
containerColor = MaterialTheme.colorScheme.primaryContainer,
|
||||
contentColor = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||
)
|
||||
} else {
|
||||
ButtonDefaults.outlinedButtonColors()
|
||||
},
|
||||
if (uiState.profileType == ProfileType.Local) {
|
||||
ButtonDefaults.buttonColors(
|
||||
containerColor = MaterialTheme.colorScheme.primaryContainer,
|
||||
contentColor = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||
)
|
||||
} else {
|
||||
ButtonDefaults.outlinedButtonColors()
|
||||
},
|
||||
border =
|
||||
BorderStroke(
|
||||
1.dp,
|
||||
if (uiState.profileType == ProfileType.Local) {
|
||||
MaterialTheme.colorScheme.primary
|
||||
} else {
|
||||
MaterialTheme.colorScheme.outline
|
||||
},
|
||||
),
|
||||
BorderStroke(
|
||||
1.dp,
|
||||
if (uiState.profileType == ProfileType.Local) {
|
||||
MaterialTheme.colorScheme.primary
|
||||
} else {
|
||||
MaterialTheme.colorScheme.outline
|
||||
},
|
||||
),
|
||||
) {
|
||||
Text(stringResource(R.string.profile_type_local))
|
||||
}
|
||||
@@ -264,30 +264,30 @@ fun NewProfileScreen(
|
||||
onClick = { viewModel.updateProfileType(ProfileType.Remote) },
|
||||
modifier = Modifier.weight(1f),
|
||||
shape =
|
||||
RoundedCornerShape(
|
||||
topStart = 0.dp,
|
||||
bottomStart = 0.dp,
|
||||
topEnd = 12.dp,
|
||||
bottomEnd = 12.dp,
|
||||
),
|
||||
RoundedCornerShape(
|
||||
topStart = 0.dp,
|
||||
bottomStart = 0.dp,
|
||||
topEnd = 12.dp,
|
||||
bottomEnd = 12.dp,
|
||||
),
|
||||
colors =
|
||||
if (uiState.profileType == ProfileType.Remote) {
|
||||
ButtonDefaults.buttonColors(
|
||||
containerColor = MaterialTheme.colorScheme.primaryContainer,
|
||||
contentColor = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||
)
|
||||
} else {
|
||||
ButtonDefaults.outlinedButtonColors()
|
||||
},
|
||||
if (uiState.profileType == ProfileType.Remote) {
|
||||
ButtonDefaults.buttonColors(
|
||||
containerColor = MaterialTheme.colorScheme.primaryContainer,
|
||||
contentColor = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||
)
|
||||
} else {
|
||||
ButtonDefaults.outlinedButtonColors()
|
||||
},
|
||||
border =
|
||||
BorderStroke(
|
||||
1.dp,
|
||||
if (uiState.profileType == ProfileType.Remote) {
|
||||
MaterialTheme.colorScheme.primary
|
||||
} else {
|
||||
MaterialTheme.colorScheme.outline
|
||||
},
|
||||
),
|
||||
BorderStroke(
|
||||
1.dp,
|
||||
if (uiState.profileType == ProfileType.Remote) {
|
||||
MaterialTheme.colorScheme.primary
|
||||
} else {
|
||||
MaterialTheme.colorScheme.outline
|
||||
},
|
||||
),
|
||||
) {
|
||||
Text(stringResource(R.string.profile_type_remote))
|
||||
}
|
||||
@@ -304,9 +304,9 @@ fun NewProfileScreen(
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors =
|
||||
CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.secondaryContainer.copy(alpha = 0.3f),
|
||||
),
|
||||
CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.secondaryContainer.copy(alpha = 0.3f),
|
||||
),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
@@ -326,30 +326,30 @@ fun NewProfileScreen(
|
||||
onClick = { viewModel.updateProfileSource(ProfileSource.CreateNew) },
|
||||
modifier = Modifier.weight(1f),
|
||||
shape =
|
||||
RoundedCornerShape(
|
||||
topStart = 12.dp,
|
||||
bottomStart = 12.dp,
|
||||
topEnd = 0.dp,
|
||||
bottomEnd = 0.dp,
|
||||
),
|
||||
RoundedCornerShape(
|
||||
topStart = 12.dp,
|
||||
bottomStart = 12.dp,
|
||||
topEnd = 0.dp,
|
||||
bottomEnd = 0.dp,
|
||||
),
|
||||
colors =
|
||||
if (uiState.profileSource == ProfileSource.CreateNew) {
|
||||
ButtonDefaults.buttonColors(
|
||||
containerColor = MaterialTheme.colorScheme.secondaryContainer,
|
||||
contentColor = MaterialTheme.colorScheme.onSecondaryContainer,
|
||||
)
|
||||
} else {
|
||||
ButtonDefaults.outlinedButtonColors()
|
||||
},
|
||||
if (uiState.profileSource == ProfileSource.CreateNew) {
|
||||
ButtonDefaults.buttonColors(
|
||||
containerColor = MaterialTheme.colorScheme.secondaryContainer,
|
||||
contentColor = MaterialTheme.colorScheme.onSecondaryContainer,
|
||||
)
|
||||
} else {
|
||||
ButtonDefaults.outlinedButtonColors()
|
||||
},
|
||||
border =
|
||||
BorderStroke(
|
||||
1.dp,
|
||||
if (uiState.profileSource == ProfileSource.CreateNew) {
|
||||
MaterialTheme.colorScheme.secondary
|
||||
} else {
|
||||
MaterialTheme.colorScheme.outline
|
||||
},
|
||||
),
|
||||
BorderStroke(
|
||||
1.dp,
|
||||
if (uiState.profileSource == ProfileSource.CreateNew) {
|
||||
MaterialTheme.colorScheme.secondary
|
||||
} else {
|
||||
MaterialTheme.colorScheme.outline
|
||||
},
|
||||
),
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.CreateNewFolder,
|
||||
@@ -363,30 +363,30 @@ fun NewProfileScreen(
|
||||
onClick = { viewModel.updateProfileSource(ProfileSource.Import) },
|
||||
modifier = Modifier.weight(1f),
|
||||
shape =
|
||||
RoundedCornerShape(
|
||||
topStart = 0.dp,
|
||||
bottomStart = 0.dp,
|
||||
topEnd = 12.dp,
|
||||
bottomEnd = 12.dp,
|
||||
),
|
||||
RoundedCornerShape(
|
||||
topStart = 0.dp,
|
||||
bottomStart = 0.dp,
|
||||
topEnd = 12.dp,
|
||||
bottomEnd = 12.dp,
|
||||
),
|
||||
colors =
|
||||
if (uiState.profileSource == ProfileSource.Import) {
|
||||
ButtonDefaults.buttonColors(
|
||||
containerColor = MaterialTheme.colorScheme.secondaryContainer,
|
||||
contentColor = MaterialTheme.colorScheme.onSecondaryContainer,
|
||||
)
|
||||
} else {
|
||||
ButtonDefaults.outlinedButtonColors()
|
||||
},
|
||||
if (uiState.profileSource == ProfileSource.Import) {
|
||||
ButtonDefaults.buttonColors(
|
||||
containerColor = MaterialTheme.colorScheme.secondaryContainer,
|
||||
contentColor = MaterialTheme.colorScheme.onSecondaryContainer,
|
||||
)
|
||||
} else {
|
||||
ButtonDefaults.outlinedButtonColors()
|
||||
},
|
||||
border =
|
||||
BorderStroke(
|
||||
1.dp,
|
||||
if (uiState.profileSource == ProfileSource.Import) {
|
||||
MaterialTheme.colorScheme.secondary
|
||||
} else {
|
||||
MaterialTheme.colorScheme.outline
|
||||
},
|
||||
),
|
||||
BorderStroke(
|
||||
1.dp,
|
||||
if (uiState.profileSource == ProfileSource.Import) {
|
||||
MaterialTheme.colorScheme.secondary
|
||||
} else {
|
||||
MaterialTheme.colorScheme.outline
|
||||
},
|
||||
),
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.FileUpload,
|
||||
@@ -408,20 +408,20 @@ fun NewProfileScreen(
|
||||
onClick = { filePickerLauncher.launch("*/*") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
border =
|
||||
BorderStroke(
|
||||
1.dp,
|
||||
if (uiState.importError != null) {
|
||||
MaterialTheme.colorScheme.error
|
||||
} else {
|
||||
MaterialTheme.colorScheme.outline
|
||||
},
|
||||
),
|
||||
BorderStroke(
|
||||
1.dp,
|
||||
if (uiState.importError != null) {
|
||||
MaterialTheme.colorScheme.error
|
||||
} else {
|
||||
MaterialTheme.colorScheme.outline
|
||||
},
|
||||
),
|
||||
) {
|
||||
Row(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
@@ -429,11 +429,11 @@ fun NewProfileScreen(
|
||||
Icons.Default.FileUpload,
|
||||
contentDescription = null,
|
||||
tint =
|
||||
if (uiState.importError != null) {
|
||||
MaterialTheme.colorScheme.error
|
||||
} else {
|
||||
MaterialTheme.colorScheme.primary
|
||||
},
|
||||
if (uiState.importError != null) {
|
||||
MaterialTheme.colorScheme.error
|
||||
} else {
|
||||
MaterialTheme.colorScheme.primary
|
||||
},
|
||||
)
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
@@ -473,9 +473,9 @@ fun NewProfileScreen(
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors =
|
||||
CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.tertiaryContainer.copy(alpha = 0.3f),
|
||||
),
|
||||
CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.tertiaryContainer.copy(alpha = 0.3f),
|
||||
),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
@@ -550,18 +550,18 @@ fun NewProfileScreen(
|
||||
|
||||
Surface(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.align(Alignment.BottomCenter),
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.align(Alignment.BottomCenter),
|
||||
color = MaterialTheme.colorScheme.surface,
|
||||
tonalElevation = 3.dp,
|
||||
) {
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.windowInsetsPadding(WindowInsets.navigationBars)
|
||||
.padding(16.dp),
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.windowInsetsPadding(WindowInsets.navigationBars)
|
||||
.padding(16.dp),
|
||||
) {
|
||||
Button(
|
||||
onClick = { viewModel.validateAndCreateProfile() },
|
||||
|
||||
@@ -124,21 +124,18 @@ class NewProfileViewModel(application: Application) : AndroidViewModel(applicati
|
||||
_uiState.update { it.copy(autoUpdateInterval = intValue.coerceAtLeast(15)) }
|
||||
}
|
||||
|
||||
fun setImportUri(
|
||||
uri: Uri,
|
||||
fileName: String?,
|
||||
) {
|
||||
fun setImportUri(uri: Uri, fileName: String?) {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
importUri = uri,
|
||||
importFileName = fileName,
|
||||
importError = null, // Clear error when file is selected
|
||||
name =
|
||||
if (it.name.isEmpty()) {
|
||||
fileName?.substringBeforeLast(".") ?: "Imported Profile"
|
||||
} else {
|
||||
it.name
|
||||
},
|
||||
if (it.name.isEmpty()) {
|
||||
fileName?.substringBeforeLast(".") ?: "Imported Profile"
|
||||
} else {
|
||||
it.name
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,8 +23,7 @@ class ProfileImportHandler(private val context: Context) {
|
||||
}
|
||||
|
||||
sealed class QRCodeParseResult {
|
||||
data class RemoteProfile(val name: String, val host: String, val url: String) :
|
||||
QRCodeParseResult()
|
||||
data class RemoteProfile(val name: String, val host: String, val url: String) : QRCodeParseResult()
|
||||
|
||||
data class LocalProfile(val name: String) : QRCodeParseResult()
|
||||
|
||||
@@ -43,188 +42,182 @@ class ProfileImportHandler(private val context: Context) {
|
||||
data class Error(val message: String) : UriParseResult()
|
||||
}
|
||||
|
||||
suspend fun importFromUri(uri: Uri): ImportResult =
|
||||
withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val data =
|
||||
context.contentResolver.openInputStream(uri)?.use { it.readBytes() }
|
||||
?: return@withContext ImportResult.Error(context.getString(R.string.error_empty_file))
|
||||
suspend fun importFromUri(uri: Uri): ImportResult = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val data =
|
||||
context.contentResolver.openInputStream(uri)?.use { it.readBytes() }
|
||||
?: return@withContext ImportResult.Error(context.getString(R.string.error_empty_file))
|
||||
|
||||
// Get the filename from the URI
|
||||
val filename = getFileNameFromUri(uri)
|
||||
// Get the filename from the URI
|
||||
val filename = getFileNameFromUri(uri)
|
||||
|
||||
// Try to detect if it's a JSON configuration file
|
||||
val dataString = String(data)
|
||||
if (isJsonConfiguration(dataString)) {
|
||||
// It's a JSON configuration, import it directly as a local profile
|
||||
return@withContext importJsonConfiguration(dataString, filename)
|
||||
}
|
||||
|
||||
// Try to decode as ProfileContent (the old way)
|
||||
val content =
|
||||
try {
|
||||
Libbox.decodeProfileContent(data)
|
||||
} catch (e: Exception) {
|
||||
// If it fails, try one more time as JSON
|
||||
if (dataString.trimStart().startsWith("{") || dataString.trimStart().startsWith("[")) {
|
||||
return@withContext importJsonConfiguration(dataString, filename)
|
||||
}
|
||||
return@withContext ImportResult.Error(
|
||||
context.getString(R.string.error_decode_profile, e.message),
|
||||
)
|
||||
}
|
||||
|
||||
importProfile(content)
|
||||
} catch (e: Exception) {
|
||||
ImportResult.Error(e.message ?: "Unknown error")
|
||||
// Try to detect if it's a JSON configuration file
|
||||
val dataString = String(data)
|
||||
if (isJsonConfiguration(dataString)) {
|
||||
// It's a JSON configuration, import it directly as a local profile
|
||||
return@withContext importJsonConfiguration(dataString, filename)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun parseUri(uri: Uri): UriParseResult =
|
||||
withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val data =
|
||||
context.contentResolver.openInputStream(uri)?.use { it.readBytes() }
|
||||
?: return@withContext UriParseResult.Error(context.getString(R.string.error_empty_file))
|
||||
|
||||
val filename = getFileNameFromUri(uri)
|
||||
val dataString = String(data)
|
||||
|
||||
if (isJsonConfiguration(dataString)) {
|
||||
return@withContext UriParseResult.Success(name = filename)
|
||||
}
|
||||
|
||||
val content =
|
||||
try {
|
||||
Libbox.decodeProfileContent(data)
|
||||
} catch (e: Exception) {
|
||||
if (dataString.trimStart().startsWith("{") || dataString.trimStart().startsWith("[")) {
|
||||
return@withContext UriParseResult.Success(name = filename)
|
||||
}
|
||||
return@withContext UriParseResult.Error(
|
||||
context.getString(R.string.error_decode_profile, e.message),
|
||||
)
|
||||
}
|
||||
|
||||
UriParseResult.Success(name = content.name)
|
||||
} catch (e: Exception) {
|
||||
UriParseResult.Error(e.message ?: "Unknown error")
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun parseQRCode(data: String): QRCodeParseResult =
|
||||
withContext(Dispatchers.IO) {
|
||||
try {
|
||||
// Check if it's a sing-box remote profile import link
|
||||
if (data.startsWith("sing-box://import-remote-profile")) {
|
||||
try {
|
||||
val profileInfo = Libbox.parseRemoteProfileImportLink(data)
|
||||
return@withContext QRCodeParseResult.RemoteProfile(
|
||||
name = profileInfo.name,
|
||||
host = profileInfo.host,
|
||||
url = profileInfo.url,
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
return@withContext QRCodeParseResult.Error(
|
||||
context.getString(R.string.error_decode_profile, e.message),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Check if it's a direct URL
|
||||
if (data.startsWith("http://") || data.startsWith("https://")) {
|
||||
val profileName = extractProfileNameFromUrl(data)
|
||||
return@withContext QRCodeParseResult.RemoteProfile(
|
||||
name = profileName,
|
||||
host = extractHostFromUrl(data),
|
||||
url = data,
|
||||
)
|
||||
}
|
||||
|
||||
// Try to decode as profile content
|
||||
val content =
|
||||
try {
|
||||
Libbox.decodeProfileContent(data.toByteArray())
|
||||
} catch (e: Exception) {
|
||||
return@withContext QRCodeParseResult.Error(
|
||||
context.getString(R.string.error_decode_profile, e.message),
|
||||
)
|
||||
}
|
||||
|
||||
return@withContext QRCodeParseResult.LocalProfile(name = content.name)
|
||||
} catch (e: Exception) {
|
||||
QRCodeParseResult.Error(e.message ?: "Unknown error")
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun importFromQRCode(data: String): ImportResult =
|
||||
withContext(Dispatchers.IO) {
|
||||
try {
|
||||
// Check if it's a sing-box remote profile import link
|
||||
if (data.startsWith("sing-box://import-remote-profile")) {
|
||||
try {
|
||||
val profileInfo = Libbox.parseRemoteProfileImportLink(data)
|
||||
return@withContext importRemoteProfile(profileInfo.name, profileInfo.url)
|
||||
} catch (e: Exception) {
|
||||
return@withContext ImportResult.Error(
|
||||
context.getString(R.string.error_decode_profile, e.message),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Check if it's a URL or direct profile content
|
||||
if (data.startsWith("http://") || data.startsWith("https://")) {
|
||||
// Handle remote profile URL
|
||||
val profileName = extractProfileNameFromUrl(data)
|
||||
importRemoteProfile(profileName, data)
|
||||
} else {
|
||||
// Try to decode as profile content
|
||||
val content =
|
||||
try {
|
||||
Libbox.decodeProfileContent(data.toByteArray())
|
||||
} catch (e: Exception) {
|
||||
return@withContext ImportResult.Error(
|
||||
context.getString(R.string.error_decode_profile, e.message),
|
||||
)
|
||||
}
|
||||
importProfile(content)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
ImportResult.Error(e.message ?: "Unknown error")
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun parseQRSData(data: ByteArray): QRSParseResult =
|
||||
withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val content = try {
|
||||
// Try to decode as ProfileContent (the old way)
|
||||
val content =
|
||||
try {
|
||||
Libbox.decodeProfileContent(data)
|
||||
} catch (e: Exception) {
|
||||
return@withContext QRSParseResult.Error(
|
||||
// If it fails, try one more time as JSON
|
||||
if (dataString.trimStart().startsWith("{") || dataString.trimStart().startsWith("[")) {
|
||||
return@withContext importJsonConfiguration(dataString, filename)
|
||||
}
|
||||
return@withContext ImportResult.Error(
|
||||
context.getString(R.string.error_decode_profile, e.message),
|
||||
)
|
||||
}
|
||||
QRSParseResult.Success(name = content.name)
|
||||
} catch (e: Exception) {
|
||||
QRSParseResult.Error(e.message ?: "Unknown error")
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun importFromQRSData(data: ByteArray): ImportResult =
|
||||
withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val content = try {
|
||||
importProfile(content)
|
||||
} catch (e: Exception) {
|
||||
ImportResult.Error(e.message ?: "Unknown error")
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun parseUri(uri: Uri): UriParseResult = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val data =
|
||||
context.contentResolver.openInputStream(uri)?.use { it.readBytes() }
|
||||
?: return@withContext UriParseResult.Error(context.getString(R.string.error_empty_file))
|
||||
|
||||
val filename = getFileNameFromUri(uri)
|
||||
val dataString = String(data)
|
||||
|
||||
if (isJsonConfiguration(dataString)) {
|
||||
return@withContext UriParseResult.Success(name = filename)
|
||||
}
|
||||
|
||||
val content =
|
||||
try {
|
||||
Libbox.decodeProfileContent(data)
|
||||
} catch (e: Exception) {
|
||||
if (dataString.trimStart().startsWith("{") || dataString.trimStart().startsWith("[")) {
|
||||
return@withContext UriParseResult.Success(name = filename)
|
||||
}
|
||||
return@withContext UriParseResult.Error(
|
||||
context.getString(R.string.error_decode_profile, e.message),
|
||||
)
|
||||
}
|
||||
|
||||
UriParseResult.Success(name = content.name)
|
||||
} catch (e: Exception) {
|
||||
UriParseResult.Error(e.message ?: "Unknown error")
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun parseQRCode(data: String): QRCodeParseResult = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
// Check if it's a sing-box remote profile import link
|
||||
if (data.startsWith("sing-box://import-remote-profile")) {
|
||||
try {
|
||||
val profileInfo = Libbox.parseRemoteProfileImportLink(data)
|
||||
return@withContext QRCodeParseResult.RemoteProfile(
|
||||
name = profileInfo.name,
|
||||
host = profileInfo.host,
|
||||
url = profileInfo.url,
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
return@withContext QRCodeParseResult.Error(
|
||||
context.getString(R.string.error_decode_profile, e.message),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Check if it's a direct URL
|
||||
if (data.startsWith("http://") || data.startsWith("https://")) {
|
||||
val profileName = extractProfileNameFromUrl(data)
|
||||
return@withContext QRCodeParseResult.RemoteProfile(
|
||||
name = profileName,
|
||||
host = extractHostFromUrl(data),
|
||||
url = data,
|
||||
)
|
||||
}
|
||||
|
||||
// Try to decode as profile content
|
||||
val content =
|
||||
try {
|
||||
Libbox.decodeProfileContent(data.toByteArray())
|
||||
} catch (e: Exception) {
|
||||
return@withContext QRCodeParseResult.Error(
|
||||
context.getString(R.string.error_decode_profile, e.message),
|
||||
)
|
||||
}
|
||||
|
||||
return@withContext QRCodeParseResult.LocalProfile(name = content.name)
|
||||
} catch (e: Exception) {
|
||||
QRCodeParseResult.Error(e.message ?: "Unknown error")
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun importFromQRCode(data: String): ImportResult = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
// Check if it's a sing-box remote profile import link
|
||||
if (data.startsWith("sing-box://import-remote-profile")) {
|
||||
try {
|
||||
val profileInfo = Libbox.parseRemoteProfileImportLink(data)
|
||||
return@withContext importRemoteProfile(profileInfo.name, profileInfo.url)
|
||||
} catch (e: Exception) {
|
||||
return@withContext ImportResult.Error(
|
||||
context.getString(R.string.error_decode_profile, e.message),
|
||||
)
|
||||
}
|
||||
importProfile(content)
|
||||
} catch (e: Exception) {
|
||||
ImportResult.Error(e.message ?: "Unknown error")
|
||||
}
|
||||
|
||||
// Check if it's a URL or direct profile content
|
||||
if (data.startsWith("http://") || data.startsWith("https://")) {
|
||||
// Handle remote profile URL
|
||||
val profileName = extractProfileNameFromUrl(data)
|
||||
importRemoteProfile(profileName, data)
|
||||
} else {
|
||||
// Try to decode as profile content
|
||||
val content =
|
||||
try {
|
||||
Libbox.decodeProfileContent(data.toByteArray())
|
||||
} catch (e: Exception) {
|
||||
return@withContext ImportResult.Error(
|
||||
context.getString(R.string.error_decode_profile, e.message),
|
||||
)
|
||||
}
|
||||
importProfile(content)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
ImportResult.Error(e.message ?: "Unknown error")
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun parseQRSData(data: ByteArray): QRSParseResult = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val content = try {
|
||||
Libbox.decodeProfileContent(data)
|
||||
} catch (e: Exception) {
|
||||
return@withContext QRSParseResult.Error(
|
||||
context.getString(R.string.error_decode_profile, e.message),
|
||||
)
|
||||
}
|
||||
QRSParseResult.Success(name = content.name)
|
||||
} catch (e: Exception) {
|
||||
QRSParseResult.Error(e.message ?: "Unknown error")
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun importFromQRSData(data: ByteArray): ImportResult = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val content = try {
|
||||
Libbox.decodeProfileContent(data)
|
||||
} catch (e: Exception) {
|
||||
return@withContext ImportResult.Error(
|
||||
context.getString(R.string.error_decode_profile, e.message),
|
||||
)
|
||||
}
|
||||
importProfile(content)
|
||||
} catch (e: Exception) {
|
||||
ImportResult.Error(e.message ?: "Unknown error")
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun importProfile(content: ProfileContent): ImportResult {
|
||||
val typedProfile = TypedProfile()
|
||||
@@ -259,10 +252,7 @@ class ProfileImportHandler(private val context: Context) {
|
||||
return ImportResult.Success(profile)
|
||||
}
|
||||
|
||||
private suspend fun importRemoteProfile(
|
||||
name: String,
|
||||
url: String,
|
||||
): ImportResult {
|
||||
private suspend fun importRemoteProfile(name: String, url: String): ImportResult {
|
||||
val typedProfile =
|
||||
TypedProfile().apply {
|
||||
type = TypedProfile.Type.Remote
|
||||
@@ -297,13 +287,11 @@ class ProfileImportHandler(private val context: Context) {
|
||||
?: "Remote Profile"
|
||||
}
|
||||
|
||||
private fun extractHostFromUrl(url: String): String {
|
||||
return try {
|
||||
val uri = Uri.parse(url)
|
||||
uri.host ?: url
|
||||
} catch (e: Exception) {
|
||||
url
|
||||
}
|
||||
private fun extractHostFromUrl(url: String): String = try {
|
||||
val uri = Uri.parse(url)
|
||||
uri.host ?: url
|
||||
} catch (e: Exception) {
|
||||
url
|
||||
}
|
||||
|
||||
private fun getFileNameFromUri(uri: Uri): String {
|
||||
@@ -354,10 +342,7 @@ class ProfileImportHandler(private val context: Context) {
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun importJsonConfiguration(
|
||||
jsonContent: String,
|
||||
profileName: String,
|
||||
): ImportResult {
|
||||
private suspend fun importJsonConfiguration(jsonContent: String, profileName: String): ImportResult {
|
||||
return try {
|
||||
// Validate the JSON configuration using sing-box
|
||||
try {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package io.nekohasekai.sfa.compose.screen.connections
|
||||
|
||||
import androidx.compose.foundation.ScrollState
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
@@ -10,7 +11,6 @@ import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.ScrollState
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
@@ -32,14 +32,14 @@ import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
|
||||
import androidx.compose.ui.input.nestedscroll.NestedScrollSource
|
||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||
import androidx.compose.ui.unit.Velocity
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.Velocity
|
||||
import androidx.compose.ui.unit.dp
|
||||
import io.nekohasekai.libbox.Libbox
|
||||
import io.nekohasekai.sfa.R
|
||||
@@ -286,10 +286,7 @@ fun ConnectionDetailsScreen(
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DetailSection(
|
||||
title: String,
|
||||
content: @Composable ColumnScope.() -> Unit,
|
||||
) {
|
||||
private fun DetailSection(title: String, content: @Composable ColumnScope.() -> Unit) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
@@ -317,12 +314,7 @@ private fun DetailSection(
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DetailRow(
|
||||
label: String,
|
||||
value: String,
|
||||
monospace: Boolean = false,
|
||||
valueColor: Color = MaterialTheme.colorScheme.onSurface,
|
||||
) {
|
||||
private fun DetailRow(label: String, value: String, monospace: Boolean = false, valueColor: Color = MaterialTheme.colorScheme.onSurface) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
@@ -346,20 +338,10 @@ private fun DetailRow(
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun rememberBounceBlockingNestedScrollConnection(
|
||||
scrollState: ScrollState
|
||||
): NestedScrollConnection = remember(scrollState) {
|
||||
private fun rememberBounceBlockingNestedScrollConnection(scrollState: ScrollState): NestedScrollConnection = remember(scrollState) {
|
||||
object : NestedScrollConnection {
|
||||
override fun onPostScroll(
|
||||
consumed: Offset,
|
||||
available: Offset,
|
||||
source: NestedScrollSource
|
||||
): Offset {
|
||||
return if (available.y < 0) available else Offset.Zero
|
||||
}
|
||||
override fun onPostScroll(consumed: Offset, available: Offset, source: NestedScrollSource): Offset = if (available.y < 0) available else Offset.Zero
|
||||
|
||||
override suspend fun onPostFling(consumed: Velocity, available: Velocity): Velocity {
|
||||
return if (available.y < 0) available else Velocity.Zero
|
||||
}
|
||||
override suspend fun onPostFling(consumed: Velocity, available: Velocity): Velocity = if (available.y < 0) available else Velocity.Zero
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,16 +45,13 @@ import androidx.compose.ui.unit.dp
|
||||
import io.nekohasekai.libbox.Libbox
|
||||
import io.nekohasekai.sfa.R
|
||||
import io.nekohasekai.sfa.compose.model.Connection
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
|
||||
private fun Drawable.toBitmap(): Bitmap {
|
||||
if (this is BitmapDrawable) return bitmap
|
||||
val bitmap = Bitmap.createBitmap(
|
||||
intrinsicWidth.coerceAtLeast(1),
|
||||
intrinsicHeight.coerceAtLeast(1),
|
||||
Bitmap.Config.ARGB_8888
|
||||
Bitmap.Config.ARGB_8888,
|
||||
)
|
||||
val canvas = Canvas(bitmap)
|
||||
setBounds(0, 0, canvas.width, canvas.height)
|
||||
@@ -62,10 +59,7 @@ private fun Drawable.toBitmap(): Bitmap {
|
||||
return bitmap
|
||||
}
|
||||
|
||||
data class AppInfo(
|
||||
val icon: ImageBitmap,
|
||||
val label: String,
|
||||
)
|
||||
data class AppInfo(val icon: ImageBitmap, val label: String)
|
||||
|
||||
@Composable
|
||||
private fun rememberAppInfo(packageName: String): AppInfo? {
|
||||
@@ -86,12 +80,7 @@ private fun rememberAppInfo(packageName: String): AppInfo? {
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
fun ConnectionItem(
|
||||
connection: Connection,
|
||||
onClick: () -> Unit,
|
||||
onClose: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
fun ConnectionItem(connection: Connection, onClick: () -> Unit, onClose: () -> Unit, modifier: Modifier = Modifier) {
|
||||
var showContextMenu by remember { mutableStateOf(false) }
|
||||
val packageName = connection.processInfo?.packageName?.takeIf { it.isNotEmpty() }
|
||||
val appInfo = packageName?.let { rememberAppInfo(it) }
|
||||
|
||||
@@ -18,11 +18,6 @@ import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.LazyListState
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
|
||||
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
|
||||
@@ -54,15 +49,20 @@ import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
|
||||
import androidx.compose.ui.input.nestedscroll.NestedScrollSource
|
||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.Velocity
|
||||
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.Connection
|
||||
import io.nekohasekai.sfa.compose.model.ConnectionSort
|
||||
import io.nekohasekai.sfa.compose.model.ConnectionStateFilter
|
||||
import io.nekohasekai.sfa.compose.topbar.OverrideTopBar
|
||||
import io.nekohasekai.sfa.constant.Status
|
||||
import io.nekohasekai.sfa.compose.model.Connection
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
@@ -118,7 +118,7 @@ fun ConnectionsPage(
|
||||
ConnectionStateFilter.All -> stringResource(R.string.connection_state_all)
|
||||
ConnectionStateFilter.Active -> stringResource(R.string.connection_state_active)
|
||||
ConnectionStateFilter.Closed -> stringResource(R.string.connection_state_closed)
|
||||
}
|
||||
},
|
||||
)
|
||||
},
|
||||
)
|
||||
@@ -230,7 +230,7 @@ fun ConnectionsPage(
|
||||
stringResource(R.string.close_search)
|
||||
} else {
|
||||
stringResource(R.string.search)
|
||||
}
|
||||
},
|
||||
)
|
||||
},
|
||||
onClick = {
|
||||
@@ -433,20 +433,10 @@ fun ConnectionsScreen(
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun rememberBounceBlockingNestedScrollConnection(
|
||||
lazyListState: LazyListState
|
||||
): NestedScrollConnection = remember(lazyListState) {
|
||||
private fun rememberBounceBlockingNestedScrollConnection(lazyListState: LazyListState): NestedScrollConnection = remember(lazyListState) {
|
||||
object : NestedScrollConnection {
|
||||
override fun onPostScroll(
|
||||
consumed: Offset,
|
||||
available: Offset,
|
||||
source: NestedScrollSource
|
||||
): Offset {
|
||||
return if (available.y < 0) available else Offset.Zero
|
||||
}
|
||||
override fun onPostScroll(consumed: Offset, available: Offset, source: NestedScrollSource): Offset = if (available.y < 0) available else Offset.Zero
|
||||
|
||||
override suspend fun onPostFling(consumed: Velocity, available: Velocity): Velocity {
|
||||
return if (available.y < 0) available else Velocity.Zero
|
||||
}
|
||||
override suspend fun onPostFling(consumed: Velocity, available: Velocity): Velocity = if (available.y < 0) available else Velocity.Zero
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,14 +6,13 @@ import io.nekohasekai.libbox.Connections
|
||||
import io.nekohasekai.libbox.Libbox
|
||||
import io.nekohasekai.sfa.compose.base.BaseViewModel
|
||||
import io.nekohasekai.sfa.compose.base.ScreenEvent
|
||||
import io.nekohasekai.sfa.constant.Status
|
||||
import io.nekohasekai.sfa.ktx.toList
|
||||
import io.nekohasekai.sfa.compose.model.Connection
|
||||
import io.nekohasekai.sfa.compose.model.ConnectionSort
|
||||
import io.nekohasekai.sfa.compose.model.ConnectionStateFilter
|
||||
import io.nekohasekai.sfa.constant.Status
|
||||
import io.nekohasekai.sfa.ktx.toList
|
||||
import io.nekohasekai.sfa.utils.AppLifecycleObserver
|
||||
import io.nekohasekai.sfa.utils.CommandClient
|
||||
import java.util.concurrent.atomic.AtomicLong
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
@@ -22,6 +21,7 @@ import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.util.concurrent.atomic.AtomicLong
|
||||
|
||||
data class ConnectionsUiState(
|
||||
val connections: List<Connection> = emptyList(),
|
||||
@@ -38,7 +38,9 @@ sealed class ConnectionsEvent : ScreenEvent {
|
||||
data object AllConnectionsClosed : ConnectionsEvent()
|
||||
}
|
||||
|
||||
class ConnectionsViewModel : BaseViewModel<ConnectionsUiState, ConnectionsEvent>(), CommandClient.Handler {
|
||||
class ConnectionsViewModel :
|
||||
BaseViewModel<ConnectionsUiState, ConnectionsEvent>(),
|
||||
CommandClient.Handler {
|
||||
private val commandClient = CommandClient(
|
||||
viewModelScope,
|
||||
CommandClient.ConnectionType.Connections,
|
||||
@@ -62,7 +64,7 @@ class ConnectionsViewModel : BaseViewModel<ConnectionsUiState, ConnectionsEvent>
|
||||
combine(
|
||||
AppLifecycleObserver.isForeground,
|
||||
_isVisible,
|
||||
_serviceStatus
|
||||
_serviceStatus,
|
||||
) { foreground, visible, status ->
|
||||
Triple(foreground, visible, status)
|
||||
}.collect { (foreground, visible, status) ->
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package io.nekohasekai.sfa.compose.screen.dashboard
|
||||
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import androidx.compose.foundation.layout.Column
|
||||
@@ -14,8 +13,8 @@ import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.UnfoldMore
|
||||
import androidx.compose.material.icons.filled.Check
|
||||
import androidx.compose.material.icons.filled.UnfoldMore
|
||||
import androidx.compose.material.icons.outlined.Tune
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
@@ -44,20 +43,15 @@ import io.nekohasekai.sfa.R
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun ClashModeCard(
|
||||
modes: List<String>,
|
||||
selectedMode: String,
|
||||
onModeSelected: (String) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
fun ClashModeCard(modes: List<String>, selectedMode: String, onModeSelected: (String) -> Unit, modifier: Modifier = Modifier) {
|
||||
Card(
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
) {
|
||||
Column(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
@@ -109,10 +103,10 @@ fun ClashModeCard(
|
||||
modes.forEachIndexed { index, mode ->
|
||||
SegmentedButton(
|
||||
shape =
|
||||
SegmentedButtonDefaults.itemShape(
|
||||
index = index,
|
||||
count = modes.size,
|
||||
),
|
||||
SegmentedButtonDefaults.itemShape(
|
||||
index = index,
|
||||
count = modes.size,
|
||||
),
|
||||
onClick = { onModeSelected(mode) },
|
||||
selected = mode == selectedMode,
|
||||
) {
|
||||
@@ -127,11 +121,7 @@ fun ClashModeCard(
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ModeDropdown(
|
||||
modes: List<String>,
|
||||
selectedMode: String,
|
||||
onModeSelected: (String) -> Unit,
|
||||
) {
|
||||
private fun ModeDropdown(modes: List<String>, selectedMode: String, onModeSelected: (String) -> Unit) {
|
||||
var expanded by remember { mutableStateOf(false) }
|
||||
|
||||
Box(modifier = Modifier.fillMaxWidth()) {
|
||||
@@ -147,9 +137,9 @@ private fun ModeDropdown(
|
||||
) {
|
||||
Row(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
|
||||
@@ -24,19 +24,15 @@ import androidx.compose.ui.unit.dp
|
||||
import io.nekohasekai.sfa.R
|
||||
|
||||
@Composable
|
||||
fun ConnectionsCard(
|
||||
connectionsIn: String,
|
||||
connectionsOut: String,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
fun ConnectionsCard(connectionsIn: String, connectionsOut: String, modifier: Modifier = Modifier) {
|
||||
Card(
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
) {
|
||||
Column(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
|
||||
@@ -24,7 +24,6 @@ import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
@@ -37,10 +36,7 @@ import io.nekohasekai.sfa.compose.topbar.OverrideTopBar
|
||||
import io.nekohasekai.sfa.constant.Status
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
data class CardRenderItem(
|
||||
val cards: List<CardGroup>,
|
||||
val isRow: Boolean,
|
||||
)
|
||||
data class CardRenderItem(val cards: List<CardGroup>, val isRow: Boolean)
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
@@ -87,18 +83,18 @@ fun DashboardScreen(
|
||||
}
|
||||
},
|
||||
dismissButton =
|
||||
if (!note.migrationLink.isNullOrBlank()) {
|
||||
{
|
||||
TextButton(onClick = {
|
||||
viewModel.sendGlobalEvent(UiEvent.OpenUrl(note.migrationLink))
|
||||
viewModel.dismissDeprecatedNote()
|
||||
}) {
|
||||
Text(stringResource(R.string.error_deprecated_documentation))
|
||||
}
|
||||
if (!note.migrationLink.isNullOrBlank()) {
|
||||
{
|
||||
TextButton(onClick = {
|
||||
viewModel.sendGlobalEvent(UiEvent.OpenUrl(note.migrationLink))
|
||||
viewModel.dismissDeprecatedNote()
|
||||
}) {
|
||||
Text(stringResource(R.string.error_deprecated_documentation))
|
||||
}
|
||||
} else {
|
||||
null
|
||||
},
|
||||
}
|
||||
} else {
|
||||
null
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -134,9 +130,9 @@ fun DashboardScreen(
|
||||
}
|
||||
LazyColumn(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.padding(horizontal = 16.dp),
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.padding(horizontal = 16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
contentPadding = PaddingValues(bottom = bottomPadding),
|
||||
) {
|
||||
@@ -172,8 +168,8 @@ fun DashboardScreen(
|
||||
DashboardCardRenderer(
|
||||
cardGroup = cardGroup,
|
||||
cardWidth =
|
||||
uiState.cardWidths[cardGroup]
|
||||
?: CardWidth.Full,
|
||||
uiState.cardWidths[cardGroup]
|
||||
?: CardWidth.Full,
|
||||
uiState = uiState,
|
||||
onClashModeSelected = viewModel::selectClashMode,
|
||||
onSystemProxyToggle = viewModel::toggleSystemProxy,
|
||||
@@ -199,9 +195,9 @@ fun DashboardScreen(
|
||||
onOpenNewProfile = onOpenNewProfile,
|
||||
commandClient = viewModel.commandClient,
|
||||
modifier =
|
||||
Modifier
|
||||
.weight(1f)
|
||||
.fillMaxWidth(),
|
||||
Modifier
|
||||
.weight(1f)
|
||||
.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -211,8 +207,8 @@ fun DashboardScreen(
|
||||
DashboardCardRenderer(
|
||||
cardGroup = cardGroup,
|
||||
cardWidth =
|
||||
uiState.cardWidths[cardGroup]
|
||||
?: CardWidth.Full,
|
||||
uiState.cardWidths[cardGroup]
|
||||
?: CardWidth.Full,
|
||||
uiState = uiState,
|
||||
serviceStatus = serviceStatus,
|
||||
onClashModeSelected = viewModel::selectClashMode,
|
||||
@@ -307,17 +303,12 @@ fun processCardsForRendering(
|
||||
* This function is only relevant when the service is running.
|
||||
* Note: Profiles card is always available and should not use this function.
|
||||
*/
|
||||
fun isCardAvailableWhenServiceRunning(
|
||||
cardGroup: CardGroup,
|
||||
uiState: DashboardUiState,
|
||||
): Boolean {
|
||||
return when (cardGroup) {
|
||||
CardGroup.ClashMode -> uiState.clashModeVisible
|
||||
CardGroup.UploadTraffic -> uiState.trafficVisible
|
||||
CardGroup.DownloadTraffic -> uiState.trafficVisible
|
||||
CardGroup.Debug -> true // Debug info is always available when service is running
|
||||
CardGroup.Connections -> uiState.trafficVisible
|
||||
CardGroup.SystemProxy -> uiState.systemProxyVisible
|
||||
CardGroup.Profiles -> true // This shouldn't be called for Profiles, but return true for safety
|
||||
}
|
||||
fun isCardAvailableWhenServiceRunning(cardGroup: CardGroup, uiState: DashboardUiState): Boolean = when (cardGroup) {
|
||||
CardGroup.ClashMode -> uiState.clashModeVisible
|
||||
CardGroup.UploadTraffic -> uiState.trafficVisible
|
||||
CardGroup.DownloadTraffic -> uiState.trafficVisible
|
||||
CardGroup.Debug -> true // Debug info is always available when service is running
|
||||
CardGroup.Connections -> uiState.trafficVisible
|
||||
CardGroup.SystemProxy -> uiState.systemProxyVisible
|
||||
CardGroup.Profiles -> true // This shouldn't be called for Profiles, but return true for safety
|
||||
}
|
||||
|
||||
@@ -27,11 +27,9 @@ import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.DragHandle
|
||||
import androidx.compose.material.icons.filled.RestartAlt
|
||||
import io.nekohasekai.sfa.compat.animateItemCompat
|
||||
import androidx.compose.material.icons.outlined.BugReport
|
||||
import androidx.compose.material.icons.outlined.Cable
|
||||
import androidx.compose.material.icons.outlined.Download
|
||||
import androidx.compose.material.icons.outlined.Folder
|
||||
import androidx.compose.material.icons.outlined.Person
|
||||
import androidx.compose.material.icons.outlined.Route
|
||||
import androidx.compose.material.icons.outlined.SettingsEthernet
|
||||
@@ -65,6 +63,7 @@ import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.zIndex
|
||||
import io.nekohasekai.sfa.R
|
||||
import io.nekohasekai.sfa.compat.animateItemCompat
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
@@ -96,12 +95,12 @@ fun DashboardSettingsBottomSheet(
|
||||
var dragOffset by remember { mutableStateOf(0f) }
|
||||
val density = LocalDensity.current
|
||||
|
||||
fun onMove(
|
||||
fromIndex: Int,
|
||||
toIndex: Int,
|
||||
) {
|
||||
if (fromIndex != toIndex && fromIndex >= 0 && toIndex >= 0 &&
|
||||
fromIndex < reorderedList.size && toIndex < reorderedList.size
|
||||
fun onMove(fromIndex: Int, toIndex: Int) {
|
||||
if (fromIndex != toIndex &&
|
||||
fromIndex >= 0 &&
|
||||
toIndex >= 0 &&
|
||||
fromIndex < reorderedList.size &&
|
||||
toIndex < reorderedList.size
|
||||
) {
|
||||
val newList = reorderedList.toMutableList()
|
||||
val item = newList.removeAt(fromIndex)
|
||||
@@ -135,17 +134,17 @@ fun DashboardSettingsBottomSheet(
|
||||
) {
|
||||
Column(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.fillMaxHeight(0.8f),
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.fillMaxHeight(0.8f),
|
||||
) {
|
||||
// Header with reset button
|
||||
Row(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 24.dp)
|
||||
.padding(bottom = 16.dp),
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 24.dp)
|
||||
.padding(bottom = 16.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
@@ -199,18 +198,18 @@ fun DashboardSettingsBottomSheet(
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier =
|
||||
Modifier
|
||||
.padding(horizontal = 24.dp)
|
||||
.padding(bottom = 12.dp),
|
||||
Modifier
|
||||
.padding(horizontal = 24.dp)
|
||||
.padding(bottom = 12.dp),
|
||||
)
|
||||
|
||||
// Reorderable list
|
||||
LazyColumn(
|
||||
state = listState,
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.weight(1f),
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.weight(1f),
|
||||
contentPadding = PaddingValues(horizontal = 24.dp, vertical = 8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
@@ -275,13 +274,13 @@ fun DashboardSettingsBottomSheet(
|
||||
dragOffset = 0f
|
||||
},
|
||||
modifier =
|
||||
animateItemCompat(
|
||||
placementSpec =
|
||||
spring(
|
||||
dampingRatio = Spring.DampingRatioMediumBouncy,
|
||||
stiffness = Spring.StiffnessLow,
|
||||
),
|
||||
animateItemCompat(
|
||||
placementSpec =
|
||||
spring(
|
||||
dampingRatio = Spring.DampingRatioMediumBouncy,
|
||||
stiffness = Spring.StiffnessLow,
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -315,40 +314,40 @@ fun DashboardItemCard(
|
||||
|
||||
Card(
|
||||
modifier =
|
||||
modifier
|
||||
.fillMaxWidth()
|
||||
.offset(y = with(LocalDensity.current) { offsetY.value.toDp() })
|
||||
.zIndex(if (isDragging) 1f else 0f)
|
||||
.clip(RoundedCornerShape(12.dp)),
|
||||
modifier
|
||||
.fillMaxWidth()
|
||||
.offset(y = with(LocalDensity.current) { offsetY.value.toDp() })
|
||||
.zIndex(if (isDragging) 1f else 0f)
|
||||
.clip(RoundedCornerShape(12.dp)),
|
||||
elevation =
|
||||
CardDefaults.cardElevation(
|
||||
defaultElevation = cardElevation,
|
||||
),
|
||||
CardDefaults.cardElevation(
|
||||
defaultElevation = cardElevation,
|
||||
),
|
||||
colors =
|
||||
CardDefaults.cardColors(
|
||||
containerColor =
|
||||
if (isDragging) {
|
||||
MaterialTheme.colorScheme.surface.copy(alpha = 0.95f)
|
||||
} else {
|
||||
MaterialTheme.colorScheme.surface
|
||||
},
|
||||
),
|
||||
CardDefaults.cardColors(
|
||||
containerColor =
|
||||
if (isDragging) {
|
||||
MaterialTheme.colorScheme.surface.copy(alpha = 0.95f)
|
||||
} else {
|
||||
MaterialTheme.colorScheme.surface
|
||||
},
|
||||
),
|
||||
border =
|
||||
BorderStroke(
|
||||
width = 1.dp,
|
||||
color =
|
||||
if (isVisible) {
|
||||
MaterialTheme.colorScheme.primary.copy(alpha = 0.3f)
|
||||
} else {
|
||||
MaterialTheme.colorScheme.outline.copy(alpha = 0.12f)
|
||||
},
|
||||
),
|
||||
BorderStroke(
|
||||
width = 1.dp,
|
||||
color =
|
||||
if (isVisible) {
|
||||
MaterialTheme.colorScheme.primary.copy(alpha = 0.3f)
|
||||
} else {
|
||||
MaterialTheme.colorScheme.outline.copy(alpha = 0.12f)
|
||||
},
|
||||
),
|
||||
) {
|
||||
Row(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(12.dp),
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
// Drag handle
|
||||
@@ -361,66 +360,66 @@ fun DashboardItemCard(
|
||||
imageVector = Icons.Default.DragHandle,
|
||||
contentDescription = stringResource(R.string.drag_to_reorder),
|
||||
modifier =
|
||||
Modifier
|
||||
.size(24.dp)
|
||||
.draggable(
|
||||
state = draggableState,
|
||||
orientation = Orientation.Vertical,
|
||||
onDragStarted = { onDragStart() },
|
||||
onDragStopped = { onDragEnd() },
|
||||
)
|
||||
.padding(4.dp),
|
||||
Modifier
|
||||
.size(24.dp)
|
||||
.draggable(
|
||||
state = draggableState,
|
||||
orientation = Orientation.Vertical,
|
||||
onDragStarted = { onDragStart() },
|
||||
onDragStopped = { onDragEnd() },
|
||||
)
|
||||
.padding(4.dp),
|
||||
tint =
|
||||
if (isDragging) {
|
||||
MaterialTheme.colorScheme.primary
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurfaceVariant
|
||||
},
|
||||
if (isDragging) {
|
||||
MaterialTheme.colorScheme.primary
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurfaceVariant
|
||||
},
|
||||
)
|
||||
|
||||
// Card icon
|
||||
Icon(
|
||||
imageVector =
|
||||
when (cardGroup) {
|
||||
CardGroup.Debug -> Icons.Outlined.BugReport
|
||||
CardGroup.Connections -> Icons.Outlined.Cable
|
||||
CardGroup.UploadTraffic -> Icons.Outlined.Upload
|
||||
CardGroup.DownloadTraffic -> Icons.Outlined.Download
|
||||
CardGroup.ClashMode -> Icons.Outlined.Route
|
||||
CardGroup.SystemProxy -> Icons.Outlined.SettingsEthernet
|
||||
CardGroup.Profiles -> Icons.Outlined.Person
|
||||
},
|
||||
when (cardGroup) {
|
||||
CardGroup.Debug -> Icons.Outlined.BugReport
|
||||
CardGroup.Connections -> Icons.Outlined.Cable
|
||||
CardGroup.UploadTraffic -> Icons.Outlined.Upload
|
||||
CardGroup.DownloadTraffic -> Icons.Outlined.Download
|
||||
CardGroup.ClashMode -> Icons.Outlined.Route
|
||||
CardGroup.SystemProxy -> Icons.Outlined.SettingsEthernet
|
||||
CardGroup.Profiles -> Icons.Outlined.Person
|
||||
},
|
||||
contentDescription = null,
|
||||
modifier =
|
||||
Modifier
|
||||
.size(24.dp)
|
||||
.padding(horizontal = 4.dp),
|
||||
Modifier
|
||||
.size(24.dp)
|
||||
.padding(horizontal = 4.dp),
|
||||
tint =
|
||||
if (isVisible) {
|
||||
MaterialTheme.colorScheme.primary
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurfaceVariant
|
||||
},
|
||||
if (isVisible) {
|
||||
MaterialTheme.colorScheme.primary
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurfaceVariant
|
||||
},
|
||||
)
|
||||
|
||||
// Card info
|
||||
Column(
|
||||
modifier =
|
||||
Modifier
|
||||
.weight(1f)
|
||||
.padding(horizontal = 8.dp),
|
||||
Modifier
|
||||
.weight(1f)
|
||||
.padding(horizontal = 8.dp),
|
||||
) {
|
||||
Text(
|
||||
text =
|
||||
when (cardGroup) {
|
||||
CardGroup.Debug -> stringResource(R.string.title_debug)
|
||||
CardGroup.Connections -> stringResource(R.string.title_connections)
|
||||
CardGroup.UploadTraffic -> stringResource(R.string.upload)
|
||||
CardGroup.DownloadTraffic -> stringResource(R.string.download)
|
||||
CardGroup.ClashMode -> stringResource(R.string.clash_mode)
|
||||
CardGroup.SystemProxy -> stringResource(R.string.system_proxy)
|
||||
CardGroup.Profiles -> stringResource(R.string.title_configuration)
|
||||
},
|
||||
when (cardGroup) {
|
||||
CardGroup.Debug -> stringResource(R.string.title_debug)
|
||||
CardGroup.Connections -> stringResource(R.string.title_connections)
|
||||
CardGroup.UploadTraffic -> stringResource(R.string.upload)
|
||||
CardGroup.DownloadTraffic -> stringResource(R.string.download)
|
||||
CardGroup.ClashMode -> stringResource(R.string.clash_mode)
|
||||
CardGroup.SystemProxy -> stringResource(R.string.system_proxy)
|
||||
CardGroup.Profiles -> stringResource(R.string.title_configuration)
|
||||
},
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
|
||||
@@ -114,16 +114,15 @@ data class DashboardUiState(
|
||||
),
|
||||
val showCardSettingsDialog: Boolean = false,
|
||||
) {
|
||||
data class DeprecatedNote(
|
||||
val message: String,
|
||||
val migrationLink: String?,
|
||||
)
|
||||
data class DeprecatedNote(val message: String, val migrationLink: String?)
|
||||
}
|
||||
|
||||
// DashboardViewModel now only uses UiEvent for all events
|
||||
// No need for DashboardEvent anymore as all events are handled globally
|
||||
|
||||
class DashboardViewModel : BaseViewModel<DashboardUiState, UiEvent>(), CommandClient.Handler {
|
||||
class DashboardViewModel :
|
||||
BaseViewModel<DashboardUiState, UiEvent>(),
|
||||
CommandClient.Handler {
|
||||
private val _serviceStatus = MutableStateFlow(Status.Stopped)
|
||||
val serviceStatus: StateFlow<Status> = _serviceStatus.asStateFlow()
|
||||
|
||||
@@ -395,10 +394,7 @@ class DashboardViewModel : BaseViewModel<DashboardUiState, UiEvent>(), CommandCl
|
||||
}
|
||||
}
|
||||
|
||||
fun moveProfile(
|
||||
from: Int,
|
||||
to: Int,
|
||||
) {
|
||||
fun moveProfile(from: Int, to: Int) {
|
||||
val currentProfiles = currentState.profiles.toMutableList()
|
||||
|
||||
if (from < to) {
|
||||
@@ -614,10 +610,7 @@ class DashboardViewModel : BaseViewModel<DashboardUiState, UiEvent>(), CommandCl
|
||||
}
|
||||
}
|
||||
|
||||
override fun initializeClashMode(
|
||||
modeList: List<String>,
|
||||
currentMode: String,
|
||||
) {
|
||||
override fun initializeClashMode(modeList: List<String>, currentMode: String) {
|
||||
viewModelScope.launch(Dispatchers.Main) {
|
||||
updateState {
|
||||
copy(
|
||||
@@ -702,16 +695,15 @@ class DashboardViewModel : BaseViewModel<DashboardUiState, UiEvent>(), CommandCl
|
||||
}
|
||||
|
||||
// Helper functions for serialization
|
||||
private fun getDefaultItemOrder() =
|
||||
listOf(
|
||||
CardGroup.UploadTraffic,
|
||||
CardGroup.DownloadTraffic,
|
||||
CardGroup.Debug,
|
||||
CardGroup.Connections,
|
||||
CardGroup.SystemProxy,
|
||||
CardGroup.ClashMode,
|
||||
CardGroup.Profiles,
|
||||
)
|
||||
private fun getDefaultItemOrder() = listOf(
|
||||
CardGroup.UploadTraffic,
|
||||
CardGroup.DownloadTraffic,
|
||||
CardGroup.Debug,
|
||||
CardGroup.Connections,
|
||||
CardGroup.SystemProxy,
|
||||
CardGroup.ClashMode,
|
||||
CardGroup.Profiles,
|
||||
)
|
||||
|
||||
private fun loadItemOrder(): List<CardGroup> {
|
||||
val savedOrder = Settings.dashboardItemOrder
|
||||
@@ -766,11 +758,9 @@ class DashboardViewModel : BaseViewModel<DashboardUiState, UiEvent>(), CommandCl
|
||||
|
||||
private fun cardGroupToString(card: CardGroup): String = card.name
|
||||
|
||||
private fun stringToCardGroup(name: String): CardGroup? {
|
||||
return try {
|
||||
CardGroup.valueOf(name)
|
||||
} catch (e: IllegalArgumentException) {
|
||||
null
|
||||
}
|
||||
private fun stringToCardGroup(name: String): CardGroup? = try {
|
||||
CardGroup.valueOf(name)
|
||||
} catch (e: IllegalArgumentException) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,19 +24,15 @@ import androidx.compose.ui.unit.dp
|
||||
import io.nekohasekai.sfa.R
|
||||
|
||||
@Composable
|
||||
fun DebugCard(
|
||||
memory: String,
|
||||
goroutines: String,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
fun DebugCard(memory: String, goroutines: String, modifier: Modifier = Modifier) {
|
||||
Card(
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
) {
|
||||
Column(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
|
||||
@@ -24,20 +24,15 @@ import io.nekohasekai.sfa.R
|
||||
import io.nekohasekai.sfa.compose.LineChart
|
||||
|
||||
@Composable
|
||||
fun DownloadTrafficCard(
|
||||
downlink: String,
|
||||
downlinkTotal: String,
|
||||
downlinkHistory: List<Float>,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
fun DownloadTrafficCard(downlink: String, downlinkTotal: String, downlinkHistory: List<Float>, modifier: Modifier = Modifier) {
|
||||
Card(
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
) {
|
||||
Column(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
|
||||
@@ -26,10 +26,10 @@ 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.material.icons.filled.UnfoldLess
|
||||
import androidx.compose.material.icons.filled.UnfoldMore
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
@@ -58,19 +58,19 @@ import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
|
||||
import androidx.compose.ui.input.nestedscroll.NestedScrollSource
|
||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.Velocity
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.Velocity
|
||||
import androidx.compose.ui.unit.dp
|
||||
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.compose.screen.dashboard.groups.GroupsViewModel
|
||||
import io.nekohasekai.sfa.compose.topbar.OverrideTopBar
|
||||
import io.nekohasekai.sfa.constant.Status
|
||||
import io.nekohasekai.sfa.utils.CommandClient
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@@ -84,12 +84,12 @@ fun GroupsCard(
|
||||
) {
|
||||
val actualViewModel: GroupsViewModel = viewModel ?: viewModel(
|
||||
factory =
|
||||
object : ViewModelProvider.Factory {
|
||||
override fun <T : androidx.lifecycle.ViewModel> create(modelClass: Class<T>): T {
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
return GroupsViewModel(commandClient) as T
|
||||
}
|
||||
},
|
||||
object : ViewModelProvider.Factory {
|
||||
override fun <T : androidx.lifecycle.ViewModel> create(modelClass: Class<T>): T {
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
return GroupsViewModel(commandClient) as T
|
||||
}
|
||||
},
|
||||
)
|
||||
val snackbarHostState = remember { SnackbarHostState() }
|
||||
val uiState by actualViewModel.uiState.collectAsState()
|
||||
@@ -104,17 +104,17 @@ fun GroupsCard(
|
||||
IconButton(onClick = { actualViewModel.toggleAllGroups() }) {
|
||||
Icon(
|
||||
imageVector =
|
||||
if (allCollapsed) {
|
||||
Icons.Default.UnfoldMore
|
||||
} else {
|
||||
Icons.Default.UnfoldLess
|
||||
},
|
||||
if (allCollapsed) {
|
||||
Icons.Default.UnfoldMore
|
||||
} else {
|
||||
Icons.Default.UnfoldLess
|
||||
},
|
||||
contentDescription =
|
||||
if (allCollapsed) {
|
||||
stringResource(R.string.expand_all)
|
||||
} else {
|
||||
stringResource(R.string.collapse_all)
|
||||
},
|
||||
if (allCollapsed) {
|
||||
stringResource(R.string.expand_all)
|
||||
} else {
|
||||
stringResource(R.string.collapse_all)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -186,9 +186,9 @@ private fun GroupsCardContent(
|
||||
if (uiState.isLoading) {
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.height(200.dp),
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.height(200.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
CircularProgressIndicator()
|
||||
@@ -196,9 +196,9 @@ private fun GroupsCardContent(
|
||||
} else if (uiState.groups.isEmpty()) {
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.height(100.dp),
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.height(100.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(
|
||||
@@ -216,12 +216,12 @@ private fun GroupsCardContent(
|
||||
.nestedScroll(bounceBlockingConnection),
|
||||
state = lazyListState,
|
||||
contentPadding =
|
||||
PaddingValues(
|
||||
start = 16.dp,
|
||||
end = 16.dp,
|
||||
top = 8.dp,
|
||||
bottom = 16.dp,
|
||||
),
|
||||
PaddingValues(
|
||||
start = 16.dp,
|
||||
end = 16.dp,
|
||||
top = 8.dp,
|
||||
bottom = 16.dp,
|
||||
),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
items(
|
||||
@@ -347,17 +347,17 @@ private fun ProxyGroupItem(
|
||||
imageVector = Icons.Default.ExpandMore,
|
||||
contentDescription = if (isExpanded) "Collapse" else "Expand",
|
||||
modifier =
|
||||
Modifier
|
||||
.size(24.dp)
|
||||
.graphicsLayer { rotationZ = rotationAngle },
|
||||
Modifier
|
||||
.size(24.dp)
|
||||
.graphicsLayer { rotationZ = rotationAngle },
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
},
|
||||
colors =
|
||||
ListItemDefaults.colors(
|
||||
containerColor = Color.Transparent,
|
||||
),
|
||||
ListItemDefaults.colors(
|
||||
containerColor = Color.Transparent,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -365,21 +365,21 @@ private fun ProxyGroupItem(
|
||||
AnimatedVisibility(
|
||||
visible = isExpanded && group.items.isNotEmpty(),
|
||||
enter =
|
||||
expandVertically(animationSpec = tween(300)) +
|
||||
fadeIn(
|
||||
animationSpec =
|
||||
tween(
|
||||
300,
|
||||
),
|
||||
expandVertically(animationSpec = tween(300)) +
|
||||
fadeIn(
|
||||
animationSpec =
|
||||
tween(
|
||||
300,
|
||||
),
|
||||
),
|
||||
exit =
|
||||
shrinkVertically(animationSpec = tween(300)) +
|
||||
fadeOut(
|
||||
animationSpec =
|
||||
tween(
|
||||
300,
|
||||
),
|
||||
shrinkVertically(animationSpec = tween(300)) +
|
||||
fadeOut(
|
||||
animationSpec =
|
||||
tween(
|
||||
300,
|
||||
),
|
||||
),
|
||||
) {
|
||||
Column {
|
||||
HorizontalDivider(
|
||||
@@ -401,12 +401,7 @@ private fun ProxyGroupItem(
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ProxyItemsList(
|
||||
items: List<GroupItem>,
|
||||
selectedTag: String,
|
||||
isSelectable: Boolean,
|
||||
onItemSelected: (String) -> Unit,
|
||||
) {
|
||||
private fun ProxyItemsList(items: List<GroupItem>, selectedTag: String, isSelectable: Boolean, onItemSelected: (String) -> Unit) {
|
||||
val itemsPerRow = 2
|
||||
val chunkedItems =
|
||||
remember(items) {
|
||||
@@ -415,9 +410,9 @@ private fun ProxyItemsList(
|
||||
|
||||
Column(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
chunkedItems.forEach { rowItems ->
|
||||
@@ -450,13 +445,7 @@ private fun ProxyItemsList(
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun ProxyChip(
|
||||
item: GroupItem,
|
||||
isSelected: Boolean,
|
||||
isSelectable: Boolean,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
private fun ProxyChip(item: GroupItem, isSelected: Boolean, isSelectable: Boolean, onClick: () -> Unit, modifier: Modifier = Modifier) {
|
||||
// Use simpler, faster animations
|
||||
val animatedElevation by animateFloatAsState(
|
||||
targetValue = if (isSelected) 6.dp.value else 1.dp.value,
|
||||
@@ -475,18 +464,18 @@ private fun ProxyChip(
|
||||
androidx.compose.foundation.BorderStroke(
|
||||
width = if (isSelected) 2.dp else 1.dp,
|
||||
color =
|
||||
when {
|
||||
isSelected -> MaterialTheme.colorScheme.primary.copy(alpha = 0.8f)
|
||||
else -> MaterialTheme.colorScheme.outline.copy(alpha = 0.2f)
|
||||
},
|
||||
when {
|
||||
isSelected -> MaterialTheme.colorScheme.primary.copy(alpha = 0.8f)
|
||||
else -> MaterialTheme.colorScheme.outline.copy(alpha = 0.2f)
|
||||
},
|
||||
)
|
||||
|
||||
val content: @Composable () -> Unit = {
|
||||
Row(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(12.dp),
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(12.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
@@ -500,11 +489,11 @@ private fun ProxyChip(
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
fontWeight = if (isSelected) FontWeight.SemiBold else FontWeight.Medium,
|
||||
color =
|
||||
if (isSelected) {
|
||||
MaterialTheme.colorScheme.onPrimaryContainer
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurfaceVariant
|
||||
},
|
||||
if (isSelected) {
|
||||
MaterialTheme.colorScheme.onPrimaryContainer
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurfaceVariant
|
||||
},
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
@@ -520,11 +509,11 @@ private fun ProxyChip(
|
||||
text = Libbox.proxyDisplayType(item.type),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color =
|
||||
if (isSelected) {
|
||||
MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.7f)
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f)
|
||||
},
|
||||
if (isSelected) {
|
||||
MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.7f)
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f)
|
||||
},
|
||||
)
|
||||
|
||||
// Latency
|
||||
@@ -566,11 +555,7 @@ private fun ProxyChip(
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ProxyLatencyBadge(
|
||||
delay: Int,
|
||||
isSelected: Boolean,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
private fun ProxyLatencyBadge(delay: Int, isSelected: Boolean, modifier: Modifier = Modifier) {
|
||||
// Direct color calculation without animation for better performance
|
||||
val colorScheme = MaterialTheme.colorScheme
|
||||
val latencyColor =
|
||||
@@ -624,15 +609,9 @@ private fun ProxyLatencyBadge(
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun rememberBounceBlockingNestedScrollConnection(
|
||||
lazyListState: LazyListState
|
||||
): NestedScrollConnection = remember(lazyListState) {
|
||||
private fun rememberBounceBlockingNestedScrollConnection(lazyListState: LazyListState): NestedScrollConnection = remember(lazyListState) {
|
||||
object : NestedScrollConnection {
|
||||
override fun onPostScroll(
|
||||
consumed: Offset,
|
||||
available: Offset,
|
||||
source: NestedScrollSource
|
||||
): Offset {
|
||||
override fun onPostScroll(consumed: Offset, available: Offset, source: NestedScrollSource): Offset {
|
||||
// Only block upward scroll (y < 0) at bottom to prevent sheet expansion
|
||||
// Allow downward scroll (y > 0) at top to let sheet collapse
|
||||
return if (available.y < 0) available else Offset.Zero
|
||||
|
||||
@@ -6,6 +6,7 @@ import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
@@ -42,9 +43,7 @@ import androidx.compose.material3.ModalBottomSheet
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.rememberModalBottomSheetState
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.graphics.toArgb
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
@@ -52,6 +51,7 @@ import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.toArgb
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package io.nekohasekai.sfa.compose.screen.dashboard
|
||||
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
@@ -16,7 +17,6 @@ import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
@@ -29,11 +29,7 @@ import io.nekohasekai.sfa.compose.util.ProfileIcons
|
||||
import io.nekohasekai.sfa.database.Profile
|
||||
|
||||
@Composable
|
||||
fun ProfileSelectorButton(
|
||||
selectedProfile: Profile?,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
fun ProfileSelectorButton(selectedProfile: Profile?, onClick: () -> Unit, modifier: Modifier = Modifier) {
|
||||
Surface(
|
||||
onClick = onClick,
|
||||
modifier = modifier.fillMaxWidth().height(48.dp),
|
||||
|
||||
@@ -5,6 +5,7 @@ import android.widget.Toast
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
@@ -21,33 +22,30 @@ import androidx.compose.material.icons.filled.AccessTime
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material.icons.filled.Check
|
||||
import androidx.compose.material.icons.filled.Cloud
|
||||
import androidx.compose.material.icons.filled.DataObject
|
||||
import androidx.compose.material.icons.filled.Edit
|
||||
import androidx.compose.material.icons.filled.IosShare
|
||||
import androidx.compose.material.icons.filled.QrCode2
|
||||
import androidx.compose.material.icons.filled.QrCodeScanner
|
||||
import androidx.compose.material.icons.filled.Refresh
|
||||
import androidx.compose.material.icons.filled.DataObject
|
||||
import androidx.compose.material.icons.filled.Save
|
||||
import androidx.compose.material.icons.outlined.CreateNewFolder
|
||||
import androidx.compose.material.icons.outlined.Description
|
||||
import androidx.compose.material.icons.outlined.FileUpload
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.ListItem
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ModalBottomSheet
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.graphics.toArgb
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
@@ -55,6 +53,7 @@ import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.toArgb
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
@@ -64,11 +63,11 @@ import io.nekohasekai.libbox.Libbox
|
||||
import io.nekohasekai.libbox.ProfileContent
|
||||
import io.nekohasekai.sfa.R
|
||||
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.component.qr.QRScanSheet
|
||||
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.screen.qrscan.QRScanResult
|
||||
import io.nekohasekai.sfa.compose.util.QRCodeGenerator
|
||||
import io.nekohasekai.sfa.compose.util.RelativeTimeFormatter
|
||||
import io.nekohasekai.sfa.database.Profile
|
||||
|
||||
@@ -23,20 +23,15 @@ import androidx.compose.ui.unit.dp
|
||||
import io.nekohasekai.sfa.R
|
||||
|
||||
@Composable
|
||||
fun SystemProxyCard(
|
||||
enabled: Boolean,
|
||||
isSwitching: Boolean,
|
||||
onToggle: (Boolean) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
fun SystemProxyCard(enabled: Boolean, isSwitching: Boolean, onToggle: (Boolean) -> Unit, modifier: Modifier = Modifier) {
|
||||
Card(
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
) {
|
||||
Row(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
|
||||
@@ -24,20 +24,15 @@ import io.nekohasekai.sfa.R
|
||||
import io.nekohasekai.sfa.compose.LineChart
|
||||
|
||||
@Composable
|
||||
fun UploadTrafficCard(
|
||||
uplink: String,
|
||||
uplinkTotal: String,
|
||||
uplinkHistory: List<Float>,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
fun UploadTrafficCard(uplink: String, uplinkTotal: String, uplinkHistory: List<Float>, modifier: Modifier = Modifier) {
|
||||
Card(
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
) {
|
||||
Column(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
|
||||
@@ -54,9 +54,9 @@ import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import io.nekohasekai.libbox.Libbox
|
||||
import io.nekohasekai.sfa.R
|
||||
import io.nekohasekai.sfa.constant.Status
|
||||
import io.nekohasekai.sfa.compose.model.Group
|
||||
import io.nekohasekai.sfa.compose.model.GroupItem
|
||||
import io.nekohasekai.sfa.constant.Status
|
||||
|
||||
@Composable
|
||||
fun GroupsScreen(
|
||||
@@ -121,12 +121,12 @@ fun GroupsScreen(
|
||||
LazyColumn(
|
||||
modifier = modifier.fillMaxSize(),
|
||||
contentPadding =
|
||||
PaddingValues(
|
||||
start = 16.dp,
|
||||
end = 16.dp,
|
||||
top = 8.dp,
|
||||
bottom = 16.dp,
|
||||
),
|
||||
PaddingValues(
|
||||
start = 16.dp,
|
||||
end = 16.dp,
|
||||
top = 8.dp,
|
||||
bottom = 16.dp,
|
||||
),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
items(
|
||||
@@ -254,17 +254,17 @@ private fun ProxyGroupCard(
|
||||
imageVector = Icons.Default.ExpandMore,
|
||||
contentDescription = if (isExpanded) collapseContentDescription else expandContentDescription,
|
||||
modifier =
|
||||
Modifier
|
||||
.size(24.dp)
|
||||
.graphicsLayer { rotationZ = rotationAngle },
|
||||
Modifier
|
||||
.size(24.dp)
|
||||
.graphicsLayer { rotationZ = rotationAngle },
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
},
|
||||
colors =
|
||||
ListItemDefaults.colors(
|
||||
containerColor = Color.Transparent,
|
||||
),
|
||||
ListItemDefaults.colors(
|
||||
containerColor = Color.Transparent,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -294,12 +294,7 @@ private fun ProxyGroupCard(
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ProxyItemsList(
|
||||
items: List<GroupItem>,
|
||||
selectedTag: String,
|
||||
isSelectable: Boolean,
|
||||
onItemSelected: (String) -> Unit,
|
||||
) {
|
||||
private fun ProxyItemsList(items: List<GroupItem>, selectedTag: String, isSelectable: Boolean, onItemSelected: (String) -> Unit) {
|
||||
// Cache the chunked items to avoid re-chunking on every recomposition
|
||||
val itemsPerRow = 2
|
||||
val chunkedItems =
|
||||
@@ -310,9 +305,9 @@ private fun ProxyItemsList(
|
||||
// Use Column with Rows for better control over item sizing
|
||||
Column(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
chunkedItems.forEach { rowItems ->
|
||||
@@ -344,13 +339,7 @@ private fun ProxyItemsList(
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun ProxyChip(
|
||||
item: GroupItem,
|
||||
isSelected: Boolean,
|
||||
isSelectable: Boolean,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
private fun ProxyChip(item: GroupItem, isSelected: Boolean, isSelectable: Boolean, onClick: () -> Unit, modifier: Modifier = Modifier) {
|
||||
// Use simpler, faster animations
|
||||
val animatedElevation by animateFloatAsState(
|
||||
targetValue = if (isSelected) 6.dp.value else 1.dp.value,
|
||||
@@ -369,18 +358,18 @@ private fun ProxyChip(
|
||||
androidx.compose.foundation.BorderStroke(
|
||||
width = if (isSelected) 2.dp else 1.dp,
|
||||
color =
|
||||
when {
|
||||
isSelected -> MaterialTheme.colorScheme.primary.copy(alpha = 0.8f)
|
||||
else -> MaterialTheme.colorScheme.outline.copy(alpha = 0.2f)
|
||||
},
|
||||
when {
|
||||
isSelected -> MaterialTheme.colorScheme.primary.copy(alpha = 0.8f)
|
||||
else -> MaterialTheme.colorScheme.outline.copy(alpha = 0.2f)
|
||||
},
|
||||
)
|
||||
|
||||
val content: @Composable () -> Unit = {
|
||||
Row(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(12.dp),
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(12.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
@@ -394,11 +383,11 @@ private fun ProxyChip(
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
fontWeight = if (isSelected) FontWeight.SemiBold else FontWeight.Medium,
|
||||
color =
|
||||
if (isSelected) {
|
||||
MaterialTheme.colorScheme.onPrimaryContainer
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurfaceVariant
|
||||
},
|
||||
if (isSelected) {
|
||||
MaterialTheme.colorScheme.onPrimaryContainer
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurfaceVariant
|
||||
},
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
@@ -414,11 +403,11 @@ private fun ProxyChip(
|
||||
text = Libbox.proxyDisplayType(item.type),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color =
|
||||
if (isSelected) {
|
||||
MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.7f)
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f)
|
||||
},
|
||||
if (isSelected) {
|
||||
MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.7f)
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f)
|
||||
},
|
||||
)
|
||||
|
||||
// Latency
|
||||
@@ -460,11 +449,7 @@ private fun ProxyChip(
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ProxyLatencyBadge(
|
||||
delay: Int,
|
||||
isSelected: Boolean,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
private fun ProxyLatencyBadge(delay: Int, isSelected: Boolean, modifier: Modifier = Modifier) {
|
||||
// Direct color calculation without animation for better performance
|
||||
val colorScheme = MaterialTheme.colorScheme
|
||||
val latencyColor =
|
||||
|
||||
@@ -5,10 +5,10 @@ import io.nekohasekai.libbox.Libbox
|
||||
import io.nekohasekai.libbox.OutboundGroup
|
||||
import io.nekohasekai.sfa.compose.base.BaseViewModel
|
||||
import io.nekohasekai.sfa.compose.base.ScreenEvent
|
||||
import io.nekohasekai.sfa.constant.Status
|
||||
import io.nekohasekai.sfa.compose.model.Group
|
||||
import io.nekohasekai.sfa.compose.model.GroupItem
|
||||
import io.nekohasekai.sfa.compose.model.toList
|
||||
import io.nekohasekai.sfa.constant.Status
|
||||
import io.nekohasekai.sfa.utils.AppLifecycleObserver
|
||||
import io.nekohasekai.sfa.utils.CommandClient
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
@@ -28,9 +28,9 @@ sealed class GroupsEvent : ScreenEvent {
|
||||
data class GroupSelected(val groupTag: String, val itemTag: String) : GroupsEvent()
|
||||
}
|
||||
|
||||
class GroupsViewModel(
|
||||
private val sharedCommandClient: CommandClient? = null,
|
||||
) : BaseViewModel<GroupsUiState, GroupsEvent>(), CommandClient.Handler {
|
||||
class GroupsViewModel(private val sharedCommandClient: CommandClient? = null) :
|
||||
BaseViewModel<GroupsUiState, GroupsEvent>(),
|
||||
CommandClient.Handler {
|
||||
private val commandClient: CommandClient
|
||||
private val isUsingSharedClient: Boolean
|
||||
|
||||
@@ -154,10 +154,7 @@ class GroupsViewModel(
|
||||
}
|
||||
}
|
||||
|
||||
fun selectGroupItem(
|
||||
groupTag: String,
|
||||
itemTag: String,
|
||||
) {
|
||||
fun selectGroupItem(groupTag: String, itemTag: String) {
|
||||
// Check if this is actually a different selection
|
||||
val currentGroup = uiState.value.groups.find { it.tag == groupTag }
|
||||
if (currentGroup?.selected == itemTag) {
|
||||
@@ -175,13 +172,13 @@ class GroupsViewModel(
|
||||
updateState {
|
||||
copy(
|
||||
groups =
|
||||
groups.map { group ->
|
||||
if (group.tag == groupTag) {
|
||||
group.copy(selected = itemTag)
|
||||
} else {
|
||||
group
|
||||
}
|
||||
},
|
||||
groups.map { group ->
|
||||
if (group.tag == groupTag) {
|
||||
group.copy(selected = itemTag)
|
||||
} else {
|
||||
group
|
||||
}
|
||||
},
|
||||
showCloseConnectionsSnackbar = true,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -15,7 +15,9 @@ import java.util.LinkedList
|
||||
import java.util.concurrent.atomic.AtomicLong
|
||||
|
||||
@OptIn(FlowPreview::class)
|
||||
abstract class BaseLogViewModel : ViewModel(), LogViewerViewModel {
|
||||
abstract class BaseLogViewModel :
|
||||
ViewModel(),
|
||||
LogViewerViewModel {
|
||||
protected val _uiState = MutableStateFlow(LogUiState())
|
||||
override val uiState: StateFlow<LogUiState> = _uiState.asStateFlow()
|
||||
|
||||
@@ -119,9 +121,7 @@ abstract class BaseLogViewModel : ViewModel(), LogViewerViewModel {
|
||||
.joinToString("\n")
|
||||
}
|
||||
|
||||
override fun getAllLogsText(): String {
|
||||
return _uiState.value.logs.joinToString("\n") { AnsiColorUtils.stripAnsi(it.entry.message) }
|
||||
}
|
||||
override fun getAllLogsText(): String = _uiState.value.logs.joinToString("\n") { AnsiColorUtils.stripAnsi(it.entry.message) }
|
||||
|
||||
protected fun updateDisplayedLogs() {
|
||||
val currentState = _uiState.value
|
||||
|
||||
@@ -3,16 +3,9 @@ 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 LogEntryData(val level: LogLevel, val message: String)
|
||||
|
||||
data class ProcessedLogEntry(
|
||||
val id: Long,
|
||||
val entry: LogEntryData,
|
||||
val annotatedString: AnnotatedString,
|
||||
)
|
||||
data class ProcessedLogEntry(val id: Long, val entry: LogEntryData, val annotatedString: AnnotatedString)
|
||||
|
||||
enum class LogLevel(val label: String, val priority: Int) {
|
||||
Default("Default", 7),
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
package io.nekohasekai.sfa.compose.screen.log
|
||||
|
||||
import android.content.ClipData
|
||||
import android.os.Build
|
||||
import android.content.res.Configuration
|
||||
import android.content.Intent
|
||||
import android.content.res.Configuration
|
||||
import android.os.Build
|
||||
import android.widget.Toast
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
@@ -144,17 +144,17 @@ fun LogScreen(
|
||||
IconButton(onClick = { resolvedViewModel.togglePause() }) {
|
||||
Icon(
|
||||
imageVector =
|
||||
if (uiState.isPaused) {
|
||||
Icons.Default.PlayArrow
|
||||
} else {
|
||||
Icons.Default.Pause
|
||||
},
|
||||
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)
|
||||
},
|
||||
if (uiState.isPaused) {
|
||||
stringResource(R.string.content_description_resume_logs)
|
||||
} else {
|
||||
stringResource(R.string.content_description_pause_logs)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -162,23 +162,23 @@ fun LogScreen(
|
||||
IconButton(onClick = { resolvedViewModel.toggleSearch() }) {
|
||||
Icon(
|
||||
imageVector =
|
||||
if (uiState.isSearchActive) {
|
||||
Icons.Default.ExpandLess
|
||||
} else {
|
||||
Icons.Default.Search
|
||||
},
|
||||
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)
|
||||
},
|
||||
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
|
||||
},
|
||||
if (uiState.isSearchActive) {
|
||||
MaterialTheme.colorScheme.primary
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurface
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -281,9 +281,9 @@ fun LogScreen(
|
||||
) {
|
||||
Row(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 8.dp, vertical = 4.dp),
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 8.dp, vertical = 4.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
@@ -298,10 +298,10 @@ fun LogScreen(
|
||||
}
|
||||
Text(
|
||||
text =
|
||||
stringResource(
|
||||
R.string.selected_count,
|
||||
uiState.selectedLogIndices.size,
|
||||
),
|
||||
stringResource(
|
||||
R.string.selected_count,
|
||||
uiState.selectedLogIndices.size,
|
||||
),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
modifier = Modifier.padding(start = 8.dp),
|
||||
)
|
||||
@@ -343,18 +343,18 @@ fun LogScreen(
|
||||
) {
|
||||
Row(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
text =
|
||||
stringResource(
|
||||
R.string.filter_label,
|
||||
uiState.filterLogLevel.label,
|
||||
),
|
||||
stringResource(
|
||||
R.string.filter_label,
|
||||
uiState.filterLogLevel.label,
|
||||
),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
TextButton(
|
||||
@@ -375,19 +375,19 @@ fun LogScreen(
|
||||
AnimatedVisibility(
|
||||
visible = uiState.isSearchActive,
|
||||
enter =
|
||||
expandVertically(
|
||||
expandVertically(
|
||||
animationSpec = tween(300),
|
||||
) +
|
||||
fadeIn(
|
||||
animationSpec = tween(300),
|
||||
) +
|
||||
fadeIn(
|
||||
animationSpec = tween(300),
|
||||
),
|
||||
),
|
||||
exit =
|
||||
shrinkVertically(
|
||||
shrinkVertically(
|
||||
animationSpec = tween(300),
|
||||
) +
|
||||
fadeOut(
|
||||
animationSpec = tween(300),
|
||||
) +
|
||||
fadeOut(
|
||||
animationSpec = tween(300),
|
||||
),
|
||||
),
|
||||
) {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
@@ -405,10 +405,10 @@ fun LogScreen(
|
||||
value = uiState.searchQuery,
|
||||
onValueChange = { resolvedViewModel.updateSearchQuery(it) },
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(start = 16.dp, end = 16.dp, bottom = 12.dp)
|
||||
.focusRequester(focusRequester),
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(start = 16.dp, end = 16.dp, bottom = 12.dp)
|
||||
.focusRequester(focusRequester),
|
||||
placeholder = { Text(stringResource(R.string.search_logs_placeholder)) },
|
||||
leadingIcon = {
|
||||
Icon(
|
||||
@@ -429,11 +429,11 @@ fun LogScreen(
|
||||
singleLine = true,
|
||||
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
|
||||
keyboardActions =
|
||||
KeyboardActions(
|
||||
onSearch = {
|
||||
focusManager.clearFocus()
|
||||
},
|
||||
),
|
||||
KeyboardActions(
|
||||
onSearch = {
|
||||
focusManager.clearFocus()
|
||||
},
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -495,12 +495,12 @@ fun LogScreen(
|
||||
state = listState,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentPadding =
|
||||
PaddingValues(
|
||||
start = 8.dp,
|
||||
end = 8.dp,
|
||||
top = 8.dp,
|
||||
bottom = bottomPadding,
|
||||
),
|
||||
PaddingValues(
|
||||
start = 8.dp,
|
||||
end = 8.dp,
|
||||
top = 8.dp,
|
||||
bottom = bottomPadding,
|
||||
),
|
||||
verticalArrangement = Arrangement.spacedBy(2.dp),
|
||||
) {
|
||||
itemsIndexed(
|
||||
@@ -532,9 +532,9 @@ fun LogScreen(
|
||||
// Options Menu - Material 3 style
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.align(Alignment.TopEnd)
|
||||
.padding(end = 8.dp),
|
||||
Modifier
|
||||
.align(Alignment.TopEnd)
|
||||
.padding(end = 8.dp),
|
||||
) {
|
||||
var expandedLogLevel by remember { mutableStateOf(false) }
|
||||
var expandedSave by remember { mutableStateOf(false) }
|
||||
@@ -595,11 +595,11 @@ fun LogScreen(
|
||||
trailingIcon = {
|
||||
Icon(
|
||||
imageVector =
|
||||
if (expandedLogLevel) {
|
||||
Icons.Default.ExpandLess
|
||||
} else {
|
||||
Icons.Default.ExpandMore
|
||||
},
|
||||
if (expandedLogLevel) {
|
||||
Icons.Default.ExpandLess
|
||||
} else {
|
||||
Icons.Default.ExpandMore
|
||||
},
|
||||
contentDescription = null,
|
||||
)
|
||||
},
|
||||
@@ -620,23 +620,23 @@ fun LogScreen(
|
||||
leadingIcon = {
|
||||
Icon(
|
||||
imageVector =
|
||||
if (uiState.filterLogLevel == level) {
|
||||
Icons.Default.RadioButtonChecked
|
||||
} else {
|
||||
Icons.Default.RadioButtonUnchecked
|
||||
},
|
||||
if (uiState.filterLogLevel == level) {
|
||||
Icons.Default.RadioButtonChecked
|
||||
} else {
|
||||
Icons.Default.RadioButtonUnchecked
|
||||
},
|
||||
contentDescription =
|
||||
if (uiState.filterLogLevel == level) {
|
||||
stringResource(R.string.group_selected_title)
|
||||
} else {
|
||||
null
|
||||
},
|
||||
if (uiState.filterLogLevel == level) {
|
||||
stringResource(R.string.group_selected_title)
|
||||
} else {
|
||||
null
|
||||
},
|
||||
tint =
|
||||
if (uiState.filterLogLevel == level) {
|
||||
MaterialTheme.colorScheme.primary
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurfaceVariant
|
||||
},
|
||||
if (uiState.filterLogLevel == level) {
|
||||
MaterialTheme.colorScheme.primary
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurfaceVariant
|
||||
},
|
||||
modifier = Modifier.padding(start = 24.dp),
|
||||
)
|
||||
},
|
||||
@@ -665,11 +665,11 @@ fun LogScreen(
|
||||
trailingIcon = {
|
||||
Icon(
|
||||
imageVector =
|
||||
if (expandedSave) {
|
||||
Icons.Default.ExpandLess
|
||||
} else {
|
||||
Icons.Default.ExpandMore
|
||||
},
|
||||
if (expandedSave) {
|
||||
Icons.Default.ExpandLess
|
||||
} else {
|
||||
Icons.Default.ExpandMore
|
||||
},
|
||||
contentDescription = null,
|
||||
)
|
||||
},
|
||||
@@ -841,9 +841,9 @@ fun LogScreen(
|
||||
val fabEndPadding = if (isTablet) 20.dp else 16.dp
|
||||
Column(
|
||||
modifier =
|
||||
Modifier
|
||||
.align(Alignment.BottomEnd)
|
||||
.padding(bottom = fabBottomPadding, end = fabEndPadding, top = 16.dp),
|
||||
Modifier
|
||||
.align(Alignment.BottomEnd)
|
||||
.padding(bottom = fabBottomPadding, end = fabEndPadding, top = 16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
// Scroll to bottom FAB
|
||||
@@ -880,34 +880,34 @@ fun LogItem(
|
||||
) {
|
||||
Card(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.combinedClickable(
|
||||
onClick = onClick,
|
||||
onLongClick = onLongClick,
|
||||
),
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.combinedClickable(
|
||||
onClick = onClick,
|
||||
onLongClick = onLongClick,
|
||||
),
|
||||
shape = RoundedCornerShape(4.dp),
|
||||
colors =
|
||||
CardDefaults.cardColors(
|
||||
containerColor =
|
||||
if (isSelected) {
|
||||
MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.3f)
|
||||
} else {
|
||||
MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f)
|
||||
},
|
||||
),
|
||||
border =
|
||||
CardDefaults.cardColors(
|
||||
containerColor =
|
||||
if (isSelected) {
|
||||
CardDefaults.outlinedCardBorder().copy(
|
||||
width = 2.dp,
|
||||
brush =
|
||||
androidx.compose.ui.graphics.SolidColor(
|
||||
MaterialTheme.colorScheme.primary.copy(alpha = 0.5f),
|
||||
),
|
||||
)
|
||||
MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.3f)
|
||||
} else {
|
||||
null
|
||||
MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f)
|
||||
},
|
||||
),
|
||||
border =
|
||||
if (isSelected) {
|
||||
CardDefaults.outlinedCardBorder().copy(
|
||||
width = 2.dp,
|
||||
brush =
|
||||
androidx.compose.ui.graphics.SolidColor(
|
||||
MaterialTheme.colorScheme.primary.copy(alpha = 0.5f),
|
||||
),
|
||||
)
|
||||
} else {
|
||||
null
|
||||
},
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
@@ -917,13 +917,13 @@ fun LogItem(
|
||||
Icon(
|
||||
imageVector = if (isSelected) Icons.Default.CheckBox else Icons.Default.CheckBoxOutlineBlank,
|
||||
contentDescription =
|
||||
if (isSelected) {
|
||||
stringResource(R.string.group_selected_title)
|
||||
} else {
|
||||
stringResource(
|
||||
R.string.not_selected,
|
||||
)
|
||||
},
|
||||
if (isSelected) {
|
||||
stringResource(R.string.group_selected_title)
|
||||
} else {
|
||||
stringResource(
|
||||
R.string.not_selected,
|
||||
)
|
||||
},
|
||||
modifier = Modifier.padding(start = 12.dp, end = 4.dp),
|
||||
tint = if (isSelected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
@@ -931,14 +931,14 @@ fun LogItem(
|
||||
Text(
|
||||
text = annotatedString,
|
||||
modifier =
|
||||
Modifier
|
||||
.weight(1f)
|
||||
.padding(
|
||||
start = if (isSelectionMode) 4.dp else 12.dp,
|
||||
end = 12.dp,
|
||||
top = 8.dp,
|
||||
bottom = 8.dp,
|
||||
),
|
||||
Modifier
|
||||
.weight(1f)
|
||||
.padding(
|
||||
start = if (isSelectionMode) 4.dp else 12.dp,
|
||||
end = 12.dp,
|
||||
top = 8.dp,
|
||||
bottom = 8.dp,
|
||||
),
|
||||
fontSize = 13.sp,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
lineHeight = 18.sp,
|
||||
|
||||
@@ -13,7 +13,9 @@ import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.util.LinkedList
|
||||
|
||||
class LogViewModel : BaseLogViewModel(), CommandClient.Handler {
|
||||
class LogViewModel :
|
||||
BaseLogViewModel(),
|
||||
CommandClient.Handler {
|
||||
companion object {
|
||||
private val maxLines = 3000
|
||||
}
|
||||
|
||||
@@ -4,9 +4,9 @@ import android.content.pm.PackageManager
|
||||
import android.os.Build
|
||||
import android.widget.Toast
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.expandVertically
|
||||
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
|
||||
@@ -53,13 +53,13 @@ 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.topbar.OverrideTopBar
|
||||
import io.nekohasekai.sfa.database.Settings
|
||||
import io.nekohasekai.sfa.ktx.clipboardText
|
||||
import io.nekohasekai.sfa.utils.PrivilegeSettingsClient
|
||||
import io.nekohasekai.sfa.vendor.PackageQueryManager
|
||||
import io.nekohasekai.sfa.vendor.PrivilegedAccessRequiredException
|
||||
@@ -68,11 +68,7 @@ import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.util.Locale
|
||||
|
||||
|
||||
private data class LoadResult(
|
||||
val packages: List<PackageCache>,
|
||||
val selectedUids: Set<Int>,
|
||||
)
|
||||
private data class LoadResult(val packages: List<PackageCache>, val selectedUids: Set<Int>)
|
||||
|
||||
private const val VPN_SERVICE_PERMISSION = "android.permission.BIND_VPN_SERVICE"
|
||||
|
||||
@@ -126,10 +122,11 @@ fun PrivilegeSettingsManageScreen(onBack: () -> Unit) {
|
||||
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
|
||||
)
|
||||
!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
|
||||
@@ -138,11 +135,9 @@ fun PrivilegeSettingsManageScreen(onBack: () -> Unit) {
|
||||
}
|
||||
}
|
||||
|
||||
fun buildPackageList(newUids: Set<Int>): Set<String> {
|
||||
return newUids.mapNotNull { uid ->
|
||||
packages.find { it.uid == uid }?.packageName
|
||||
}.toSet()
|
||||
}
|
||||
fun buildPackageList(newUids: Set<Int>): Set<String> = newUids.mapNotNull { uid ->
|
||||
packages.find { it.uid == uid }?.packageName
|
||||
}.toSet()
|
||||
|
||||
fun updateCurrentPackages(filterQuery: String) {
|
||||
currentPackages =
|
||||
@@ -443,10 +438,10 @@ fun PrivilegeSettingsManageScreen(onBack: () -> Unit) {
|
||||
)
|
||||
},
|
||||
colors =
|
||||
TopAppBarDefaults.topAppBarColors(
|
||||
containerColor = MaterialTheme.colorScheme.surface,
|
||||
titleContentColor = MaterialTheme.colorScheme.onSurface,
|
||||
),
|
||||
TopAppBarDefaults.topAppBarColors(
|
||||
containerColor = MaterialTheme.colorScheme.surface,
|
||||
titleContentColor = MaterialTheme.colorScheme.onSurface,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -492,10 +487,10 @@ fun PrivilegeSettingsManageScreen(onBack: () -> Unit) {
|
||||
updateCurrentPackages(it)
|
||||
},
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp)
|
||||
.focusRequester(focusRequester),
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp)
|
||||
.focusRequester(focusRequester),
|
||||
placeholder = { Text(stringResource(R.string.search)) },
|
||||
leadingIcon = {
|
||||
Icon(
|
||||
@@ -524,10 +519,10 @@ fun PrivilegeSettingsManageScreen(onBack: () -> Unit) {
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentPadding =
|
||||
androidx.compose.foundation.layout.PaddingValues(
|
||||
horizontal = 16.dp,
|
||||
vertical = 12.dp,
|
||||
),
|
||||
androidx.compose.foundation.layout.PaddingValues(
|
||||
horizontal = 16.dp,
|
||||
vertical = 12.dp,
|
||||
),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
items(currentPackages, key = { it.packageName }) { packageCache ->
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -38,11 +38,7 @@ data class EditProfileContentUiState(
|
||||
val profileName: String = "", // Add profile name
|
||||
)
|
||||
|
||||
class EditProfileContentViewModel(
|
||||
private val profileId: Long,
|
||||
initialProfileName: String = "",
|
||||
initialIsReadOnly: Boolean = false,
|
||||
) : ViewModel() {
|
||||
class EditProfileContentViewModel(private val profileId: Long, initialProfileName: String = "", initialIsReadOnly: Boolean = false) : ViewModel() {
|
||||
private val _uiState =
|
||||
MutableStateFlow(
|
||||
EditProfileContentUiState(
|
||||
@@ -56,10 +52,7 @@ class EditProfileContentViewModel(
|
||||
private var editor: ManualScrollTextProcessor? = null
|
||||
private var configCheckJob: Job? = null
|
||||
|
||||
fun setEditor(
|
||||
textProcessor: ManualScrollTextProcessor,
|
||||
isReadOnly: Boolean = false,
|
||||
) {
|
||||
fun setEditor(textProcessor: ManualScrollTextProcessor, isReadOnly: Boolean = false) {
|
||||
val isNewEditor = editor != textProcessor
|
||||
editor = textProcessor
|
||||
textProcessor.resumeAutoScroll()
|
||||
@@ -89,18 +82,12 @@ class EditProfileContentViewModel(
|
||||
// Customize text selection to remove Cut and Paste options
|
||||
textProcessor.customSelectionActionModeCallback =
|
||||
object : android.view.ActionMode.Callback {
|
||||
override fun onCreateActionMode(
|
||||
mode: android.view.ActionMode?,
|
||||
menu: android.view.Menu?,
|
||||
): Boolean {
|
||||
override fun onCreateActionMode(mode: android.view.ActionMode?, menu: android.view.Menu?): Boolean {
|
||||
// Allow the action mode to be created
|
||||
return true
|
||||
}
|
||||
|
||||
override fun onPrepareActionMode(
|
||||
mode: android.view.ActionMode?,
|
||||
menu: android.view.Menu?,
|
||||
): Boolean {
|
||||
override fun onPrepareActionMode(mode: android.view.ActionMode?, menu: android.view.Menu?): Boolean {
|
||||
// Remove editing-related menu items, keep only Copy and Select All
|
||||
menu?.let { m ->
|
||||
// Remove all editing-related items
|
||||
@@ -116,10 +103,7 @@ class EditProfileContentViewModel(
|
||||
return true
|
||||
}
|
||||
|
||||
override fun onActionItemClicked(
|
||||
mode: android.view.ActionMode?,
|
||||
item: android.view.MenuItem?,
|
||||
): Boolean {
|
||||
override fun onActionItemClicked(mode: android.view.ActionMode?, item: android.view.MenuItem?): Boolean {
|
||||
// Let the default implementation handle allowed actions (copy, select all)
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -13,11 +13,7 @@ import androidx.navigation.compose.rememberNavController
|
||||
import androidx.navigation.navArgument
|
||||
|
||||
@Composable
|
||||
fun EditProfileRoute(
|
||||
profileId: Long,
|
||||
onNavigateBack: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
fun EditProfileRoute(profileId: Long, onNavigateBack: () -> Unit, modifier: Modifier = Modifier) {
|
||||
if (profileId == -1L) {
|
||||
LaunchedEffect(Unit) {
|
||||
onNavigateBack()
|
||||
@@ -84,12 +80,12 @@ fun EditProfileRoute(
|
||||
composable(
|
||||
route = "icon_selection/{currentIconId}",
|
||||
arguments =
|
||||
listOf(
|
||||
navArgument("currentIconId") {
|
||||
type = NavType.StringType
|
||||
nullable = true
|
||||
},
|
||||
),
|
||||
listOf(
|
||||
navArgument("currentIconId") {
|
||||
type = NavType.StringType
|
||||
nullable = true
|
||||
},
|
||||
),
|
||||
enterTransition = {
|
||||
slideIntoContainer(
|
||||
AnimatedContentTransitionScope.SlideDirection.Left,
|
||||
@@ -134,16 +130,16 @@ fun EditProfileRoute(
|
||||
composable(
|
||||
route = "edit_content/{profileName}/{isReadOnly}",
|
||||
arguments =
|
||||
listOf(
|
||||
navArgument("profileName") {
|
||||
type = NavType.StringType
|
||||
defaultValue = ""
|
||||
},
|
||||
navArgument("isReadOnly") {
|
||||
type = NavType.BoolType
|
||||
defaultValue = false
|
||||
},
|
||||
),
|
||||
listOf(
|
||||
navArgument("profileName") {
|
||||
type = NavType.StringType
|
||||
defaultValue = ""
|
||||
},
|
||||
navArgument("isReadOnly") {
|
||||
type = NavType.BoolType
|
||||
defaultValue = false
|
||||
},
|
||||
),
|
||||
enterTransition = {
|
||||
slideIntoContainer(
|
||||
AnimatedContentTransitionScope.SlideDirection.Left,
|
||||
|
||||
@@ -13,7 +13,6 @@ 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
|
||||
@@ -61,6 +60,7 @@ import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
@@ -179,9 +179,9 @@ fun EditProfileScreen(
|
||||
}
|
||||
},
|
||||
colors =
|
||||
TopAppBarDefaults.topAppBarColors(
|
||||
containerColor = MaterialTheme.colorScheme.surface,
|
||||
),
|
||||
TopAppBarDefaults.topAppBarColors(
|
||||
containerColor = MaterialTheme.colorScheme.surface,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -199,324 +199,324 @@ fun EditProfileScreen(
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
) {
|
||||
// Progress indicator at top (only for initial loading)
|
||||
if (uiState.isLoading) {
|
||||
LinearProgressIndicator(
|
||||
// Progress indicator at top (only for initial loading)
|
||||
if (uiState.isLoading) {
|
||||
LinearProgressIndicator(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
|
||||
if (!uiState.isLoading) {
|
||||
Column(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(16.dp)
|
||||
.padding(bottom = bottomBarPadding),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
// Basic Information Card
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
|
||||
if (!uiState.isLoading) {
|
||||
Column(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(16.dp)
|
||||
.padding(bottom = bottomBarPadding),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
colors =
|
||||
CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f),
|
||||
),
|
||||
) {
|
||||
// Basic Information Card
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors =
|
||||
CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f),
|
||||
),
|
||||
Column(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.basic_information),
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.basic_information),
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
|
||||
OutlinedTextField(
|
||||
value = uiState.name,
|
||||
onValueChange = viewModel::updateName,
|
||||
label = { Text(stringResource(R.string.profile_name)) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true,
|
||||
)
|
||||
|
||||
HorizontalDivider(
|
||||
modifier = Modifier.padding(vertical = 4.dp),
|
||||
color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f),
|
||||
)
|
||||
|
||||
// Icon selection with Material You style
|
||||
Text(
|
||||
text = stringResource(R.string.icon),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(bottom = 4.dp),
|
||||
)
|
||||
|
||||
Surface(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
.clickable { viewModel.showIconDialog() },
|
||||
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f),
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
) {
|
||||
Row(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
// Display current icon
|
||||
val currentIcon =
|
||||
ProfileIcons.getIconById(uiState.icon)
|
||||
?: Icons.AutoMirrored.Filled.InsertDriveFile
|
||||
|
||||
Icon(
|
||||
imageVector = currentIcon,
|
||||
contentDescription = stringResource(R.string.profile_icon),
|
||||
modifier = Modifier.size(24.dp),
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
|
||||
Text(
|
||||
text =
|
||||
uiState.icon?.let { iconId ->
|
||||
MaterialIconsLibrary.getAllIcons()
|
||||
.find { it.id == iconId }?.label
|
||||
} ?: stringResource(R.string.default_text),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight,
|
||||
contentDescription = stringResource(R.string.select_icon),
|
||||
modifier = Modifier.size(20.dp),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Remote Profile Options
|
||||
if (uiState.profileType == TypedProfile.Type.Remote) {
|
||||
Card(
|
||||
OutlinedTextField(
|
||||
value = uiState.name,
|
||||
onValueChange = viewModel::updateName,
|
||||
label = { Text(stringResource(R.string.profile_name)) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors =
|
||||
CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.tertiaryContainer.copy(alpha = 0.3f),
|
||||
),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.CloudDownload,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.tertiary,
|
||||
modifier = Modifier.size(20.dp),
|
||||
)
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(2.dp),
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.remote_configuration),
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
color = MaterialTheme.colorScheme.tertiary,
|
||||
)
|
||||
uiState.lastUpdated?.let { lastUpdated ->
|
||||
Text(
|
||||
text =
|
||||
stringResource(
|
||||
R.string.last_updated_format,
|
||||
RelativeTimeFormatter.format(
|
||||
context,
|
||||
lastUpdated,
|
||||
),
|
||||
),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
// Update button in top-right corner
|
||||
IconButton(
|
||||
onClick = { viewModel.updateRemoteProfile() },
|
||||
enabled = !uiState.isUpdating && !uiState.showUpdateSuccess,
|
||||
) {
|
||||
when {
|
||||
uiState.isUpdating -> {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(24.dp),
|
||||
strokeWidth = 2.dp,
|
||||
)
|
||||
}
|
||||
uiState.showUpdateSuccess -> {
|
||||
Icon(
|
||||
Icons.Default.Check,
|
||||
contentDescription = stringResource(R.string.success),
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
}
|
||||
else -> {
|
||||
Icon(
|
||||
Icons.Default.Update,
|
||||
contentDescription = stringResource(R.string.profile_update),
|
||||
tint = MaterialTheme.colorScheme.tertiary,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
singleLine = true,
|
||||
)
|
||||
|
||||
OutlinedTextField(
|
||||
value = uiState.remoteUrl,
|
||||
onValueChange = viewModel::updateRemoteUrl,
|
||||
label = { Text(stringResource(R.string.profile_url)) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true,
|
||||
HorizontalDivider(
|
||||
modifier = Modifier.padding(vertical = 4.dp),
|
||||
color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f),
|
||||
)
|
||||
|
||||
// Icon selection with Material You style
|
||||
Text(
|
||||
text = stringResource(R.string.icon),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(bottom = 4.dp),
|
||||
)
|
||||
|
||||
Surface(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
.clickable { viewModel.showIconDialog() },
|
||||
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f),
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
) {
|
||||
Row(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
// Display current icon
|
||||
val currentIcon =
|
||||
ProfileIcons.getIconById(uiState.icon)
|
||||
?: Icons.AutoMirrored.Filled.InsertDriveFile
|
||||
|
||||
Icon(
|
||||
imageVector = currentIcon,
|
||||
contentDescription = stringResource(R.string.profile_icon),
|
||||
modifier = Modifier.size(24.dp),
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
|
||||
HorizontalDivider()
|
||||
Text(
|
||||
text =
|
||||
uiState.icon?.let { iconId ->
|
||||
MaterialIconsLibrary.getAllIcons()
|
||||
.find { it.id == iconId }?.label
|
||||
} ?: stringResource(R.string.default_text),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
|
||||
// Auto Update Toggle
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.profile_auto_update),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
)
|
||||
Switch(
|
||||
checked = uiState.autoUpdate,
|
||||
onCheckedChange = viewModel::updateAutoUpdate,
|
||||
)
|
||||
}
|
||||
|
||||
AnimatedVisibility(visible = uiState.autoUpdate) {
|
||||
OutlinedTextField(
|
||||
value = uiState.autoUpdateInterval.toString(),
|
||||
onValueChange = viewModel::updateAutoUpdateInterval,
|
||||
label = { Text(stringResource(R.string.profile_auto_update_interval)) },
|
||||
supportingText = {
|
||||
uiState.autoUpdateIntervalError?.let { error ->
|
||||
Text(
|
||||
text = error,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
} ?: Text(stringResource(R.string.profile_auto_update_interval_minimum_hint))
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true,
|
||||
isError = uiState.autoUpdateIntervalError != null,
|
||||
)
|
||||
}
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight,
|
||||
contentDescription = stringResource(R.string.select_icon),
|
||||
modifier = Modifier.size(20.dp),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Content Card (for both Local and Remote profiles) - placed at the end
|
||||
// Remote Profile Options
|
||||
if (uiState.profileType == TypedProfile.Type.Remote) {
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors =
|
||||
CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.secondaryContainer.copy(alpha = 0.3f),
|
||||
),
|
||||
CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.tertiaryContainer.copy(alpha = 0.3f),
|
||||
),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.CloudDownload,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.tertiary,
|
||||
modifier = Modifier.size(20.dp),
|
||||
)
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(2.dp),
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.remote_configuration),
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
color = MaterialTheme.colorScheme.tertiary,
|
||||
)
|
||||
uiState.lastUpdated?.let { lastUpdated ->
|
||||
Text(
|
||||
text =
|
||||
stringResource(
|
||||
R.string.last_updated_format,
|
||||
RelativeTimeFormatter.format(
|
||||
context,
|
||||
lastUpdated,
|
||||
),
|
||||
),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
// Update button in top-right corner
|
||||
IconButton(
|
||||
onClick = { viewModel.updateRemoteProfile() },
|
||||
enabled = !uiState.isUpdating && !uiState.showUpdateSuccess,
|
||||
) {
|
||||
when {
|
||||
uiState.isUpdating -> {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(24.dp),
|
||||
strokeWidth = 2.dp,
|
||||
)
|
||||
}
|
||||
uiState.showUpdateSuccess -> {
|
||||
Icon(
|
||||
Icons.Default.Check,
|
||||
contentDescription = stringResource(R.string.success),
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
}
|
||||
else -> {
|
||||
Icon(
|
||||
Icons.Default.Update,
|
||||
contentDescription = stringResource(R.string.profile_update),
|
||||
tint = MaterialTheme.colorScheme.tertiary,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
OutlinedTextField(
|
||||
value = uiState.remoteUrl,
|
||||
onValueChange = viewModel::updateRemoteUrl,
|
||||
label = { Text(stringResource(R.string.profile_url)) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true,
|
||||
)
|
||||
|
||||
HorizontalDivider()
|
||||
|
||||
// Auto Update Toggle
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
) {
|
||||
Icon(
|
||||
Icons.AutoMirrored.Filled.InsertDriveFile,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.secondary,
|
||||
modifier = Modifier.size(20.dp),
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.content),
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
color = MaterialTheme.colorScheme.secondary,
|
||||
text = stringResource(R.string.profile_auto_update),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
)
|
||||
Switch(
|
||||
checked = uiState.autoUpdate,
|
||||
onCheckedChange = viewModel::updateAutoUpdate,
|
||||
)
|
||||
}
|
||||
|
||||
// JSON Editor/Viewer option
|
||||
Surface(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
.clickable {
|
||||
onNavigateToEditContent(
|
||||
uiState.name,
|
||||
uiState.profileType == TypedProfile.Type.Remote,
|
||||
AnimatedVisibility(visible = uiState.autoUpdate) {
|
||||
OutlinedTextField(
|
||||
value = uiState.autoUpdateInterval.toString(),
|
||||
onValueChange = viewModel::updateAutoUpdateInterval,
|
||||
label = { Text(stringResource(R.string.profile_auto_update_interval)) },
|
||||
supportingText = {
|
||||
uiState.autoUpdateIntervalError?.let { error ->
|
||||
Text(
|
||||
text = error,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
},
|
||||
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f),
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
} ?: Text(stringResource(R.string.profile_auto_update_interval_minimum_hint))
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true,
|
||||
isError = uiState.autoUpdateIntervalError != null,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Content Card (for both Local and Remote profiles) - placed at the end
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors =
|
||||
CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.secondaryContainer.copy(alpha = 0.3f),
|
||||
),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Icon(
|
||||
Icons.AutoMirrored.Filled.InsertDriveFile,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.secondary,
|
||||
modifier = Modifier.size(20.dp),
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.content),
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
color = MaterialTheme.colorScheme.secondary,
|
||||
)
|
||||
}
|
||||
|
||||
// JSON Editor/Viewer option
|
||||
Surface(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
.clickable {
|
||||
onNavigateToEditContent(
|
||||
uiState.name,
|
||||
uiState.profileType == TypedProfile.Type.Remote,
|
||||
)
|
||||
},
|
||||
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f),
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
) {
|
||||
Row(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
Row(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Code,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(24.dp),
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
Text(
|
||||
text =
|
||||
if (uiState.profileType == TypedProfile.Type.Remote) {
|
||||
stringResource(R.string.json_viewer)
|
||||
} else {
|
||||
stringResource(R.string.json_editor)
|
||||
},
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(20.dp),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
Icon(
|
||||
imageVector = Icons.Default.Code,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(24.dp),
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
Text(
|
||||
text =
|
||||
if (uiState.profileType == TypedProfile.Type.Remote) {
|
||||
stringResource(R.string.json_viewer)
|
||||
} else {
|
||||
stringResource(R.string.json_editor)
|
||||
},
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(20.dp),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
AnimatedVisibility(
|
||||
visible = uiState.hasChanges,
|
||||
enter = fadeIn() + expandVertically(),
|
||||
@@ -530,10 +530,10 @@ fun EditProfileScreen(
|
||||
) {
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.windowInsetsPadding(WindowInsets.navigationBars)
|
||||
.padding(16.dp),
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.windowInsetsPadding(WindowInsets.navigationBars)
|
||||
.padding(16.dp),
|
||||
) {
|
||||
Button(
|
||||
onClick = { viewModel.saveChanges() },
|
||||
|
||||
@@ -109,9 +109,9 @@ class EditProfileViewModel(application: Application) : AndroidViewModel(applicat
|
||||
state.copy(
|
||||
name = name,
|
||||
hasChanges =
|
||||
checkHasChanges(
|
||||
state.copy(name = name),
|
||||
),
|
||||
checkHasChanges(
|
||||
state.copy(name = name),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -121,9 +121,9 @@ class EditProfileViewModel(application: Application) : AndroidViewModel(applicat
|
||||
state.copy(
|
||||
icon = icon,
|
||||
hasChanges =
|
||||
checkHasChanges(
|
||||
state.copy(icon = icon),
|
||||
),
|
||||
checkHasChanges(
|
||||
state.copy(icon = icon),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -141,9 +141,9 @@ class EditProfileViewModel(application: Application) : AndroidViewModel(applicat
|
||||
state.copy(
|
||||
remoteUrl = url,
|
||||
hasChanges =
|
||||
checkHasChanges(
|
||||
state.copy(remoteUrl = url),
|
||||
),
|
||||
checkHasChanges(
|
||||
state.copy(remoteUrl = url),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -153,9 +153,9 @@ class EditProfileViewModel(application: Application) : AndroidViewModel(applicat
|
||||
state.copy(
|
||||
autoUpdate = enabled,
|
||||
hasChanges =
|
||||
checkHasChanges(
|
||||
state.copy(autoUpdate = enabled),
|
||||
),
|
||||
checkHasChanges(
|
||||
state.copy(autoUpdate = enabled),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -174,22 +174,20 @@ class EditProfileViewModel(application: Application) : AndroidViewModel(applicat
|
||||
autoUpdateInterval = intValue,
|
||||
autoUpdateIntervalError = error,
|
||||
hasChanges =
|
||||
if (error == null) {
|
||||
checkHasChanges(state.copy(autoUpdateInterval = intValue))
|
||||
} else {
|
||||
state.hasChanges
|
||||
},
|
||||
if (error == null) {
|
||||
checkHasChanges(state.copy(autoUpdateInterval = intValue))
|
||||
} else {
|
||||
state.hasChanges
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun checkHasChanges(state: EditProfileUiState): Boolean {
|
||||
return state.name != state.originalName ||
|
||||
state.icon != state.originalIcon ||
|
||||
state.remoteUrl != state.originalRemoteUrl ||
|
||||
state.autoUpdate != state.originalAutoUpdate ||
|
||||
state.autoUpdateInterval != state.originalAutoUpdateInterval
|
||||
}
|
||||
private fun checkHasChanges(state: EditProfileUiState): Boolean = state.name != state.originalName ||
|
||||
state.icon != state.originalIcon ||
|
||||
state.remoteUrl != state.originalRemoteUrl ||
|
||||
state.autoUpdate != state.originalAutoUpdate ||
|
||||
state.autoUpdateInterval != state.originalAutoUpdateInterval
|
||||
|
||||
fun saveChanges() {
|
||||
val state = _uiState.value
|
||||
@@ -343,10 +341,7 @@ class EditProfileViewModel(application: Application) : AndroidViewModel(applicat
|
||||
}
|
||||
}
|
||||
|
||||
fun saveExportToUri(
|
||||
context: Context,
|
||||
uri: Uri,
|
||||
) {
|
||||
fun saveExportToUri(context: Context, uri: Uri) {
|
||||
val content = pendingExportContent ?: return
|
||||
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
|
||||
@@ -37,17 +37,13 @@ import io.nekohasekai.sfa.compose.util.ProfileIcon
|
||||
import io.nekohasekai.sfa.compose.util.ProfileIcons
|
||||
|
||||
@Composable
|
||||
fun IconSelectionDialog(
|
||||
currentIconId: String?,
|
||||
onIconSelected: (String?) -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
fun IconSelectionDialog(currentIconId: String?, onIconSelected: (String?) -> Unit, onDismiss: () -> Unit) {
|
||||
Dialog(onDismissRequest = onDismiss) {
|
||||
Card(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(max = 500.dp),
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(max = 500.dp),
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
) {
|
||||
Column(
|
||||
@@ -65,9 +61,9 @@ fun IconSelectionDialog(
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.weight(1f),
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.weight(1f),
|
||||
) {
|
||||
// Add option to remove custom icon (use default)
|
||||
item {
|
||||
@@ -110,40 +106,35 @@ fun IconSelectionDialog(
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun IconOption(
|
||||
icon: ProfileIcon?,
|
||||
label: String,
|
||||
isSelected: Boolean,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
private fun IconOption(icon: ProfileIcon?, label: String, isSelected: Boolean, onClick: () -> Unit) {
|
||||
Card(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.aspectRatio(1f)
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.clickable { onClick() },
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.aspectRatio(1f)
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.clickable { onClick() },
|
||||
colors =
|
||||
CardDefaults.cardColors(
|
||||
containerColor =
|
||||
if (isSelected) {
|
||||
MaterialTheme.colorScheme.primaryContainer
|
||||
} else {
|
||||
MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)
|
||||
},
|
||||
),
|
||||
border =
|
||||
CardDefaults.cardColors(
|
||||
containerColor =
|
||||
if (isSelected) {
|
||||
CardDefaults.outlinedCardBorder()
|
||||
MaterialTheme.colorScheme.primaryContainer
|
||||
} else {
|
||||
null
|
||||
MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)
|
||||
},
|
||||
),
|
||||
border =
|
||||
if (isSelected) {
|
||||
CardDefaults.outlinedCardBorder()
|
||||
} else {
|
||||
null
|
||||
},
|
||||
) {
|
||||
Column(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.padding(8.dp),
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.padding(8.dp),
|
||||
verticalArrangement = Arrangement.Center,
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
@@ -153,11 +144,11 @@ private fun IconOption(
|
||||
contentDescription = label,
|
||||
modifier = Modifier.size(28.dp),
|
||||
tint =
|
||||
if (isSelected) {
|
||||
MaterialTheme.colorScheme.onPrimaryContainer
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurfaceVariant
|
||||
},
|
||||
if (isSelected) {
|
||||
MaterialTheme.colorScheme.onPrimaryContainer
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurfaceVariant
|
||||
},
|
||||
)
|
||||
} else {
|
||||
// Default icon indicator
|
||||
@@ -165,11 +156,11 @@ private fun IconOption(
|
||||
text = stringResource(R.string.auto),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color =
|
||||
if (isSelected) {
|
||||
MaterialTheme.colorScheme.onPrimaryContainer
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurfaceVariant
|
||||
},
|
||||
if (isSelected) {
|
||||
MaterialTheme.colorScheme.onPrimaryContainer
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurfaceVariant
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -179,11 +170,11 @@ private fun IconOption(
|
||||
text = label,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color =
|
||||
if (isSelected) {
|
||||
MaterialTheme.colorScheme.onPrimaryContainer
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurfaceVariant
|
||||
},
|
||||
if (isSelected) {
|
||||
MaterialTheme.colorScheme.onPrimaryContainer
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurfaceVariant
|
||||
},
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
textAlign = TextAlign.Center,
|
||||
|
||||
@@ -12,7 +12,6 @@ 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
|
||||
@@ -60,6 +59,7 @@ import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.platform.LocalFocusManager
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
@@ -74,11 +74,7 @@ import io.nekohasekai.sfa.compose.util.icons.MaterialIconsLibrary
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun IconSelectionScreen(
|
||||
currentIconId: String?,
|
||||
onIconSelected: (String?) -> Unit,
|
||||
onNavigateBack: () -> Unit,
|
||||
) {
|
||||
fun IconSelectionScreen(currentIconId: String?, onIconSelected: (String?) -> Unit, onNavigateBack: () -> Unit) {
|
||||
var searchQuery by remember { mutableStateOf("") }
|
||||
var selectedCategory by remember { mutableStateOf<String?>(null) }
|
||||
var viewMode by remember { mutableStateOf(IconViewMode.CATEGORIES) }
|
||||
@@ -126,26 +122,26 @@ fun IconSelectionScreen(
|
||||
Icon(
|
||||
imageVector = Icons.Default.Search,
|
||||
contentDescription =
|
||||
if (isSearchActive) {
|
||||
stringResource(R.string.close_search)
|
||||
} else {
|
||||
stringResource(
|
||||
R.string.search_icons,
|
||||
)
|
||||
},
|
||||
if (isSearchActive) {
|
||||
stringResource(R.string.close_search)
|
||||
} else {
|
||||
stringResource(
|
||||
R.string.search_icons,
|
||||
)
|
||||
},
|
||||
tint =
|
||||
if (isSearchActive) {
|
||||
MaterialTheme.colorScheme.primary
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurface
|
||||
},
|
||||
if (isSearchActive) {
|
||||
MaterialTheme.colorScheme.primary
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurface
|
||||
},
|
||||
)
|
||||
}
|
||||
},
|
||||
colors =
|
||||
TopAppBarDefaults.topAppBarColors(
|
||||
containerColor = MaterialTheme.colorScheme.surface,
|
||||
),
|
||||
TopAppBarDefaults.topAppBarColors(
|
||||
containerColor = MaterialTheme.colorScheme.surface,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -167,27 +163,27 @@ fun IconSelectionScreen(
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
Column(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.padding(bottom = bottomBarPadding),
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.padding(bottom = bottomBarPadding),
|
||||
) {
|
||||
// Show search bar with animation
|
||||
AnimatedVisibility(
|
||||
visible = isSearchActive,
|
||||
enter =
|
||||
expandVertically(
|
||||
expandVertically(
|
||||
animationSpec = tween(300),
|
||||
) +
|
||||
fadeIn(
|
||||
animationSpec = tween(300),
|
||||
) +
|
||||
fadeIn(
|
||||
animationSpec = tween(300),
|
||||
),
|
||||
),
|
||||
exit =
|
||||
shrinkVertically(
|
||||
shrinkVertically(
|
||||
animationSpec = tween(300),
|
||||
) +
|
||||
fadeOut(
|
||||
animationSpec = tween(300),
|
||||
) +
|
||||
fadeOut(
|
||||
animationSpec = tween(300),
|
||||
),
|
||||
),
|
||||
) {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
@@ -212,10 +208,10 @@ fun IconSelectionScreen(
|
||||
}
|
||||
},
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(start = 16.dp, end = 16.dp, bottom = 12.dp)
|
||||
.focusRequester(focusRequester),
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(start = 16.dp, end = 16.dp, bottom = 12.dp)
|
||||
.focusRequester(focusRequester),
|
||||
placeholder = { Text(stringResource(R.string.search_icons_placeholder)) },
|
||||
leadingIcon = {
|
||||
Icon(
|
||||
@@ -240,20 +236,20 @@ fun IconSelectionScreen(
|
||||
singleLine = true,
|
||||
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
|
||||
keyboardActions =
|
||||
KeyboardActions(
|
||||
onSearch = {
|
||||
focusManager.clearFocus()
|
||||
},
|
||||
),
|
||||
KeyboardActions(
|
||||
onSearch = {
|
||||
focusManager.clearFocus()
|
||||
},
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.padding(horizontal = 16.dp),
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.padding(horizontal = 16.dp),
|
||||
) {
|
||||
// View mode tabs (only show when not searching)
|
||||
AnimatedVisibility(visible = searchQuery.isEmpty()) {
|
||||
@@ -269,11 +265,11 @@ fun IconSelectionScreen(
|
||||
},
|
||||
label = { Text(stringResource(R.string.categories)) },
|
||||
leadingIcon =
|
||||
if (viewMode == IconViewMode.CATEGORIES && selectedCategory == null) {
|
||||
{ Icon(Icons.Default.Check, contentDescription = null, Modifier.size(16.dp)) }
|
||||
} else {
|
||||
null
|
||||
},
|
||||
if (viewMode == IconViewMode.CATEGORIES && selectedCategory == null) {
|
||||
{ Icon(Icons.Default.Check, contentDescription = null, Modifier.size(16.dp)) }
|
||||
} else {
|
||||
null
|
||||
},
|
||||
)
|
||||
|
||||
FilterChip(
|
||||
@@ -284,11 +280,11 @@ fun IconSelectionScreen(
|
||||
},
|
||||
label = { Text(stringResource(R.string.all_icons)) },
|
||||
leadingIcon =
|
||||
if (viewMode == IconViewMode.ALL) {
|
||||
{ Icon(Icons.Default.Check, contentDescription = null, Modifier.size(16.dp)) }
|
||||
} else {
|
||||
null
|
||||
},
|
||||
if (viewMode == IconViewMode.ALL) {
|
||||
{ Icon(Icons.Default.Check, contentDescription = null, Modifier.size(16.dp)) }
|
||||
} else {
|
||||
null
|
||||
},
|
||||
)
|
||||
|
||||
FilterChip(
|
||||
@@ -329,9 +325,9 @@ fun IconSelectionScreen(
|
||||
// Main content area
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.weight(1f),
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.weight(1f),
|
||||
) {
|
||||
when {
|
||||
// Search results
|
||||
@@ -387,21 +383,21 @@ fun IconSelectionScreen(
|
||||
currentIcon?.let { (id, icon) ->
|
||||
Card(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.align(Alignment.BottomCenter)
|
||||
.windowInsetsPadding(WindowInsets.navigationBars)
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
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),
|
||||
),
|
||||
CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.3f),
|
||||
),
|
||||
) {
|
||||
Row(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(12.dp),
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Icon(
|
||||
@@ -415,10 +411,10 @@ fun IconSelectionScreen(
|
||||
val iconInfo = MaterialIconsLibrary.getAllIcons().find { it.id == id }
|
||||
Text(
|
||||
text =
|
||||
stringResource(
|
||||
R.string.current_icon_format,
|
||||
iconInfo?.label ?: id,
|
||||
),
|
||||
stringResource(
|
||||
R.string.current_icon_format,
|
||||
iconInfo?.label ?: id,
|
||||
),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
MaterialIconsLibrary.getCategoryForIcon(id)?.let { category ->
|
||||
@@ -436,11 +432,7 @@ fun IconSelectionScreen(
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CategoryList(
|
||||
categories: List<IconCategory>,
|
||||
currentIconId: String?,
|
||||
onCategoryClick: (IconCategory) -> Unit,
|
||||
) {
|
||||
private fun CategoryList(categories: List<IconCategory>, currentIconId: String?, onCategoryClick: (IconCategory) -> Unit) {
|
||||
LazyColumn(
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
@@ -456,29 +448,25 @@ private fun CategoryList(
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun CategoryCard(
|
||||
category: IconCategory,
|
||||
hasSelectedIcon: Boolean,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
private fun CategoryCard(category: IconCategory, hasSelectedIcon: Boolean, onClick: () -> Unit) {
|
||||
Card(
|
||||
onClick = onClick,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors =
|
||||
CardDefaults.cardColors(
|
||||
containerColor =
|
||||
if (hasSelectedIcon) {
|
||||
MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.5f)
|
||||
} else {
|
||||
MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)
|
||||
},
|
||||
),
|
||||
CardDefaults.cardColors(
|
||||
containerColor =
|
||||
if (hasSelectedIcon) {
|
||||
MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.5f)
|
||||
} else {
|
||||
MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)
|
||||
},
|
||||
),
|
||||
) {
|
||||
Row(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
) {
|
||||
@@ -517,11 +505,7 @@ private fun CategoryCard(
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun IconGrid(
|
||||
icons: List<ProfileIcon>,
|
||||
currentIconId: String?,
|
||||
onIconClick: (ProfileIcon) -> Unit,
|
||||
) {
|
||||
private fun IconGrid(icons: List<ProfileIcon>, currentIconId: String?, onIconClick: (ProfileIcon) -> Unit) {
|
||||
LazyVerticalGrid(
|
||||
columns = GridCells.Adaptive(minSize = 72.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
@@ -539,38 +523,34 @@ private fun IconGrid(
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun IconGridItem(
|
||||
icon: ProfileIcon,
|
||||
isSelected: Boolean,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
private fun IconGridItem(icon: ProfileIcon, isSelected: Boolean, onClick: () -> Unit) {
|
||||
Card(
|
||||
onClick = onClick,
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.aspectRatio(1f),
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.aspectRatio(1f),
|
||||
colors =
|
||||
CardDefaults.cardColors(
|
||||
containerColor =
|
||||
if (isSelected) {
|
||||
MaterialTheme.colorScheme.primaryContainer
|
||||
} else {
|
||||
MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f)
|
||||
},
|
||||
),
|
||||
border =
|
||||
CardDefaults.cardColors(
|
||||
containerColor =
|
||||
if (isSelected) {
|
||||
CardDefaults.outlinedCardBorder()
|
||||
MaterialTheme.colorScheme.primaryContainer
|
||||
} else {
|
||||
null
|
||||
MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f)
|
||||
},
|
||||
),
|
||||
border =
|
||||
if (isSelected) {
|
||||
CardDefaults.outlinedCardBorder()
|
||||
} else {
|
||||
null
|
||||
},
|
||||
) {
|
||||
Column(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.padding(8.dp),
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.padding(8.dp),
|
||||
verticalArrangement = Arrangement.Center,
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
@@ -579,11 +559,11 @@ private fun IconGridItem(
|
||||
contentDescription = icon.label,
|
||||
modifier = Modifier.size(28.dp),
|
||||
tint =
|
||||
if (isSelected) {
|
||||
MaterialTheme.colorScheme.onPrimaryContainer
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurfaceVariant
|
||||
},
|
||||
if (isSelected) {
|
||||
MaterialTheme.colorScheme.onPrimaryContainer
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurfaceVariant
|
||||
},
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
@@ -592,11 +572,11 @@ private fun IconGridItem(
|
||||
text = icon.label,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color =
|
||||
if (isSelected) {
|
||||
MaterialTheme.colorScheme.onPrimaryContainer
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurfaceVariant
|
||||
},
|
||||
if (isSelected) {
|
||||
MaterialTheme.colorScheme.onPrimaryContainer
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurfaceVariant
|
||||
},
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
textAlign = TextAlign.Center,
|
||||
@@ -610,9 +590,9 @@ private fun IconGridItem(
|
||||
private fun EmptySearchResult(query: String) {
|
||||
Column(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.padding(32.dp),
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.padding(32.dp),
|
||||
verticalArrangement = Arrangement.Center,
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
|
||||
@@ -4,129 +4,129 @@ import android.content.Context;
|
||||
import android.util.AttributeSet;
|
||||
import android.view.MotionEvent;
|
||||
import android.view.ViewConfiguration;
|
||||
|
||||
import com.blacksquircle.ui.editorkit.widget.TextProcessor;
|
||||
|
||||
public class ManualScrollTextProcessor extends TextProcessor {
|
||||
|
||||
private final int touchSlop;
|
||||
private boolean allowCursorAutoScroll = true;
|
||||
private float downX;
|
||||
private float downY;
|
||||
private boolean userDragging;
|
||||
private int downSelectionStart = -1;
|
||||
private int downSelectionEnd = -1;
|
||||
private boolean restoringSelection;
|
||||
private final int touchSlop;
|
||||
private boolean allowCursorAutoScroll = true;
|
||||
private float downX;
|
||||
private float downY;
|
||||
private boolean userDragging;
|
||||
private int downSelectionStart = -1;
|
||||
private int downSelectionEnd = -1;
|
||||
private boolean restoringSelection;
|
||||
|
||||
public ManualScrollTextProcessor(Context context) {
|
||||
this(context, null);
|
||||
public ManualScrollTextProcessor(Context context) {
|
||||
this(context, null);
|
||||
}
|
||||
|
||||
public ManualScrollTextProcessor(Context context, AttributeSet attrs) {
|
||||
this(context, attrs, android.R.attr.autoCompleteTextViewStyle);
|
||||
}
|
||||
|
||||
public ManualScrollTextProcessor(Context context, AttributeSet attrs, int defStyleAttr) {
|
||||
super(context, attrs, defStyleAttr);
|
||||
touchSlop = ViewConfiguration.get(context).getScaledTouchSlop();
|
||||
}
|
||||
|
||||
public void resumeAutoScroll() {
|
||||
allowCursorAutoScroll = true;
|
||||
userDragging = false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean bringPointIntoView(int offset) {
|
||||
if (allowCursorAutoScroll) {
|
||||
return super.bringPointIntoView(offset);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
public ManualScrollTextProcessor(Context context, AttributeSet attrs) {
|
||||
this(context, attrs, android.R.attr.autoCompleteTextViewStyle);
|
||||
}
|
||||
|
||||
public ManualScrollTextProcessor(Context context, AttributeSet attrs, int defStyleAttr) {
|
||||
super(context, attrs, defStyleAttr);
|
||||
touchSlop = ViewConfiguration.get(context).getScaledTouchSlop();
|
||||
}
|
||||
|
||||
public void resumeAutoScroll() {
|
||||
allowCursorAutoScroll = true;
|
||||
@Override
|
||||
public boolean onTouchEvent(MotionEvent event) {
|
||||
int action = event.getActionMasked();
|
||||
switch (action) {
|
||||
case MotionEvent.ACTION_DOWN:
|
||||
downX = event.getX();
|
||||
downY = event.getY();
|
||||
userDragging = false;
|
||||
restoringSelection = false;
|
||||
downSelectionStart = getSelectionStart();
|
||||
downSelectionEnd = getSelectionEnd();
|
||||
break;
|
||||
case MotionEvent.ACTION_MOVE:
|
||||
if (!userDragging) {
|
||||
float dx = Math.abs(event.getX() - downX);
|
||||
float dy = Math.abs(event.getY() - downY);
|
||||
if (dx > touchSlop || dy > touchSlop) {
|
||||
userDragging = true;
|
||||
allowCursorAutoScroll = false;
|
||||
}
|
||||
}
|
||||
break;
|
||||
case MotionEvent.ACTION_UP:
|
||||
case MotionEvent.ACTION_CANCEL:
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean bringPointIntoView(int offset) {
|
||||
if (allowCursorAutoScroll) {
|
||||
return super.bringPointIntoView(offset);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean onTouchEvent(MotionEvent event) {
|
||||
int action = event.getActionMasked();
|
||||
switch (action) {
|
||||
case MotionEvent.ACTION_DOWN:
|
||||
downX = event.getX();
|
||||
downY = event.getY();
|
||||
userDragging = false;
|
||||
restoringSelection = false;
|
||||
downSelectionStart = getSelectionStart();
|
||||
downSelectionEnd = getSelectionEnd();
|
||||
break;
|
||||
case MotionEvent.ACTION_MOVE:
|
||||
if (!userDragging) {
|
||||
float dx = Math.abs(event.getX() - downX);
|
||||
float dy = Math.abs(event.getY() - downY);
|
||||
if (dx > touchSlop || dy > touchSlop) {
|
||||
userDragging = true;
|
||||
allowCursorAutoScroll = false;
|
||||
}
|
||||
}
|
||||
break;
|
||||
case MotionEvent.ACTION_UP:
|
||||
case MotionEvent.ACTION_CANCEL:
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
boolean handled = super.onTouchEvent(event);
|
||||
|
||||
switch (action) {
|
||||
case MotionEvent.ACTION_MOVE:
|
||||
if (userDragging) {
|
||||
maybeRestoreSelection();
|
||||
}
|
||||
break;
|
||||
case MotionEvent.ACTION_UP:
|
||||
case MotionEvent.ACTION_CANCEL:
|
||||
if (userDragging) {
|
||||
maybeRestoreSelection();
|
||||
} else {
|
||||
resumeAutoScroll();
|
||||
}
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
return handled;
|
||||
}
|
||||
|
||||
private void maybeRestoreSelection() {
|
||||
if (userDragging && !restoringSelection) {
|
||||
int selStart = getSelectionStart();
|
||||
int selEnd = getSelectionEnd();
|
||||
if (selStart != downSelectionStart || selEnd != downSelectionEnd) {
|
||||
restoringSelection = true;
|
||||
int targetEnd = downSelectionEnd >= 0 ? downSelectionEnd : downSelectionStart;
|
||||
setSelection(downSelectionStart, targetEnd);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onSelectionChanged(int selStart, int selEnd) {
|
||||
if (restoringSelection) {
|
||||
restoringSelection = false;
|
||||
super.onSelectionChanged(selStart, selEnd);
|
||||
return;
|
||||
}
|
||||
boolean handled = super.onTouchEvent(event);
|
||||
|
||||
switch (action) {
|
||||
case MotionEvent.ACTION_MOVE:
|
||||
if (userDragging) {
|
||||
if (downSelectionStart >= 0 && (selStart != downSelectionStart || selEnd != downSelectionEnd)) {
|
||||
restoringSelection = true;
|
||||
int targetEnd = downSelectionEnd >= 0 ? downSelectionEnd : downSelectionStart;
|
||||
setSelection(downSelectionStart, targetEnd);
|
||||
return;
|
||||
}
|
||||
maybeRestoreSelection();
|
||||
}
|
||||
|
||||
downSelectionStart = selStart;
|
||||
downSelectionEnd = selEnd;
|
||||
super.onSelectionChanged(selStart, selEnd);
|
||||
break;
|
||||
case MotionEvent.ACTION_UP:
|
||||
case MotionEvent.ACTION_CANCEL:
|
||||
if (userDragging) {
|
||||
maybeRestoreSelection();
|
||||
} else {
|
||||
resumeAutoScroll();
|
||||
}
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
return handled;
|
||||
}
|
||||
|
||||
private void maybeRestoreSelection() {
|
||||
if (userDragging && !restoringSelection) {
|
||||
int selStart = getSelectionStart();
|
||||
int selEnd = getSelectionEnd();
|
||||
if (selStart != downSelectionStart || selEnd != downSelectionEnd) {
|
||||
restoringSelection = true;
|
||||
int targetEnd = downSelectionEnd >= 0 ? downSelectionEnd : downSelectionStart;
|
||||
setSelection(downSelectionStart, targetEnd);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onSelectionChanged(int selStart, int selEnd) {
|
||||
if (restoringSelection) {
|
||||
restoringSelection = false;
|
||||
super.onSelectionChanged(selStart, selEnd);
|
||||
return;
|
||||
}
|
||||
|
||||
if (userDragging) {
|
||||
if (downSelectionStart >= 0
|
||||
&& (selStart != downSelectionStart || selEnd != downSelectionEnd)) {
|
||||
restoringSelection = true;
|
||||
int targetEnd = downSelectionEnd >= 0 ? downSelectionEnd : downSelectionStart;
|
||||
setSelection(downSelectionStart, targetEnd);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
downSelectionStart = selStart;
|
||||
downSelectionEnd = selEnd;
|
||||
super.onSelectionChanged(selStart, selEnd);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,9 +6,9 @@ import android.os.Build
|
||||
import android.util.Log
|
||||
import android.widget.Toast
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.expandVertically
|
||||
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
|
||||
@@ -54,7 +54,6 @@ 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
|
||||
@@ -66,9 +65,7 @@ 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
|
||||
@@ -79,13 +76,13 @@ 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.compose.topbar.OverrideTopBar
|
||||
import io.nekohasekai.sfa.database.Settings
|
||||
import io.nekohasekai.sfa.ktx.clipboardText
|
||||
import io.nekohasekai.sfa.vendor.PackageQueryManager
|
||||
import io.nekohasekai.sfa.vendor.PrivilegedAccessRequiredException
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
@@ -97,16 +94,9 @@ 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<PackageCache>,
|
||||
val selectedUids: Set<Int>,
|
||||
)
|
||||
private data class LoadResult(val proxyMode: Int, val packages: List<PackageCache>, val selectedUids: Set<Int>)
|
||||
|
||||
private data class ScanProgress(
|
||||
val current: Int,
|
||||
val max: Int,
|
||||
)
|
||||
private data class ScanProgress(val current: Int, val max: Int)
|
||||
|
||||
private sealed class ScanResult {
|
||||
data object Empty : ScanResult()
|
||||
@@ -139,11 +129,9 @@ fun PerAppProxyScreen(onBack: () -> Unit) {
|
||||
var scanProgress by remember { mutableStateOf<ScanProgress?>(null) }
|
||||
var scanResult by remember { mutableStateOf<ScanResult?>(null) }
|
||||
|
||||
fun buildPackageList(newUids: Set<Int>): Set<String> {
|
||||
return newUids.mapNotNull { uid ->
|
||||
packages.find { it.uid == uid }?.packageName
|
||||
}.toSet()
|
||||
}
|
||||
fun buildPackageList(newUids: Set<Int>): Set<String> = newUids.mapNotNull { uid ->
|
||||
packages.find { it.uid == uid }?.packageName
|
||||
}.toSet()
|
||||
|
||||
fun updateCurrentPackages(filterQuery: String) {
|
||||
currentPackages =
|
||||
@@ -411,10 +399,10 @@ fun PerAppProxyScreen(onBack: () -> Unit) {
|
||||
)
|
||||
},
|
||||
colors =
|
||||
TopAppBarDefaults.topAppBarColors(
|
||||
containerColor = MaterialTheme.colorScheme.surface,
|
||||
titleContentColor = MaterialTheme.colorScheme.onSurface,
|
||||
),
|
||||
TopAppBarDefaults.topAppBarColors(
|
||||
containerColor = MaterialTheme.colorScheme.surface,
|
||||
titleContentColor = MaterialTheme.colorScheme.onSurface,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -435,11 +423,11 @@ fun PerAppProxyScreen(onBack: () -> Unit) {
|
||||
) {
|
||||
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)
|
||||
},
|
||||
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,
|
||||
)
|
||||
@@ -465,10 +453,10 @@ fun PerAppProxyScreen(onBack: () -> Unit) {
|
||||
updateCurrentPackages(it)
|
||||
},
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp)
|
||||
.focusRequester(focusRequester),
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp)
|
||||
.focusRequester(focusRequester),
|
||||
placeholder = { Text(stringResource(R.string.search)) },
|
||||
leadingIcon = {
|
||||
Icon(
|
||||
@@ -497,10 +485,10 @@ fun PerAppProxyScreen(onBack: () -> Unit) {
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentPadding =
|
||||
androidx.compose.foundation.layout.PaddingValues(
|
||||
horizontal = 16.dp,
|
||||
vertical = 12.dp,
|
||||
),
|
||||
androidx.compose.foundation.layout.PaddingValues(
|
||||
horizontal = 16.dp,
|
||||
vertical = 12.dp,
|
||||
),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
items(currentPackages, key = { it.packageName }) { packageCache ->
|
||||
@@ -609,10 +597,10 @@ fun PerAppProxyScreen(onBack: () -> Unit) {
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(max = 360.dp)
|
||||
.verticalScroll(rememberScrollState()),
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(max = 360.dp)
|
||||
.verticalScroll(rememberScrollState()),
|
||||
) {
|
||||
Text(
|
||||
text = dialogContent,
|
||||
@@ -722,11 +710,11 @@ private fun PerAppProxyMenus(
|
||||
trailingIcon = {
|
||||
Icon(
|
||||
imageVector =
|
||||
if (showModeMenu) {
|
||||
Icons.Default.ExpandLess
|
||||
} else {
|
||||
Icons.Default.ExpandMore
|
||||
},
|
||||
if (showModeMenu) {
|
||||
Icons.Default.ExpandLess
|
||||
} else {
|
||||
Icons.Default.ExpandMore
|
||||
},
|
||||
contentDescription = null,
|
||||
)
|
||||
},
|
||||
@@ -742,18 +730,18 @@ private fun PerAppProxyMenus(
|
||||
leadingIcon = {
|
||||
Icon(
|
||||
imageVector =
|
||||
if (proxyMode == Settings.PER_APP_PROXY_INCLUDE) {
|
||||
Icons.Default.RadioButtonChecked
|
||||
} else {
|
||||
Icons.Default.RadioButtonUnchecked
|
||||
},
|
||||
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
|
||||
},
|
||||
if (proxyMode == Settings.PER_APP_PROXY_INCLUDE) {
|
||||
MaterialTheme.colorScheme.primary
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurfaceVariant
|
||||
},
|
||||
modifier = Modifier.padding(start = 24.dp),
|
||||
)
|
||||
},
|
||||
@@ -768,18 +756,18 @@ private fun PerAppProxyMenus(
|
||||
leadingIcon = {
|
||||
Icon(
|
||||
imageVector =
|
||||
if (proxyMode == Settings.PER_APP_PROXY_EXCLUDE) {
|
||||
Icons.Default.RadioButtonChecked
|
||||
} else {
|
||||
Icons.Default.RadioButtonUnchecked
|
||||
},
|
||||
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
|
||||
},
|
||||
if (proxyMode == Settings.PER_APP_PROXY_EXCLUDE) {
|
||||
MaterialTheme.colorScheme.primary
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurfaceVariant
|
||||
},
|
||||
modifier = Modifier.padding(start = 24.dp),
|
||||
)
|
||||
},
|
||||
@@ -799,11 +787,11 @@ private fun PerAppProxyMenus(
|
||||
trailingIcon = {
|
||||
Icon(
|
||||
imageVector =
|
||||
if (showSortMenu) {
|
||||
Icons.Default.ExpandLess
|
||||
} else {
|
||||
Icons.Default.ExpandMore
|
||||
},
|
||||
if (showSortMenu) {
|
||||
Icons.Default.ExpandLess
|
||||
} else {
|
||||
Icons.Default.ExpandMore
|
||||
},
|
||||
contentDescription = null,
|
||||
)
|
||||
},
|
||||
@@ -819,18 +807,18 @@ private fun PerAppProxyMenus(
|
||||
leadingIcon = {
|
||||
Icon(
|
||||
imageVector =
|
||||
if (sortMode == SortMode.NAME) {
|
||||
Icons.Default.RadioButtonChecked
|
||||
} else {
|
||||
Icons.Default.RadioButtonUnchecked
|
||||
},
|
||||
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
|
||||
},
|
||||
if (sortMode == SortMode.NAME) {
|
||||
MaterialTheme.colorScheme.primary
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurfaceVariant
|
||||
},
|
||||
modifier = Modifier.padding(start = 24.dp),
|
||||
)
|
||||
},
|
||||
@@ -845,18 +833,18 @@ private fun PerAppProxyMenus(
|
||||
leadingIcon = {
|
||||
Icon(
|
||||
imageVector =
|
||||
if (sortMode == SortMode.PACKAGE_NAME) {
|
||||
Icons.Default.RadioButtonChecked
|
||||
} else {
|
||||
Icons.Default.RadioButtonUnchecked
|
||||
},
|
||||
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
|
||||
},
|
||||
if (sortMode == SortMode.PACKAGE_NAME) {
|
||||
MaterialTheme.colorScheme.primary
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurfaceVariant
|
||||
},
|
||||
modifier = Modifier.padding(start = 24.dp),
|
||||
)
|
||||
},
|
||||
@@ -871,18 +859,18 @@ private fun PerAppProxyMenus(
|
||||
leadingIcon = {
|
||||
Icon(
|
||||
imageVector =
|
||||
if (sortMode == SortMode.UID) {
|
||||
Icons.Default.RadioButtonChecked
|
||||
} else {
|
||||
Icons.Default.RadioButtonUnchecked
|
||||
},
|
||||
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
|
||||
},
|
||||
if (sortMode == SortMode.UID) {
|
||||
MaterialTheme.colorScheme.primary
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurfaceVariant
|
||||
},
|
||||
modifier = Modifier.padding(start = 24.dp),
|
||||
)
|
||||
},
|
||||
@@ -897,18 +885,18 @@ private fun PerAppProxyMenus(
|
||||
leadingIcon = {
|
||||
Icon(
|
||||
imageVector =
|
||||
if (sortMode == SortMode.INSTALL_TIME) {
|
||||
Icons.Default.RadioButtonChecked
|
||||
} else {
|
||||
Icons.Default.RadioButtonUnchecked
|
||||
},
|
||||
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
|
||||
},
|
||||
if (sortMode == SortMode.INSTALL_TIME) {
|
||||
MaterialTheme.colorScheme.primary
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurfaceVariant
|
||||
},
|
||||
modifier = Modifier.padding(start = 24.dp),
|
||||
)
|
||||
},
|
||||
@@ -923,18 +911,18 @@ private fun PerAppProxyMenus(
|
||||
leadingIcon = {
|
||||
Icon(
|
||||
imageVector =
|
||||
if (sortMode == SortMode.UPDATE_TIME) {
|
||||
Icons.Default.RadioButtonChecked
|
||||
} else {
|
||||
Icons.Default.RadioButtonUnchecked
|
||||
},
|
||||
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
|
||||
},
|
||||
if (sortMode == SortMode.UPDATE_TIME) {
|
||||
MaterialTheme.colorScheme.primary
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurfaceVariant
|
||||
},
|
||||
modifier = Modifier.padding(start = 24.dp),
|
||||
)
|
||||
},
|
||||
@@ -949,18 +937,18 @@ private fun PerAppProxyMenus(
|
||||
leadingIcon = {
|
||||
Icon(
|
||||
imageVector =
|
||||
if (sortReverse) {
|
||||
Icons.Default.Check
|
||||
} else {
|
||||
Icons.Default.RadioButtonUnchecked
|
||||
},
|
||||
if (sortReverse) {
|
||||
Icons.Default.Check
|
||||
} else {
|
||||
Icons.Default.RadioButtonUnchecked
|
||||
},
|
||||
contentDescription = null,
|
||||
tint =
|
||||
if (sortReverse) {
|
||||
MaterialTheme.colorScheme.primary
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurfaceVariant
|
||||
},
|
||||
if (sortReverse) {
|
||||
MaterialTheme.colorScheme.primary
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurfaceVariant
|
||||
},
|
||||
modifier = Modifier.padding(start = 24.dp),
|
||||
)
|
||||
},
|
||||
@@ -980,11 +968,11 @@ private fun PerAppProxyMenus(
|
||||
trailingIcon = {
|
||||
Icon(
|
||||
imageVector =
|
||||
if (showFilterMenu) {
|
||||
Icons.Default.ExpandLess
|
||||
} else {
|
||||
Icons.Default.ExpandMore
|
||||
},
|
||||
if (showFilterMenu) {
|
||||
Icons.Default.ExpandLess
|
||||
} else {
|
||||
Icons.Default.ExpandMore
|
||||
},
|
||||
contentDescription = null,
|
||||
)
|
||||
},
|
||||
@@ -1000,18 +988,18 @@ private fun PerAppProxyMenus(
|
||||
leadingIcon = {
|
||||
Icon(
|
||||
imageVector =
|
||||
if (hideSystemApps) {
|
||||
Icons.Default.Check
|
||||
} else {
|
||||
Icons.Default.RadioButtonUnchecked
|
||||
},
|
||||
if (hideSystemApps) {
|
||||
Icons.Default.Check
|
||||
} else {
|
||||
Icons.Default.RadioButtonUnchecked
|
||||
},
|
||||
contentDescription = null,
|
||||
tint =
|
||||
if (hideSystemApps) {
|
||||
MaterialTheme.colorScheme.primary
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurfaceVariant
|
||||
},
|
||||
if (hideSystemApps) {
|
||||
MaterialTheme.colorScheme.primary
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurfaceVariant
|
||||
},
|
||||
modifier = Modifier.padding(start = 24.dp),
|
||||
)
|
||||
},
|
||||
@@ -1026,18 +1014,18 @@ private fun PerAppProxyMenus(
|
||||
leadingIcon = {
|
||||
Icon(
|
||||
imageVector =
|
||||
if (hideOfflineApps) {
|
||||
Icons.Default.Check
|
||||
} else {
|
||||
Icons.Default.RadioButtonUnchecked
|
||||
},
|
||||
if (hideOfflineApps) {
|
||||
Icons.Default.Check
|
||||
} else {
|
||||
Icons.Default.RadioButtonUnchecked
|
||||
},
|
||||
contentDescription = null,
|
||||
tint =
|
||||
if (hideOfflineApps) {
|
||||
MaterialTheme.colorScheme.primary
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurfaceVariant
|
||||
},
|
||||
if (hideOfflineApps) {
|
||||
MaterialTheme.colorScheme.primary
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurfaceVariant
|
||||
},
|
||||
modifier = Modifier.padding(start = 24.dp),
|
||||
)
|
||||
},
|
||||
@@ -1052,18 +1040,18 @@ private fun PerAppProxyMenus(
|
||||
leadingIcon = {
|
||||
Icon(
|
||||
imageVector =
|
||||
if (hideDisabledApps) {
|
||||
Icons.Default.Check
|
||||
} else {
|
||||
Icons.Default.RadioButtonUnchecked
|
||||
},
|
||||
if (hideDisabledApps) {
|
||||
Icons.Default.Check
|
||||
} else {
|
||||
Icons.Default.RadioButtonUnchecked
|
||||
},
|
||||
contentDescription = null,
|
||||
tint =
|
||||
if (hideDisabledApps) {
|
||||
MaterialTheme.colorScheme.primary
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurfaceVariant
|
||||
},
|
||||
if (hideDisabledApps) {
|
||||
MaterialTheme.colorScheme.primary
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurfaceVariant
|
||||
},
|
||||
modifier = Modifier.padding(start = 24.dp),
|
||||
)
|
||||
},
|
||||
@@ -1083,11 +1071,11 @@ private fun PerAppProxyMenus(
|
||||
trailingIcon = {
|
||||
Icon(
|
||||
imageVector =
|
||||
if (showSelectMenu) {
|
||||
Icons.Default.ExpandLess
|
||||
} else {
|
||||
Icons.Default.ExpandMore
|
||||
},
|
||||
if (showSelectMenu) {
|
||||
Icons.Default.ExpandLess
|
||||
} else {
|
||||
Icons.Default.ExpandMore
|
||||
},
|
||||
contentDescription = null,
|
||||
)
|
||||
},
|
||||
@@ -1140,11 +1128,11 @@ private fun PerAppProxyMenus(
|
||||
trailingIcon = {
|
||||
Icon(
|
||||
imageVector =
|
||||
if (showBackupMenu) {
|
||||
Icons.Default.ExpandLess
|
||||
} else {
|
||||
Icons.Default.ExpandMore
|
||||
},
|
||||
if (showBackupMenu) {
|
||||
Icons.Default.ExpandLess
|
||||
} else {
|
||||
Icons.Default.ExpandMore
|
||||
},
|
||||
contentDescription = null,
|
||||
)
|
||||
},
|
||||
@@ -1197,11 +1185,11 @@ private fun PerAppProxyMenus(
|
||||
trailingIcon = {
|
||||
Icon(
|
||||
imageVector =
|
||||
if (showScanMenu) {
|
||||
Icons.Default.ExpandLess
|
||||
} else {
|
||||
Icons.Default.ExpandMore
|
||||
},
|
||||
if (showScanMenu) {
|
||||
Icons.Default.ExpandLess
|
||||
} else {
|
||||
Icons.Default.ExpandMore
|
||||
},
|
||||
contentDescription = null,
|
||||
)
|
||||
},
|
||||
@@ -1331,7 +1319,7 @@ object PerAppProxyScanner {
|
||||
if (!(
|
||||
packageEntry.name.startsWith("classes") &&
|
||||
packageEntry.name.endsWith(".dex")
|
||||
)
|
||||
)
|
||||
) {
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -14,12 +14,7 @@ data class QRCodeCropArea(
|
||||
)
|
||||
|
||||
object QRCodeSmartCrop {
|
||||
fun findCropArea(
|
||||
yData: ByteArray,
|
||||
width: Int,
|
||||
height: Int,
|
||||
rotationDegrees: Int,
|
||||
): QRCodeCropArea? {
|
||||
fun findCropArea(yData: ByteArray, width: Int, height: Int, rotationDegrees: Int): QRCodeCropArea? {
|
||||
val minDim = min(width, height)
|
||||
if (minDim <= 0) return null
|
||||
|
||||
@@ -94,14 +89,7 @@ object QRCodeSmartCrop {
|
||||
return bestArea
|
||||
}
|
||||
|
||||
private data class CropComponent(
|
||||
val minX: Int,
|
||||
val minY: Int,
|
||||
val maxX: Int,
|
||||
val maxY: Int,
|
||||
val count: Int,
|
||||
val score: Float,
|
||||
)
|
||||
private data class CropComponent(val minX: Int, val minY: Int, val maxX: Int, val maxY: Int, val count: Int, val score: Float)
|
||||
|
||||
private fun findBestComponent(
|
||||
yData: ByteArray,
|
||||
@@ -233,13 +221,7 @@ object QRCodeSmartCrop {
|
||||
return best
|
||||
}
|
||||
|
||||
private fun buildCropArea(
|
||||
component: CropComponent,
|
||||
step: Int,
|
||||
width: Int,
|
||||
height: Int,
|
||||
rotationDegrees: Int,
|
||||
): QRCodeCropArea? {
|
||||
private fun buildCropArea(component: CropComponent, step: Int, width: Int, height: Int, rotationDegrees: Int): QRCodeCropArea? {
|
||||
val left = component.minX * step
|
||||
val top = component.minY * step
|
||||
val right = min(width, (component.maxX + 1) * step)
|
||||
|
||||
@@ -83,7 +83,7 @@ class QRScanViewModel(application: Application) : AndroidViewModel(application)
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
vendorAnalyzerAvailable = vendorAnalyzer != null,
|
||||
useVendorAnalyzer = vendorAnalyzer != null
|
||||
useVendorAnalyzer = vendorAnalyzer != null,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -196,7 +196,7 @@ class QRScanViewModel(application: Application) : AndroidViewModel(application)
|
||||
lifecycleOwner,
|
||||
cameraSelector,
|
||||
preview,
|
||||
analysis
|
||||
analysis,
|
||||
)
|
||||
val maxZoom = camera?.cameraInfo?.zoomState?.value?.maxZoomRatio ?: 1f
|
||||
_uiState.update { it.copy(maxZoomRatio = maxZoom, zoomRatio = 1f) }
|
||||
|
||||
@@ -109,12 +109,10 @@ class ZxingQRCodeAnalyzer(
|
||||
return yData
|
||||
}
|
||||
|
||||
private fun tryDecode(bitmap: BinaryBitmap): Result? {
|
||||
return try {
|
||||
qrCodeReader.decode(bitmap)
|
||||
} catch (_: NotFoundException) {
|
||||
qrCodeReader.reset()
|
||||
null
|
||||
}
|
||||
private fun tryDecode(bitmap: BinaryBitmap): Result? = try {
|
||||
qrCodeReader.decode(bitmap)
|
||||
} catch (_: NotFoundException) {
|
||||
qrCodeReader.reset()
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.util.Log
|
||||
import android.provider.Settings as AndroidSettings
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Column
|
||||
@@ -29,7 +28,6 @@ import androidx.compose.material.icons.outlined.NewReleases
|
||||
import androidx.compose.material.icons.outlined.Refresh
|
||||
import androidx.compose.material.icons.outlined.Settings
|
||||
import androidx.compose.material.icons.outlined.SystemUpdateAlt
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Badge
|
||||
import androidx.compose.material3.Card
|
||||
@@ -42,6 +40,7 @@ import androidx.compose.material3.ListItem
|
||||
import androidx.compose.material3.ListItemDefaults
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
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
|
||||
@@ -53,8 +52,6 @@ import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.compose.LifecycleEventEffect
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
@@ -62,22 +59,25 @@ 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.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.compose.LifecycleEventEffect
|
||||
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.compose.topbar.OverrideTopBar
|
||||
import io.nekohasekai.sfa.database.Settings
|
||||
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.vendor.Vendor
|
||||
import io.nekohasekai.sfa.xposed.XposedActivation
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import android.provider.Settings as AndroidSettings
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
@@ -136,10 +136,12 @@ fun AppSettingsScreen(navController: NavController) {
|
||||
isMethodAvailable = success
|
||||
silentInstallError = if (success) {
|
||||
null
|
||||
} else when (silentInstallMethod) {
|
||||
"PACKAGE_INSTALLER" -> context.getString(R.string.package_installer_not_available)
|
||||
"SHIZUKU" -> context.getString(R.string.shizuku_not_available)
|
||||
else -> context.getString(R.string.silent_install_verify_failed, silentInstallMethod)
|
||||
} else {
|
||||
when (silentInstallMethod) {
|
||||
"PACKAGE_INSTALLER" -> context.getString(R.string.package_installer_not_available)
|
||||
"SHIZUKU" -> context.getString(R.string.shizuku_not_available)
|
||||
else -> context.getString(R.string.silent_install_verify_failed, silentInstallMethod)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -224,10 +226,12 @@ fun AppSettingsScreen(navController: NavController) {
|
||||
isMethodAvailable = success
|
||||
silentInstallError = if (success) {
|
||||
null
|
||||
} else when (method) {
|
||||
"PACKAGE_INSTALLER" -> context.getString(R.string.package_installer_not_available)
|
||||
"SHIZUKU" -> context.getString(R.string.shizuku_not_available)
|
||||
else -> context.getString(R.string.silent_install_verify_failed, method)
|
||||
} else {
|
||||
when (method) {
|
||||
"PACKAGE_INSTALLER" -> context.getString(R.string.package_installer_not_available)
|
||||
"SHIZUKU" -> context.getString(R.string.shizuku_not_available)
|
||||
else -> context.getString(R.string.silent_install_verify_failed, method)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -259,22 +263,22 @@ fun AppSettingsScreen(navController: NavController) {
|
||||
|
||||
Column(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.background(MaterialTheme.colorScheme.surface)
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(vertical = 8.dp),
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.background(MaterialTheme.colorScheme.surface)
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(vertical = 8.dp),
|
||||
) {
|
||||
// Info Card
|
||||
Card(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp),
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp),
|
||||
colors =
|
||||
CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceContainer,
|
||||
),
|
||||
CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceContainer,
|
||||
),
|
||||
) {
|
||||
Column {
|
||||
ListItem(
|
||||
@@ -303,12 +307,12 @@ fun AppSettingsScreen(navController: NavController) {
|
||||
}
|
||||
},
|
||||
modifier =
|
||||
Modifier
|
||||
.clip(RoundedCornerShape(12.dp)),
|
||||
Modifier
|
||||
.clip(RoundedCornerShape(12.dp)),
|
||||
colors =
|
||||
ListItemDefaults.colors(
|
||||
containerColor = Color.Transparent,
|
||||
),
|
||||
ListItemDefaults.colors(
|
||||
containerColor = Color.Transparent,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -324,13 +328,13 @@ fun AppSettingsScreen(navController: NavController) {
|
||||
|
||||
Card(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp),
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp),
|
||||
colors =
|
||||
CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceContainer,
|
||||
),
|
||||
CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceContainer,
|
||||
),
|
||||
) {
|
||||
Column {
|
||||
val updateItemCount =
|
||||
@@ -393,12 +397,12 @@ fun AppSettingsScreen(navController: NavController) {
|
||||
)
|
||||
},
|
||||
modifier =
|
||||
updateItemModifier()
|
||||
.clickable { showTrackDialog = true },
|
||||
updateItemModifier()
|
||||
.clickable { showTrackDialog = true },
|
||||
colors =
|
||||
ListItemDefaults.colors(
|
||||
containerColor = Color.Transparent,
|
||||
),
|
||||
ListItemDefaults.colors(
|
||||
containerColor = Color.Transparent,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -429,9 +433,9 @@ fun AppSettingsScreen(navController: NavController) {
|
||||
},
|
||||
modifier = updateItemModifier(),
|
||||
colors =
|
||||
ListItemDefaults.colors(
|
||||
containerColor = Color.Transparent,
|
||||
),
|
||||
ListItemDefaults.colors(
|
||||
containerColor = Color.Transparent,
|
||||
),
|
||||
)
|
||||
|
||||
if (Vendor.supportsSilentInstall()) {
|
||||
@@ -478,10 +482,12 @@ fun AppSettingsScreen(navController: NavController) {
|
||||
isMethodAvailable = success
|
||||
silentInstallError = if (success) {
|
||||
null
|
||||
} else when (silentInstallMethod) {
|
||||
"PACKAGE_INSTALLER" -> context.getString(R.string.package_installer_not_available)
|
||||
"SHIZUKU" -> context.getString(R.string.shizuku_not_available)
|
||||
else -> context.getString(R.string.silent_install_verify_failed, silentInstallMethod)
|
||||
} else {
|
||||
when (silentInstallMethod) {
|
||||
"PACKAGE_INSTALLER" -> context.getString(R.string.package_installer_not_available)
|
||||
"SHIZUKU" -> context.getString(R.string.shizuku_not_available)
|
||||
else -> context.getString(R.string.silent_install_verify_failed, silentInstallMethod)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
@@ -493,9 +499,9 @@ fun AppSettingsScreen(navController: NavController) {
|
||||
},
|
||||
modifier = updateItemModifier(),
|
||||
colors =
|
||||
ListItemDefaults.colors(
|
||||
containerColor = Color.Transparent,
|
||||
),
|
||||
ListItemDefaults.colors(
|
||||
containerColor = Color.Transparent,
|
||||
),
|
||||
)
|
||||
|
||||
if (silentInstallEnabled) {
|
||||
@@ -510,11 +516,13 @@ fun AppSettingsScreen(navController: NavController) {
|
||||
Text(
|
||||
if (xposedActivated) {
|
||||
stringResource(R.string.install_method_root)
|
||||
} else when (silentInstallMethod) {
|
||||
"PACKAGE_INSTALLER" -> stringResource(R.string.install_method_package_installer)
|
||||
"SHIZUKU" -> stringResource(R.string.install_method_shizuku)
|
||||
"ROOT" -> stringResource(R.string.install_method_root)
|
||||
else -> silentInstallMethod
|
||||
} else {
|
||||
when (silentInstallMethod) {
|
||||
"PACKAGE_INSTALLER" -> stringResource(R.string.install_method_package_installer)
|
||||
"SHIZUKU" -> stringResource(R.string.install_method_shizuku)
|
||||
"ROOT" -> stringResource(R.string.install_method_root)
|
||||
else -> silentInstallMethod
|
||||
}
|
||||
},
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
@@ -527,12 +535,12 @@ fun AppSettingsScreen(navController: NavController) {
|
||||
)
|
||||
},
|
||||
modifier =
|
||||
updateItemModifier()
|
||||
.let { if (!xposedActivated) it.clickable { showInstallMethodMenu = true } else it },
|
||||
updateItemModifier()
|
||||
.let { if (!xposedActivated) it.clickable { showInstallMethodMenu = true } else it },
|
||||
colors =
|
||||
ListItemDefaults.colors(
|
||||
containerColor = Color.Transparent,
|
||||
),
|
||||
ListItemDefaults.colors(
|
||||
containerColor = Color.Transparent,
|
||||
),
|
||||
)
|
||||
|
||||
if (silentInstallMethod == "SHIZUKU" && !isMethodAvailable) {
|
||||
@@ -558,15 +566,15 @@ fun AppSettingsScreen(navController: NavController) {
|
||||
)
|
||||
},
|
||||
modifier =
|
||||
updateItemModifier()
|
||||
.clickable {
|
||||
val intent = Intent(Intent.ACTION_VIEW, Uri.parse("https://shizuku.rikka.app/"))
|
||||
context.startActivity(intent)
|
||||
},
|
||||
updateItemModifier()
|
||||
.clickable {
|
||||
val intent = Intent(Intent.ACTION_VIEW, Uri.parse("https://shizuku.rikka.app/"))
|
||||
context.startActivity(intent)
|
||||
},
|
||||
colors =
|
||||
ListItemDefaults.colors(
|
||||
containerColor = Color.Transparent,
|
||||
),
|
||||
ListItemDefaults.colors(
|
||||
containerColor = Color.Transparent,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -593,18 +601,18 @@ fun AppSettingsScreen(navController: NavController) {
|
||||
)
|
||||
},
|
||||
modifier =
|
||||
updateItemModifier()
|
||||
.clickable {
|
||||
val intent = Intent(
|
||||
AndroidSettings.ACTION_MANAGE_UNKNOWN_APP_SOURCES,
|
||||
Uri.parse("package:${context.packageName}")
|
||||
)
|
||||
context.startActivity(intent)
|
||||
},
|
||||
updateItemModifier()
|
||||
.clickable {
|
||||
val intent = Intent(
|
||||
AndroidSettings.ACTION_MANAGE_UNKNOWN_APP_SOURCES,
|
||||
Uri.parse("package:${context.packageName}"),
|
||||
)
|
||||
context.startActivity(intent)
|
||||
},
|
||||
colors =
|
||||
ListItemDefaults.colors(
|
||||
containerColor = Color.Transparent,
|
||||
),
|
||||
ListItemDefaults.colors(
|
||||
containerColor = Color.Transparent,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -646,9 +654,9 @@ fun AppSettingsScreen(navController: NavController) {
|
||||
},
|
||||
modifier = updateItemModifier(),
|
||||
colors =
|
||||
ListItemDefaults.colors(
|
||||
containerColor = Color.Transparent,
|
||||
),
|
||||
ListItemDefaults.colors(
|
||||
containerColor = Color.Transparent,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -666,13 +674,13 @@ fun AppSettingsScreen(navController: NavController) {
|
||||
|
||||
Card(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp),
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp),
|
||||
colors =
|
||||
CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceContainer,
|
||||
),
|
||||
CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceContainer,
|
||||
),
|
||||
) {
|
||||
Column {
|
||||
ListItem(
|
||||
@@ -698,40 +706,40 @@ fun AppSettingsScreen(navController: NavController) {
|
||||
}
|
||||
},
|
||||
modifier =
|
||||
Modifier
|
||||
.clip(
|
||||
if (hasUpdate) {
|
||||
RoundedCornerShape(topStart = 12.dp, topEnd = 12.dp)
|
||||
} else {
|
||||
RoundedCornerShape(12.dp)
|
||||
},
|
||||
)
|
||||
.clickable(enabled = !isChecking) {
|
||||
if (hasUpdate && updateInfo != null) {
|
||||
showUpdateAvailableDialog = true
|
||||
} else {
|
||||
scope.launch {
|
||||
UpdateState.isChecking.value = true
|
||||
withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val result = Vendor.checkUpdateAsync()
|
||||
UpdateState.setUpdate(result)
|
||||
if (result == null) {
|
||||
showErrorDialog = R.string.no_updates_available
|
||||
}
|
||||
} catch (_: UpdateCheckException.TrackNotSupported) {
|
||||
showErrorDialog = R.string.update_track_not_supported
|
||||
} catch (_: Exception) {
|
||||
}
|
||||
}
|
||||
UpdateState.isChecking.value = false
|
||||
}
|
||||
}
|
||||
Modifier
|
||||
.clip(
|
||||
if (hasUpdate) {
|
||||
RoundedCornerShape(topStart = 12.dp, topEnd = 12.dp)
|
||||
} else {
|
||||
RoundedCornerShape(12.dp)
|
||||
},
|
||||
)
|
||||
.clickable(enabled = !isChecking) {
|
||||
if (hasUpdate && updateInfo != null) {
|
||||
showUpdateAvailableDialog = true
|
||||
} else {
|
||||
scope.launch {
|
||||
UpdateState.isChecking.value = true
|
||||
withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val result = Vendor.checkUpdateAsync()
|
||||
UpdateState.setUpdate(result)
|
||||
if (result == null) {
|
||||
showErrorDialog = R.string.no_updates_available
|
||||
}
|
||||
} catch (_: UpdateCheckException.TrackNotSupported) {
|
||||
showErrorDialog = R.string.update_track_not_supported
|
||||
} catch (_: Exception) {
|
||||
}
|
||||
}
|
||||
UpdateState.isChecking.value = false
|
||||
}
|
||||
}
|
||||
},
|
||||
colors =
|
||||
ListItemDefaults.colors(
|
||||
containerColor = Color.Transparent,
|
||||
),
|
||||
ListItemDefaults.colors(
|
||||
containerColor = Color.Transparent,
|
||||
),
|
||||
)
|
||||
|
||||
if (hasUpdate && updateInfo != null) {
|
||||
@@ -756,15 +764,15 @@ fun AppSettingsScreen(navController: NavController) {
|
||||
)
|
||||
},
|
||||
modifier =
|
||||
Modifier
|
||||
.clip(RoundedCornerShape(bottomStart = 12.dp, bottomEnd = 12.dp))
|
||||
.clickable {
|
||||
showUpdateAvailableDialog = true
|
||||
},
|
||||
Modifier
|
||||
.clip(RoundedCornerShape(bottomStart = 12.dp, bottomEnd = 12.dp))
|
||||
.clickable {
|
||||
showUpdateAvailableDialog = true
|
||||
},
|
||||
colors =
|
||||
ListItemDefaults.colors(
|
||||
containerColor = Color.Transparent,
|
||||
),
|
||||
ListItemDefaults.colors(
|
||||
containerColor = Color.Transparent,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -791,11 +799,11 @@ private fun UpdateTrackDialog(
|
||||
tracks.forEach { (value, label) ->
|
||||
Row(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.clickable { onTrackSelected(value) }
|
||||
.padding(vertical = 8.dp),
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.clickable { onTrackSelected(value) }
|
||||
.padding(vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
RadioButton(
|
||||
@@ -841,11 +849,11 @@ private fun InstallMethodDialog(
|
||||
methods.forEach { (value, label) ->
|
||||
Row(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.clickable { onMethodSelected(value) }
|
||||
.padding(vertical = 8.dp),
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.clickable { onMethodSelected(value) }
|
||||
.padding(vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
RadioButton(
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
package io.nekohasekai.sfa.compose.screen.settings
|
||||
|
||||
import android.content.ActivityNotFoundException
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.provider.DocumentsContract
|
||||
import android.widget.Toast
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Column
|
||||
@@ -11,11 +16,6 @@ import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import android.content.ActivityNotFoundException
|
||||
import android.content.Context
|
||||
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
|
||||
@@ -95,22 +95,22 @@ fun CoreSettingsScreen(navController: NavController) {
|
||||
|
||||
Column(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.background(MaterialTheme.colorScheme.surface)
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(vertical = 8.dp),
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.background(MaterialTheme.colorScheme.surface)
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(vertical = 8.dp),
|
||||
) {
|
||||
// Core Information Card
|
||||
Card(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
colors =
|
||||
CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceContainer,
|
||||
),
|
||||
CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceContainer,
|
||||
),
|
||||
) {
|
||||
Column {
|
||||
// Version Info
|
||||
@@ -138,9 +138,9 @@ fun CoreSettingsScreen(navController: NavController) {
|
||||
},
|
||||
modifier = Modifier.clip(RoundedCornerShape(topStart = 12.dp, topEnd = 12.dp)),
|
||||
colors =
|
||||
ListItemDefaults.colors(
|
||||
containerColor = Color.Transparent,
|
||||
),
|
||||
ListItemDefaults.colors(
|
||||
containerColor = Color.Transparent,
|
||||
),
|
||||
)
|
||||
|
||||
// Data Size
|
||||
@@ -167,16 +167,16 @@ fun CoreSettingsScreen(navController: NavController) {
|
||||
)
|
||||
},
|
||||
modifier =
|
||||
Modifier.clip(
|
||||
RoundedCornerShape(
|
||||
bottomStart = 12.dp,
|
||||
bottomEnd = 12.dp,
|
||||
),
|
||||
Modifier.clip(
|
||||
RoundedCornerShape(
|
||||
bottomStart = 12.dp,
|
||||
bottomEnd = 12.dp,
|
||||
),
|
||||
),
|
||||
colors =
|
||||
ListItemDefaults.colors(
|
||||
containerColor = Color.Transparent,
|
||||
),
|
||||
ListItemDefaults.colors(
|
||||
containerColor = Color.Transparent,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -193,13 +193,13 @@ fun CoreSettingsScreen(navController: NavController) {
|
||||
|
||||
Card(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp),
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp),
|
||||
colors =
|
||||
CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceContainer,
|
||||
),
|
||||
CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceContainer,
|
||||
),
|
||||
) {
|
||||
ListItem(
|
||||
headlineContent = {
|
||||
@@ -228,9 +228,9 @@ fun CoreSettingsScreen(navController: NavController) {
|
||||
},
|
||||
modifier = Modifier.clip(RoundedCornerShape(12.dp)),
|
||||
colors =
|
||||
ListItemDefaults.colors(
|
||||
containerColor = Color.Transparent,
|
||||
),
|
||||
ListItemDefaults.colors(
|
||||
containerColor = Color.Transparent,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -246,13 +246,13 @@ fun CoreSettingsScreen(navController: NavController) {
|
||||
// Working Directory Card
|
||||
Card(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp),
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp),
|
||||
colors =
|
||||
CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceContainer,
|
||||
),
|
||||
CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceContainer,
|
||||
),
|
||||
) {
|
||||
// Browse
|
||||
ListItem(
|
||||
@@ -270,15 +270,15 @@ fun CoreSettingsScreen(navController: NavController) {
|
||||
)
|
||||
},
|
||||
modifier =
|
||||
Modifier
|
||||
.clip(RoundedCornerShape(topStart = 12.dp, topEnd = 12.dp))
|
||||
.clickable {
|
||||
openInFileManager(context)
|
||||
},
|
||||
Modifier
|
||||
.clip(RoundedCornerShape(topStart = 12.dp, topEnd = 12.dp))
|
||||
.clickable {
|
||||
openInFileManager(context)
|
||||
},
|
||||
colors =
|
||||
ListItemDefaults.colors(
|
||||
containerColor = Color.Transparent,
|
||||
),
|
||||
ListItemDefaults.colors(
|
||||
containerColor = Color.Transparent,
|
||||
),
|
||||
)
|
||||
|
||||
// Destroy
|
||||
@@ -298,28 +298,28 @@ fun CoreSettingsScreen(navController: NavController) {
|
||||
)
|
||||
},
|
||||
modifier =
|
||||
Modifier
|
||||
.clip(RoundedCornerShape(bottomStart = 12.dp, bottomEnd = 12.dp))
|
||||
.clickable {
|
||||
scope.launch(Dispatchers.IO) {
|
||||
val filesDir = context.getExternalFilesDir(null) ?: context.filesDir
|
||||
filesDir.deleteRecursively()
|
||||
filesDir.mkdirs()
|
||||
Modifier
|
||||
.clip(RoundedCornerShape(bottomStart = 12.dp, bottomEnd = 12.dp))
|
||||
.clickable {
|
||||
scope.launch(Dispatchers.IO) {
|
||||
val filesDir = context.getExternalFilesDir(null) ?: context.filesDir
|
||||
filesDir.deleteRecursively()
|
||||
filesDir.mkdirs()
|
||||
|
||||
// Recalculate data size
|
||||
val newSize =
|
||||
filesDir.walkTopDown()
|
||||
.filter { it.isFile }
|
||||
.map { it.length() }
|
||||
.sum()
|
||||
val formattedSize = Libbox.formatBytes(newSize)
|
||||
dataSize = formattedSize
|
||||
}
|
||||
},
|
||||
// Recalculate data size
|
||||
val newSize =
|
||||
filesDir.walkTopDown()
|
||||
.filter { it.isFile }
|
||||
.map { it.length() }
|
||||
.sum()
|
||||
val formattedSize = Libbox.formatBytes(newSize)
|
||||
dataSize = formattedSize
|
||||
}
|
||||
},
|
||||
colors =
|
||||
ListItemDefaults.colors(
|
||||
containerColor = Color.Transparent,
|
||||
),
|
||||
ListItemDefaults.colors(
|
||||
containerColor = Color.Transparent,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -343,7 +343,7 @@ private fun openInFileManager(context: Context) {
|
||||
Toast.makeText(
|
||||
context,
|
||||
context.getString(R.string.no_file_manager),
|
||||
Toast.LENGTH_SHORT
|
||||
Toast.LENGTH_SHORT,
|
||||
).show()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
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
|
||||
@@ -61,18 +60,18 @@ 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.R
|
||||
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.database.Settings
|
||||
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.PrivilegeSettingsClient
|
||||
import io.nekohasekai.sfa.utils.VpnDetectionTest
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
@@ -122,7 +121,7 @@ fun PrivilegeSettingsScreen(navController: NavController, serviceStatus: Status
|
||||
var messageDialogMessage by remember { mutableStateOf("") }
|
||||
|
||||
val saveFileLauncher = rememberLauncherForActivityResult(
|
||||
contract = ActivityResultContracts.CreateDocument("application/zip")
|
||||
contract = ActivityResultContracts.CreateDocument("application/zip"),
|
||||
) { uri ->
|
||||
val file = exportedFile
|
||||
if (uri != null && file != null) {
|
||||
@@ -146,9 +145,9 @@ fun PrivilegeSettingsScreen(navController: NavController, serviceStatus: Status
|
||||
HookStatusClient.refresh()
|
||||
}
|
||||
|
||||
val hasPendingDowngrade = HookModuleUpdateNotifier.isDowngrade(systemHookStatus)
|
||||
val hasPendingUpdate = HookModuleUpdateNotifier.isUpgrade(systemHookStatus)
|
||||
val hasPendingChange = hasPendingDowngrade || hasPendingUpdate
|
||||
val hasPendingDowngrade = HookModuleUpdateNotifier.isDowngrade(systemHookStatus)
|
||||
val hasPendingUpdate = HookModuleUpdateNotifier.isUpgrade(systemHookStatus)
|
||||
val hasPendingChange = hasPendingDowngrade || hasPendingUpdate
|
||||
androidx.compose.runtime.LaunchedEffect(systemHookStatus) {
|
||||
HookModuleUpdateNotifier.maybeNotify(context, systemHookStatus)
|
||||
}
|
||||
@@ -231,8 +230,11 @@ fun PrivilegeSettingsScreen(navController: NavController, serviceStatus: Status
|
||||
CircularProgressIndicator(modifier = Modifier.size(24.dp))
|
||||
Spacer(modifier = Modifier.width(12.dp))
|
||||
Text(
|
||||
if (exportError != null) exportError!!
|
||||
else stringResource(R.string.exporting)
|
||||
if (exportError != null) {
|
||||
exportError!!
|
||||
} else {
|
||||
stringResource(R.string.exporting)
|
||||
},
|
||||
)
|
||||
}
|
||||
},
|
||||
@@ -273,7 +275,7 @@ fun PrivilegeSettingsScreen(navController: NavController, serviceStatus: Status
|
||||
val uri = FileProvider.getUriForFile(
|
||||
context,
|
||||
"${context.packageName}.cache",
|
||||
file
|
||||
file,
|
||||
)
|
||||
val intent = Intent(Intent.ACTION_SEND).apply {
|
||||
type = "application/zip"
|
||||
@@ -283,7 +285,7 @@ fun PrivilegeSettingsScreen(navController: NavController, serviceStatus: Status
|
||||
context.startActivity(Intent.createChooser(intent, null))
|
||||
showExportSuccessDialog = false
|
||||
exportedFile = null
|
||||
}
|
||||
},
|
||||
) {
|
||||
Text(stringResource(R.string.menu_share))
|
||||
}
|
||||
@@ -293,7 +295,7 @@ fun PrivilegeSettingsScreen(navController: NavController, serviceStatus: Status
|
||||
onClick = {
|
||||
val file = exportedFile ?: return@TextButton
|
||||
saveFileLauncher.launch(file.name)
|
||||
}
|
||||
},
|
||||
) {
|
||||
Text(stringResource(R.string.save))
|
||||
}
|
||||
@@ -413,11 +415,11 @@ fun PrivilegeSettingsScreen(navController: NavController, serviceStatus: Status
|
||||
)
|
||||
},
|
||||
modifier =
|
||||
Modifier
|
||||
.clip(logItemShape)
|
||||
.clickable {
|
||||
navController.navigate("settings/privilege/logs")
|
||||
},
|
||||
Modifier
|
||||
.clip(logItemShape)
|
||||
.clickable {
|
||||
navController.navigate("settings/privilege/logs")
|
||||
},
|
||||
colors = ListItemDefaults.colors(
|
||||
containerColor = Color.Transparent,
|
||||
),
|
||||
@@ -439,42 +441,42 @@ fun PrivilegeSettingsScreen(navController: NavController, serviceStatus: Status
|
||||
)
|
||||
},
|
||||
modifier =
|
||||
Modifier
|
||||
.clip(RoundedCornerShape(bottomStart = 12.dp, bottomEnd = 12.dp))
|
||||
.clickable {
|
||||
val exportBase = File(context.cacheDir, "debug")
|
||||
if (!exportBase.exists()) {
|
||||
exportBase.mkdirs()
|
||||
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)
|
||||
}
|
||||
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
|
||||
}
|
||||
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,
|
||||
),
|
||||
@@ -496,44 +498,44 @@ fun PrivilegeSettingsScreen(navController: NavController, serviceStatus: Status
|
||||
)
|
||||
},
|
||||
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
|
||||
}
|
||||
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,
|
||||
),
|
||||
@@ -621,7 +623,7 @@ fun PrivilegeSettingsScreen(navController: NavController, serviceStatus: Status
|
||||
RoundedCornerShape(topStart = 12.dp, topEnd = 12.dp)
|
||||
} else {
|
||||
RoundedCornerShape(12.dp)
|
||||
}
|
||||
},
|
||||
),
|
||||
colors = ListItemDefaults.colors(
|
||||
containerColor = Color.Transparent,
|
||||
@@ -662,7 +664,6 @@ fun PrivilegeSettingsScreen(navController: NavController, serviceStatus: Status
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -730,7 +731,7 @@ fun PrivilegeSettingsScreen(navController: NavController, serviceStatus: Status
|
||||
RoundedCornerShape(topStart = 12.dp, topEnd = 12.dp)
|
||||
} else {
|
||||
RoundedCornerShape(12.dp)
|
||||
}
|
||||
},
|
||||
),
|
||||
colors = ListItemDefaults.colors(
|
||||
containerColor = Color.Transparent,
|
||||
@@ -847,11 +848,7 @@ fun PrivilegeSettingsScreen(navController: NavController, serviceStatus: Status
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SelfTestDialog(
|
||||
isRunning: Boolean,
|
||||
result: DetectionResult?,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
private fun SelfTestDialog(isRunning: Boolean, result: DetectionResult?, onDismiss: () -> Unit) {
|
||||
val notDetectedText = stringResource(R.string.privilege_settings_hide_test_not_detected)
|
||||
|
||||
AlertDialog(
|
||||
|
||||
@@ -54,14 +54,14 @@ 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 androidx.navigation.NavController
|
||||
import io.nekohasekai.sfa.R
|
||||
import io.nekohasekai.sfa.bg.RootClient
|
||||
import io.nekohasekai.sfa.compose.screen.profileoverride.PerAppProxyScanner
|
||||
import io.nekohasekai.sfa.compose.topbar.OverrideTopBar
|
||||
import io.nekohasekai.sfa.database.Settings
|
||||
import io.nekohasekai.sfa.compose.screen.profileoverride.PerAppProxyScanner
|
||||
import io.nekohasekai.sfa.vendor.PackageQueryManager
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.async
|
||||
@@ -162,22 +162,22 @@ fun ProfileOverrideScreen(navController: NavController) {
|
||||
|
||||
Column(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.background(MaterialTheme.colorScheme.surface)
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(vertical = 8.dp),
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.background(MaterialTheme.colorScheme.surface)
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(vertical = 8.dp),
|
||||
) {
|
||||
// Card 1: Auto Redirect
|
||||
Card(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
colors =
|
||||
CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceContainer,
|
||||
),
|
||||
CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceContainer,
|
||||
),
|
||||
) {
|
||||
ListItem(
|
||||
headlineContent = {
|
||||
@@ -232,9 +232,9 @@ fun ProfileOverrideScreen(navController: NavController) {
|
||||
},
|
||||
modifier = Modifier.clip(RoundedCornerShape(12.dp)),
|
||||
colors =
|
||||
ListItemDefaults.colors(
|
||||
containerColor = Color.Transparent,
|
||||
),
|
||||
ListItemDefaults.colors(
|
||||
containerColor = Color.Transparent,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -254,13 +254,13 @@ fun ProfileOverrideScreen(navController: NavController) {
|
||||
|
||||
Card(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp),
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp),
|
||||
colors =
|
||||
CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceContainer,
|
||||
),
|
||||
CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceContainer,
|
||||
),
|
||||
) {
|
||||
Column {
|
||||
// Mode selector (only when privileged query is needed)
|
||||
@@ -272,32 +272,44 @@ fun ProfileOverrideScreen(navController: NavController) {
|
||||
Text(
|
||||
stringResource(R.string.per_app_proxy_package_query_mode),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = if (modeEnabled) Color.Unspecified
|
||||
else MaterialTheme.colorScheme.onSurface.copy(alpha = disabledAlpha),
|
||||
color = if (modeEnabled) {
|
||||
Color.Unspecified
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurface.copy(alpha = disabledAlpha)
|
||||
},
|
||||
)
|
||||
},
|
||||
supportingContent = {
|
||||
Text(
|
||||
if (useRootMode) "ROOT" else "Shizuku",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = if (modeEnabled) MaterialTheme.colorScheme.onSurfaceVariant
|
||||
else MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = disabledAlpha),
|
||||
color = if (modeEnabled) {
|
||||
MaterialTheme.colorScheme.onSurfaceVariant
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = disabledAlpha)
|
||||
},
|
||||
)
|
||||
},
|
||||
leadingContent = {
|
||||
Icon(
|
||||
imageVector = Icons.Outlined.Tune,
|
||||
contentDescription = null,
|
||||
tint = if (modeEnabled) MaterialTheme.colorScheme.primary
|
||||
else MaterialTheme.colorScheme.onSurface.copy(alpha = disabledAlpha),
|
||||
tint = if (modeEnabled) {
|
||||
MaterialTheme.colorScheme.primary
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurface.copy(alpha = disabledAlpha)
|
||||
},
|
||||
)
|
||||
},
|
||||
trailingContent = {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Outlined.KeyboardArrowRight,
|
||||
contentDescription = null,
|
||||
tint = if (modeEnabled) MaterialTheme.colorScheme.onSurfaceVariant
|
||||
else MaterialTheme.colorScheme.onSurface.copy(alpha = disabledAlpha),
|
||||
tint = if (modeEnabled) {
|
||||
MaterialTheme.colorScheme.onSurfaceVariant
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurface.copy(alpha = disabledAlpha)
|
||||
},
|
||||
)
|
||||
},
|
||||
modifier = Modifier
|
||||
@@ -355,19 +367,19 @@ fun ProfileOverrideScreen(navController: NavController) {
|
||||
)
|
||||
},
|
||||
modifier =
|
||||
Modifier.clip(
|
||||
if (showModeSelector) {
|
||||
RoundedCornerShape(0.dp)
|
||||
} else if (perAppProxyEnabled && canUsePerAppProxy) {
|
||||
RoundedCornerShape(topStart = 12.dp, topEnd = 12.dp)
|
||||
} else {
|
||||
RoundedCornerShape(12.dp)
|
||||
},
|
||||
),
|
||||
Modifier.clip(
|
||||
if (showModeSelector) {
|
||||
RoundedCornerShape(0.dp)
|
||||
} else if (perAppProxyEnabled && canUsePerAppProxy) {
|
||||
RoundedCornerShape(topStart = 12.dp, topEnd = 12.dp)
|
||||
} else {
|
||||
RoundedCornerShape(12.dp)
|
||||
},
|
||||
),
|
||||
colors =
|
||||
ListItemDefaults.colors(
|
||||
containerColor = Color.Transparent,
|
||||
),
|
||||
ListItemDefaults.colors(
|
||||
containerColor = Color.Transparent,
|
||||
),
|
||||
)
|
||||
|
||||
if (perAppProxyEnabled && canUsePerAppProxy) {
|
||||
@@ -409,13 +421,13 @@ fun ProfileOverrideScreen(navController: NavController) {
|
||||
)
|
||||
},
|
||||
modifier =
|
||||
Modifier.clickable(enabled = manageEnabled) {
|
||||
navController.navigate("settings/profile_override/manage")
|
||||
},
|
||||
Modifier.clickable(enabled = manageEnabled) {
|
||||
navController.navigate("settings/profile_override/manage")
|
||||
},
|
||||
colors =
|
||||
ListItemDefaults.colors(
|
||||
containerColor = Color.Transparent,
|
||||
),
|
||||
ListItemDefaults.colors(
|
||||
containerColor = Color.Transparent,
|
||||
),
|
||||
)
|
||||
|
||||
// Managed Mode toggle
|
||||
@@ -477,9 +489,9 @@ fun ProfileOverrideScreen(navController: NavController) {
|
||||
},
|
||||
modifier = Modifier.clip(RoundedCornerShape(bottomStart = 12.dp, bottomEnd = 12.dp)),
|
||||
colors =
|
||||
ListItemDefaults.colors(
|
||||
containerColor = Color.Transparent,
|
||||
),
|
||||
ListItemDefaults.colors(
|
||||
containerColor = Color.Transparent,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -601,7 +613,7 @@ fun ProfileOverrideScreen(navController: NavController) {
|
||||
Toast.makeText(
|
||||
context,
|
||||
R.string.root_access_denied,
|
||||
Toast.LENGTH_LONG
|
||||
Toast.LENGTH_LONG,
|
||||
).show()
|
||||
}
|
||||
}
|
||||
@@ -706,7 +718,7 @@ private suspend fun scanAllChinaApps(): Set<String> = withContext(Dispatchers.De
|
||||
val chinaApps = mutableSetOf<String>()
|
||||
installedPackages.map { packageInfo ->
|
||||
async {
|
||||
if (PerAppProxyScanner.scanChinaPackage(packageInfo)) {
|
||||
if (PerAppProxyScanner.scanChinaPackage(packageInfo)) {
|
||||
synchronized(chinaApps) {
|
||||
chinaApps.add(packageInfo.packageName)
|
||||
}
|
||||
|
||||
@@ -63,10 +63,7 @@ import kotlinx.coroutines.launch
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun ServiceSettingsScreen(
|
||||
navController: NavController,
|
||||
serviceConnection: ServiceConnection? = null,
|
||||
) {
|
||||
fun ServiceSettingsScreen(navController: NavController, serviceConnection: ServiceConnection? = null) {
|
||||
OverrideTopBar {
|
||||
TopAppBar(
|
||||
title = { Text(stringResource(R.string.service)) },
|
||||
@@ -113,23 +110,23 @@ fun ServiceSettingsScreen(
|
||||
|
||||
Column(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.background(MaterialTheme.colorScheme.surface)
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(vertical = 8.dp),
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.background(MaterialTheme.colorScheme.surface)
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(vertical = 8.dp),
|
||||
) {
|
||||
// Background Permission Card (only show if battery optimization is not ignored)
|
||||
if (!isBatteryOptimizationIgnored && Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||
Card(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
colors =
|
||||
CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.tertiaryContainer.copy(alpha = 0.5f),
|
||||
),
|
||||
CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.tertiaryContainer.copy(alpha = 0.5f),
|
||||
),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
@@ -193,13 +190,13 @@ fun ServiceSettingsScreen(
|
||||
// Options Section
|
||||
Card(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp),
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp),
|
||||
colors =
|
||||
CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceContainer,
|
||||
),
|
||||
CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceContainer,
|
||||
),
|
||||
) {
|
||||
ListItem(
|
||||
headlineContent = {
|
||||
@@ -234,9 +231,9 @@ fun ServiceSettingsScreen(
|
||||
},
|
||||
modifier = Modifier.clip(RoundedCornerShape(12.dp)),
|
||||
colors =
|
||||
ListItemDefaults.colors(
|
||||
containerColor = Color.Transparent,
|
||||
),
|
||||
ListItemDefaults.colors(
|
||||
containerColor = Color.Transparent,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -15,15 +15,14 @@ import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.outlined.OpenInNew
|
||||
import androidx.compose.material.icons.outlined.AdminPanelSettings
|
||||
import androidx.compose.material.icons.outlined.Code
|
||||
import androidx.compose.material.icons.outlined.Description
|
||||
import androidx.compose.material.icons.outlined.Favorite
|
||||
import androidx.compose.material.icons.outlined.FilterAlt
|
||||
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
|
||||
@@ -49,15 +48,12 @@ import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
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
|
||||
@@ -87,22 +83,22 @@ fun SettingsScreen(navController: NavController) {
|
||||
|
||||
Column(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.background(MaterialTheme.colorScheme.surface)
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(vertical = 8.dp),
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.background(MaterialTheme.colorScheme.surface)
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(vertical = 8.dp),
|
||||
) {
|
||||
// General Settings Group
|
||||
Card(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
colors =
|
||||
CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceContainer,
|
||||
),
|
||||
CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceContainer,
|
||||
),
|
||||
) {
|
||||
Column {
|
||||
ListItem(
|
||||
@@ -125,13 +121,13 @@ fun SettingsScreen(navController: NavController) {
|
||||
}
|
||||
},
|
||||
modifier =
|
||||
Modifier
|
||||
.clip(RoundedCornerShape(topStart = 12.dp, topEnd = 12.dp))
|
||||
.clickable { navController.navigate("settings/app") },
|
||||
Modifier
|
||||
.clip(RoundedCornerShape(topStart = 12.dp, topEnd = 12.dp))
|
||||
.clickable { navController.navigate("settings/app") },
|
||||
colors =
|
||||
ListItemDefaults.colors(
|
||||
containerColor = Color.Transparent,
|
||||
),
|
||||
ListItemDefaults.colors(
|
||||
containerColor = Color.Transparent,
|
||||
),
|
||||
)
|
||||
|
||||
ListItem(
|
||||
@@ -149,12 +145,12 @@ fun SettingsScreen(navController: NavController) {
|
||||
)
|
||||
},
|
||||
modifier =
|
||||
Modifier
|
||||
.clickable { navController.navigate("settings/core") },
|
||||
Modifier
|
||||
.clickable { navController.navigate("settings/core") },
|
||||
colors =
|
||||
ListItemDefaults.colors(
|
||||
containerColor = Color.Transparent,
|
||||
),
|
||||
ListItemDefaults.colors(
|
||||
containerColor = Color.Transparent,
|
||||
),
|
||||
)
|
||||
|
||||
ListItem(
|
||||
@@ -178,9 +174,9 @@ fun SettingsScreen(navController: NavController) {
|
||||
},
|
||||
modifier = Modifier.clickable { navController.navigate("settings/service") },
|
||||
colors =
|
||||
ListItemDefaults.colors(
|
||||
containerColor = Color.Transparent,
|
||||
),
|
||||
ListItemDefaults.colors(
|
||||
containerColor = Color.Transparent,
|
||||
),
|
||||
)
|
||||
|
||||
ListItem(
|
||||
@@ -198,12 +194,12 @@ fun SettingsScreen(navController: NavController) {
|
||||
)
|
||||
},
|
||||
modifier =
|
||||
Modifier
|
||||
.clickable { navController.navigate("settings/profile_override") },
|
||||
Modifier
|
||||
.clickable { navController.navigate("settings/profile_override") },
|
||||
colors =
|
||||
ListItemDefaults.colors(
|
||||
containerColor = Color.Transparent,
|
||||
),
|
||||
ListItemDefaults.colors(
|
||||
containerColor = Color.Transparent,
|
||||
),
|
||||
)
|
||||
|
||||
ListItem(
|
||||
@@ -228,13 +224,13 @@ fun SettingsScreen(navController: NavController) {
|
||||
}
|
||||
},
|
||||
modifier =
|
||||
Modifier
|
||||
.clip(RoundedCornerShape(bottomStart = 12.dp, bottomEnd = 12.dp))
|
||||
.clickable { navController.navigate("settings/privilege") },
|
||||
Modifier
|
||||
.clip(RoundedCornerShape(bottomStart = 12.dp, bottomEnd = 12.dp))
|
||||
.clickable { navController.navigate("settings/privilege") },
|
||||
colors =
|
||||
ListItemDefaults.colors(
|
||||
containerColor = Color.Transparent,
|
||||
),
|
||||
ListItemDefaults.colors(
|
||||
containerColor = Color.Transparent,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -249,13 +245,13 @@ fun SettingsScreen(navController: NavController) {
|
||||
|
||||
Card(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp),
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp),
|
||||
colors =
|
||||
CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceContainer,
|
||||
),
|
||||
CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceContainer,
|
||||
),
|
||||
) {
|
||||
Column {
|
||||
ListItem(
|
||||
@@ -280,17 +276,17 @@ fun SettingsScreen(navController: NavController) {
|
||||
)
|
||||
},
|
||||
modifier =
|
||||
Modifier
|
||||
.clip(RoundedCornerShape(topStart = 12.dp, topEnd = 12.dp))
|
||||
.clickable {
|
||||
val intent = android.content.Intent(android.content.Intent.ACTION_VIEW)
|
||||
intent.data = android.net.Uri.parse("https://sing-box.sagernet.org/")
|
||||
context.startActivity(intent)
|
||||
},
|
||||
Modifier
|
||||
.clip(RoundedCornerShape(topStart = 12.dp, topEnd = 12.dp))
|
||||
.clickable {
|
||||
val intent = android.content.Intent(android.content.Intent.ACTION_VIEW)
|
||||
intent.data = android.net.Uri.parse("https://sing-box.sagernet.org/")
|
||||
context.startActivity(intent)
|
||||
},
|
||||
colors =
|
||||
ListItemDefaults.colors(
|
||||
containerColor = Color.Transparent,
|
||||
),
|
||||
ListItemDefaults.colors(
|
||||
containerColor = Color.Transparent,
|
||||
),
|
||||
)
|
||||
|
||||
ListItem(
|
||||
@@ -315,17 +311,17 @@ fun SettingsScreen(navController: NavController) {
|
||||
)
|
||||
},
|
||||
modifier =
|
||||
Modifier
|
||||
.clickable {
|
||||
val intent = android.content.Intent(android.content.Intent.ACTION_VIEW)
|
||||
intent.data =
|
||||
android.net.Uri.parse("https://github.com/SagerNet/sing-box-for-android")
|
||||
context.startActivity(intent)
|
||||
},
|
||||
Modifier
|
||||
.clickable {
|
||||
val intent = android.content.Intent(android.content.Intent.ACTION_VIEW)
|
||||
intent.data =
|
||||
android.net.Uri.parse("https://github.com/SagerNet/sing-box-for-android")
|
||||
context.startActivity(intent)
|
||||
},
|
||||
colors =
|
||||
ListItemDefaults.colors(
|
||||
containerColor = Color.Transparent,
|
||||
),
|
||||
ListItemDefaults.colors(
|
||||
containerColor = Color.Transparent,
|
||||
),
|
||||
)
|
||||
|
||||
ListItem(
|
||||
@@ -350,17 +346,17 @@ fun SettingsScreen(navController: NavController) {
|
||||
)
|
||||
},
|
||||
modifier =
|
||||
Modifier
|
||||
.clip(RoundedCornerShape(bottomStart = 12.dp, bottomEnd = 12.dp))
|
||||
.clickable {
|
||||
val intent = android.content.Intent(android.content.Intent.ACTION_VIEW)
|
||||
intent.data = android.net.Uri.parse("https://sekai.icu/sponsors/")
|
||||
context.startActivity(intent)
|
||||
},
|
||||
Modifier
|
||||
.clip(RoundedCornerShape(bottomStart = 12.dp, bottomEnd = 12.dp))
|
||||
.clickable {
|
||||
val intent = android.content.Intent(android.content.Intent.ACTION_VIEW)
|
||||
intent.data = android.net.Uri.parse("https://sekai.icu/sponsors/")
|
||||
context.startActivity(intent)
|
||||
},
|
||||
colors =
|
||||
ListItemDefaults.colors(
|
||||
containerColor = Color.Transparent,
|
||||
),
|
||||
ListItemDefaults.colors(
|
||||
containerColor = Color.Transparent,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -178,9 +178,9 @@ fun AppSelectionCard(
|
||||
modifier = cardModifier,
|
||||
shape = cardShape,
|
||||
colors =
|
||||
CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceContainerLow,
|
||||
),
|
||||
CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceContainerLow,
|
||||
),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(12.dp),
|
||||
@@ -236,11 +236,11 @@ fun AppSelectionCard(
|
||||
trailingIcon = {
|
||||
Icon(
|
||||
imageVector =
|
||||
if (showCopyMenu) {
|
||||
Icons.Default.ExpandLess
|
||||
} else {
|
||||
Icons.Default.ExpandMore
|
||||
},
|
||||
if (showCopyMenu) {
|
||||
Icons.Default.ExpandLess
|
||||
} else {
|
||||
Icons.Default.ExpandMore
|
||||
},
|
||||
contentDescription = null,
|
||||
)
|
||||
},
|
||||
|
||||
@@ -11,127 +11,127 @@ val Typography =
|
||||
Typography(
|
||||
// Display styles
|
||||
displayLarge =
|
||||
TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontSize = 57.sp,
|
||||
lineHeight = 64.sp,
|
||||
letterSpacing = (-0.25).sp,
|
||||
),
|
||||
TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontSize = 57.sp,
|
||||
lineHeight = 64.sp,
|
||||
letterSpacing = (-0.25).sp,
|
||||
),
|
||||
displayMedium =
|
||||
TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontSize = 45.sp,
|
||||
lineHeight = 52.sp,
|
||||
letterSpacing = 0.sp,
|
||||
),
|
||||
TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontSize = 45.sp,
|
||||
lineHeight = 52.sp,
|
||||
letterSpacing = 0.sp,
|
||||
),
|
||||
displaySmall =
|
||||
TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontSize = 36.sp,
|
||||
lineHeight = 44.sp,
|
||||
letterSpacing = 0.sp,
|
||||
),
|
||||
TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontSize = 36.sp,
|
||||
lineHeight = 44.sp,
|
||||
letterSpacing = 0.sp,
|
||||
),
|
||||
// Headline styles
|
||||
headlineLarge =
|
||||
TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontSize = 32.sp,
|
||||
lineHeight = 40.sp,
|
||||
letterSpacing = 0.sp,
|
||||
),
|
||||
TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontSize = 32.sp,
|
||||
lineHeight = 40.sp,
|
||||
letterSpacing = 0.sp,
|
||||
),
|
||||
headlineMedium =
|
||||
TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontSize = 28.sp,
|
||||
lineHeight = 36.sp,
|
||||
letterSpacing = 0.sp,
|
||||
),
|
||||
TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontSize = 28.sp,
|
||||
lineHeight = 36.sp,
|
||||
letterSpacing = 0.sp,
|
||||
),
|
||||
headlineSmall =
|
||||
TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontSize = 24.sp,
|
||||
lineHeight = 32.sp,
|
||||
letterSpacing = 0.sp,
|
||||
),
|
||||
TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontSize = 24.sp,
|
||||
lineHeight = 32.sp,
|
||||
letterSpacing = 0.sp,
|
||||
),
|
||||
// Title styles
|
||||
titleLarge =
|
||||
TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontSize = 22.sp,
|
||||
lineHeight = 28.sp,
|
||||
letterSpacing = 0.sp,
|
||||
),
|
||||
TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontSize = 22.sp,
|
||||
lineHeight = 28.sp,
|
||||
letterSpacing = 0.sp,
|
||||
),
|
||||
titleMedium =
|
||||
TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontSize = 16.sp,
|
||||
lineHeight = 24.sp,
|
||||
letterSpacing = 0.15.sp,
|
||||
),
|
||||
TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontSize = 16.sp,
|
||||
lineHeight = 24.sp,
|
||||
letterSpacing = 0.15.sp,
|
||||
),
|
||||
titleSmall =
|
||||
TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontSize = 14.sp,
|
||||
lineHeight = 20.sp,
|
||||
letterSpacing = 0.1.sp,
|
||||
),
|
||||
TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontSize = 14.sp,
|
||||
lineHeight = 20.sp,
|
||||
letterSpacing = 0.1.sp,
|
||||
),
|
||||
// Body styles
|
||||
bodyLarge =
|
||||
TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontSize = 16.sp,
|
||||
lineHeight = 24.sp,
|
||||
letterSpacing = 0.5.sp,
|
||||
),
|
||||
TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontSize = 16.sp,
|
||||
lineHeight = 24.sp,
|
||||
letterSpacing = 0.5.sp,
|
||||
),
|
||||
bodyMedium =
|
||||
TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontSize = 14.sp,
|
||||
lineHeight = 20.sp,
|
||||
letterSpacing = 0.25.sp,
|
||||
),
|
||||
TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontSize = 14.sp,
|
||||
lineHeight = 20.sp,
|
||||
letterSpacing = 0.25.sp,
|
||||
),
|
||||
bodySmall =
|
||||
TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontSize = 12.sp,
|
||||
lineHeight = 16.sp,
|
||||
letterSpacing = 0.4.sp,
|
||||
),
|
||||
TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontSize = 12.sp,
|
||||
lineHeight = 16.sp,
|
||||
letterSpacing = 0.4.sp,
|
||||
),
|
||||
// Label styles
|
||||
labelLarge =
|
||||
TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontSize = 14.sp,
|
||||
lineHeight = 20.sp,
|
||||
letterSpacing = 0.1.sp,
|
||||
),
|
||||
TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontSize = 14.sp,
|
||||
lineHeight = 20.sp,
|
||||
letterSpacing = 0.1.sp,
|
||||
),
|
||||
labelMedium =
|
||||
TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontSize = 12.sp,
|
||||
lineHeight = 16.sp,
|
||||
letterSpacing = 0.5.sp,
|
||||
),
|
||||
TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontSize = 12.sp,
|
||||
lineHeight = 16.sp,
|
||||
letterSpacing = 0.5.sp,
|
||||
),
|
||||
labelSmall =
|
||||
TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontSize = 11.sp,
|
||||
lineHeight = 16.sp,
|
||||
letterSpacing = 0.5.sp,
|
||||
),
|
||||
TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontSize = 11.sp,
|
||||
lineHeight = 16.sp,
|
||||
letterSpacing = 0.5.sp,
|
||||
),
|
||||
)
|
||||
|
||||
@@ -7,20 +7,12 @@ 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,
|
||||
)
|
||||
internal data class TopBarEntry(val key: Any, val content: @Composable () -> Unit)
|
||||
|
||||
class TopBarController internal constructor(
|
||||
private val state: MutableState<List<TopBarEntry>>,
|
||||
) {
|
||||
class TopBarController internal constructor(private val state: MutableState<List<TopBarEntry>>) {
|
||||
val current: (@Composable () -> Unit)? get() = state.value.lastOrNull()?.content
|
||||
|
||||
fun set(
|
||||
key: Any,
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
fun set(key: Any, content: @Composable () -> Unit) {
|
||||
state.value = state.value.filterNot { it.key == key } + TopBarEntry(key, content)
|
||||
}
|
||||
|
||||
|
||||
@@ -9,10 +9,7 @@ import androidx.compose.material.icons.sharp.*
|
||||
import androidx.compose.material.icons.twotone.*
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
|
||||
data class IconCategory(
|
||||
val name: String,
|
||||
val icons: List<ProfileIcon>,
|
||||
)
|
||||
data class IconCategory(val name: String, val icons: List<ProfileIcon>)
|
||||
|
||||
object MaterialIconsLibrary {
|
||||
val categories =
|
||||
@@ -416,20 +413,16 @@ object MaterialIconsLibrary {
|
||||
),
|
||||
)
|
||||
|
||||
fun getAllIcons(): List<ProfileIcon> {
|
||||
return categories.flatMap { it.icons }
|
||||
}
|
||||
fun getAllIcons(): List<ProfileIcon> = categories.flatMap { it.icons }
|
||||
|
||||
fun getIconById(id: String?): ImageVector? {
|
||||
if (id == null) return null
|
||||
return getAllIcons().find { it.id == id }?.icon
|
||||
}
|
||||
|
||||
fun getCategoryForIcon(iconId: String): String? {
|
||||
return categories.find { category ->
|
||||
category.icons.any { it.id == iconId }
|
||||
}?.name
|
||||
}
|
||||
fun getCategoryForIcon(iconId: String): String? = categories.find { category ->
|
||||
category.icons.any { it.id == iconId }
|
||||
}?.name
|
||||
|
||||
fun searchIcons(query: String): List<ProfileIcon> {
|
||||
val lowercaseQuery = query.lowercase()
|
||||
|
||||
@@ -5,11 +5,7 @@ import androidx.compose.material.icons.automirrored.filled.InsertDriveFile
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import io.nekohasekai.sfa.compose.util.icons.MaterialIconsLibrary
|
||||
|
||||
data class ProfileIcon(
|
||||
val id: String,
|
||||
val icon: ImageVector,
|
||||
val label: String,
|
||||
)
|
||||
data class ProfileIcon(val id: String, val icon: ImageVector, val label: String)
|
||||
|
||||
object ProfileIcons {
|
||||
// Use the complete Material Icons library with all available icons
|
||||
@@ -26,13 +22,9 @@ object ProfileIcons {
|
||||
return Icons.AutoMirrored.Default.InsertDriveFile
|
||||
}
|
||||
|
||||
fun getCategoryForIcon(iconId: String): String? {
|
||||
return MaterialIconsLibrary.getCategoryForIcon(iconId)
|
||||
}
|
||||
fun getCategoryForIcon(iconId: String): String? = MaterialIconsLibrary.getCategoryForIcon(iconId)
|
||||
|
||||
fun searchIcons(query: String): List<ProfileIcon> {
|
||||
return MaterialIconsLibrary.searchIcons(query)
|
||||
}
|
||||
fun searchIcons(query: String): List<ProfileIcon> = MaterialIconsLibrary.searchIcons(query)
|
||||
|
||||
fun getCategories() = MaterialIconsLibrary.categories
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user