Apply Spotless formatting to Java and Kotlin files

This commit is contained in:
世界
2026-01-17 16:58:26 +08:00
parent 3c9ab19466
commit 9c820a3400
170 changed files with 4496 additions and 5318 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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