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( handle.service.installPackage(
pfd, pfd,
apkFile.length(), apkFile.length(),
android.os.Process.myUserHandle().hashCode() android.os.Process.myUserHandle().hashCode(),
) )
} }
} }
@@ -69,10 +69,7 @@ object RootInstaller {
} }
} }
private class RootServiceHandle( private class RootServiceHandle(val connection: ServiceConnection, val service: IRootService) : java.io.Closeable {
val connection: ServiceConnection,
val service: IRootService
) : java.io.Closeable {
override fun close() { override fun close() {
Handler(Looper.getMainLooper()).post { Handler(Looper.getMainLooper()).post {
RootService.unbind(connection) RootService.unbind(connection)

View File

@@ -10,9 +10,7 @@ import android.content.pm.PackageInstaller as AndroidPackageInstaller
object SystemPackageInstaller { object SystemPackageInstaller {
fun canSystemSilentInstall(): Boolean { fun canSystemSilentInstall(): Boolean = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S
return Build.VERSION.SDK_INT >= Build.VERSION_CODES.S
}
fun install(context: Context, apkFile: File) { fun install(context: Context, apkFile: File) {
val packageInstaller = context.packageManager.packageInstaller val packageInstaller = context.packageManager.packageInstaller
@@ -38,7 +36,7 @@ object SystemPackageInstaller {
context, context,
sessionId, sessionId,
intent, intent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_MUTABLE PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_MUTABLE,
) )
session.commit(pendingIntent.intentSender) session.commit(pendingIntent.intentSender)

View File

@@ -15,10 +15,7 @@ import io.nekohasekai.sfa.update.UpdateState
import io.nekohasekai.sfa.update.UpdateTrack import io.nekohasekai.sfa.update.UpdateTrack
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
class UpdateWorker( class UpdateWorker(private val appContext: Context, params: WorkerParameters) : CoroutineWorker(appContext, params) {
private val appContext: Context,
params: WorkerParameters
) : CoroutineWorker(appContext, params) {
companion object { companion object {
private const val WORK_NAME = "AutoUpdate" private const val WORK_NAME = "AutoUpdate"
@@ -37,7 +34,8 @@ class UpdateWorker(
.build() .build()
val workRequest = PeriodicWorkRequestBuilder<UpdateWorker>( val workRequest = PeriodicWorkRequestBuilder<UpdateWorker>(
24, TimeUnit.HOURS 24,
TimeUnit.HOURS,
) )
.setConstraints(constraints) .setConstraints(constraints)
.setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 1, TimeUnit.HOURS) .setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 1, TimeUnit.HOURS)
@@ -46,7 +44,7 @@ class UpdateWorker(
WorkManager.getInstance(context).enqueueUniquePeriodicWork( WorkManager.getInstance(context).enqueueUniquePeriodicWork(
WORK_NAME, WORK_NAME,
ExistingPeriodicWorkPolicy.KEEP, ExistingPeriodicWorkPolicy.KEEP,
workRequest workRequest,
) )
Log.d(TAG, "Auto update scheduled") Log.d(TAG, "Auto update scheduled")
} }

View File

@@ -4,6 +4,12 @@ import android.os.Bundle;
import android.os.IInterface; import android.os.IInterface;
public interface IIntentReceiver extends IInterface { public interface IIntentReceiver extends IInterface {
void performReceive(Intent intent, int resultCode, String data, Bundle extras, void performReceive(
boolean ordered, boolean sticky, int sendingUser); 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 { public interface IIntentSender extends IInterface {
void send(int code, Intent intent, String resolvedType, IBinder whitelistToken, void send(
IIntentReceiver finishedReceiver, String requiredPermission, Bundle options); int code,
Intent intent,
String resolvedType,
IBinder whitelistToken,
IIntentReceiver finishedReceiver,
String requiredPermission,
Bundle options);
abstract class Stub extends Binder implements IIntentSender { abstract class Stub extends Binder implements IIntentSender {
public static IIntentSender asInterface(IBinder binder) { public static IIntentSender asInterface(IBinder binder) {
throw new UnsupportedOperationException(); throw new UnsupportedOperationException();
}
@Override
public IBinder asBinder() {
return this;
}
} }
@Override
public IBinder asBinder() {
return this;
}
}
} }

View File

@@ -7,15 +7,20 @@ import android.os.RemoteException;
public interface IPackageInstaller extends IInterface { 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 { abstract class Stub extends Binder implements IPackageInstaller {
public static IPackageInstaller asInterface(IBinder binder) { public static IPackageInstaller asInterface(IBinder binder) {
throw new UnsupportedOperationException(); throw new UnsupportedOperationException();
}
} }
}
} }

View File

@@ -6,9 +6,9 @@ import android.os.IInterface;
public interface IPackageInstallerSession extends IInterface { public interface IPackageInstallerSession extends IInterface {
abstract class Stub extends Binder implements IPackageInstallerSession { abstract class Stub extends Binder implements IPackageInstallerSession {
public static IPackageInstallerSession asInterface(IBinder binder) { public static IPackageInstallerSession asInterface(IBinder binder) {
throw new UnsupportedOperationException(); throw new UnsupportedOperationException();
}
} }
}
} }

View File

@@ -6,10 +6,8 @@ import android.os.Handler;
import android.os.Looper; import android.os.Looper;
import android.os.RemoteException; import android.os.RemoteException;
import android.util.Log; import android.util.Log;
import androidx.annotation.NonNull; import androidx.annotation.NonNull;
import androidx.annotation.Nullable; import androidx.annotation.Nullable;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Collections; import java.util.Collections;
import java.util.HashMap; import java.util.HashMap;
@@ -26,211 +24,215 @@ import java.util.concurrent.locks.ReentrantLock;
@SuppressWarnings("unchecked") @SuppressWarnings("unchecked")
public final class RemotePreferences implements SharedPreferences { public final class RemotePreferences implements SharedPreferences {
private static final String TAG = "RemotePreferences"; private static final String TAG = "RemotePreferences";
private static final Object CONTENT = new Object(); private static final Object CONTENT = new Object();
private static final Handler HANDLER = new Handler(Looper.getMainLooper()); private static final Handler HANDLER = new Handler(Looper.getMainLooper());
private final XposedService mService; private final XposedService mService;
private final String mGroup; private final String mGroup;
private final Lock mLock = new ReentrantLock(); private final Lock mLock = new ReentrantLock();
private final Map<String, Object> mMap = new ConcurrentHashMap<>(); private final Map<String, Object> mMap = new ConcurrentHashMap<>();
private final Map<OnSharedPreferenceChangeListener, Object> mListeners = Collections.synchronizedMap(new WeakHashMap<>()); 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) { private RemotePreferences(XposedService service, String group) {
this.mService = service; this.mService = service;
this.mGroup = group; 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 void setDeleted() {
static RemotePreferences newInstance(XposedService service, String group) throws RemoteException { this.isDeleted = true;
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() { @Override
this.isDeleted = true; 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 @Override
public Map<String, ?> getAll() { public SharedPreferences.Editor putString(String key, @Nullable String value) {
return new TreeMap<>(mMap); if (value == null) remove(key);
} else put(key, value);
return this;
@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 @Override
public int getInt(String key, int defValue) { public SharedPreferences.Editor putStringSet(String key, @Nullable Set<String> values) {
Integer v = (Integer) mMap.getOrDefault(key, defValue); if (values == null) remove(key);
assert v != null; else put(key, values);
return v; return this;
} }
@Override @Override
public long getLong(String key, long defValue) { public SharedPreferences.Editor putInt(String key, int value) {
Long v = (Long) mMap.getOrDefault(key, defValue); put(key, value);
assert v != null; return this;
return v;
} }
@Override @Override
public float getFloat(String key, float defValue) { public SharedPreferences.Editor putLong(String key, long value) {
Float v = (Float) mMap.getOrDefault(key, defValue); put(key, value);
assert v != null; return this;
return v;
} }
@Override @Override
public boolean getBoolean(String key, boolean defValue) { public SharedPreferences.Editor putFloat(String key, float value) {
Boolean v = (Boolean) mMap.getOrDefault(key, defValue); put(key, value);
assert v != null; return this;
return v;
} }
@Override @Override
public boolean contains(String key) { public SharedPreferences.Editor putBoolean(String key, boolean value) {
return mMap.containsKey(key); put(key, value);
return this;
} }
@Override @Override
public void registerOnSharedPreferenceChangeListener(OnSharedPreferenceChangeListener listener) { public SharedPreferences.Editor remove(String key) {
mListeners.put(listener, CONTENT); mDelete.add(key);
mPut.remove(key);
return this;
} }
@Override @Override
public void unregisterOnSharedPreferenceChangeListener(OnSharedPreferenceChangeListener listener) { public SharedPreferences.Editor clear() {
mListeners.remove(listener); 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 @Override
public Editor edit() { public boolean commit() {
return new Editor(); if (!mLock.tryLock()) return false;
try {
doUpdate(true);
return true;
} finally {
mLock.unlock();
}
} }
public class Editor implements SharedPreferences.Editor { @Override
public void apply() {
private final HashSet<String> mDelete = new HashSet<>(); HANDLER.post(() -> doUpdate(false));
private final HashMap<String, Object> mPut = new HashMap<>();
private void put(String key, @NonNull Object value) {
mDelete.remove(key);
mPut.put(key, value);
}
@Override
public SharedPreferences.Editor putString(String key, @Nullable String value) {
if (value == null) remove(key);
else put(key, value);
return this;
}
@Override
public SharedPreferences.Editor putStringSet(String key, @Nullable Set<String> values) {
if (values == null) remove(key);
else put(key, values);
return this;
}
@Override
public SharedPreferences.Editor putInt(String key, int value) {
put(key, value);
return this;
}
@Override
public SharedPreferences.Editor putLong(String key, long value) {
put(key, value);
return this;
}
@Override
public SharedPreferences.Editor putFloat(String key, float value) {
put(key, value);
return this;
}
@Override
public SharedPreferences.Editor putBoolean(String key, boolean value) {
put(key, value);
return this;
}
@Override
public SharedPreferences.Editor remove(String key) {
mDelete.add(key);
mPut.remove(key);
return this;
}
@Override
public SharedPreferences.Editor clear() {
mDelete.clear();
mPut.clear();
return this;
}
private void doUpdate(boolean throwing) {
mService.deletionLock.readLock().lock();
try {
if (isDeleted) {
throw new IllegalStateException("This preferences group has been deleted");
}
mDelete.forEach(mMap::remove);
mMap.putAll(mPut);
List<String> changes = new ArrayList<>(mDelete.size() + mMap.size());
changes.addAll(mDelete);
changes.addAll(mMap.keySet());
for (String key : changes) {
mListeners.keySet().forEach(listener -> listener.onSharedPreferenceChanged(RemotePreferences.this, key));
}
Bundle bundle = new Bundle();
bundle.putSerializable("delete", mDelete);
bundle.putSerializable("put", mPut);
try {
mService.getRaw().updateRemotePreferences(mGroup, bundle);
} catch (RemoteException e) {
if (throwing) {
throw new RuntimeException(e);
} else {
Log.e(TAG, "Failed to update remote preferences", e);
}
}
} finally {
mService.deletionLock.readLock().unlock();
}
}
@Override
public boolean commit() {
if (!mLock.tryLock()) return false;
try {
doUpdate(true);
return true;
} finally {
mLock.unlock();
}
}
@Override
public void apply() {
HANDLER.post(() -> doUpdate(false));
}
} }
}
} }

View File

@@ -7,58 +7,67 @@ import android.net.Uri;
import android.os.Bundle; import android.os.Bundle;
import android.os.IBinder; import android.os.IBinder;
import android.util.Log; import android.util.Log;
import androidx.annotation.NonNull; import androidx.annotation.NonNull;
import androidx.annotation.Nullable; import androidx.annotation.Nullable;
public final class XposedProvider extends ContentProvider { public final class XposedProvider extends ContentProvider {
private static final String TAG = "XposedProvider"; private static final String TAG = "XposedProvider";
@Override @Override
public boolean onCreate() { public boolean onCreate() {
return false; return false;
} }
@Nullable @Nullable
@Override @Override
public Cursor query(@NonNull Uri uri, @Nullable String[] projection, @Nullable String selection, @Nullable String[] selectionArgs, @Nullable String sortOrder) { public Cursor query(
return null; @NonNull Uri uri,
} @Nullable String[] projection,
@Nullable String selection,
@Nullable String[] selectionArgs,
@Nullable String sortOrder) {
return null;
}
@Nullable @Nullable
@Override @Override
public String getType(@NonNull Uri uri) { public String getType(@NonNull Uri uri) {
return null; return null;
} }
@Nullable @Nullable
@Override @Override
public Uri insert(@NonNull Uri uri, @Nullable ContentValues values) { public Uri insert(@NonNull Uri uri, @Nullable ContentValues values) {
return null; return null;
} }
@Override @Override
public int delete(@NonNull Uri uri, @Nullable String selection, @Nullable String[] selectionArgs) { public int delete(
return 0; @NonNull Uri uri, @Nullable String selection, @Nullable String[] selectionArgs) {
} return 0;
}
@Override @Override
public int update(@NonNull Uri uri, @Nullable ContentValues values, @Nullable String selection, @Nullable String[] selectionArgs) { public int update(
return 0; @NonNull Uri uri,
} @Nullable ContentValues values,
@Nullable String selection,
@Nullable String[] selectionArgs) {
return 0;
}
@Nullable @Nullable
@Override @Override
public Bundle call(@NonNull String method, @Nullable String arg, @Nullable Bundle extras) { public Bundle call(@NonNull String method, @Nullable String arg, @Nullable Bundle extras) {
if (method.equals(IXposedService.SEND_BINDER) && extras != null) { if (method.equals(IXposedService.SEND_BINDER) && extras != null) {
IBinder binder = extras.getBinder("binder"); IBinder binder = extras.getBinder("binder");
if (binder != null) { if (binder != null) {
Log.d(TAG, "binder received: " + binder); Log.d(TAG, "binder received: " + binder);
XposedServiceHelper.onBinderReceived(binder); XposedServiceHelper.onBinderReceived(binder);
} }
return new Bundle(); return new Bundle();
}
return null;
} }
return null;
}
} }

View File

@@ -3,10 +3,8 @@ package io.github.libxposed.service;
import android.content.SharedPreferences; import android.content.SharedPreferences;
import android.os.ParcelFileDescriptor; import android.os.ParcelFileDescriptor;
import android.os.RemoteException; import android.os.RemoteException;
import androidx.annotation.NonNull; import androidx.annotation.NonNull;
import androidx.annotation.Nullable; import androidx.annotation.Nullable;
import java.util.HashMap; import java.util.HashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
@@ -16,363 +14,359 @@ import java.util.concurrent.locks.ReentrantReadWriteLock;
@SuppressWarnings("unused") @SuppressWarnings("unused")
public final class XposedService { public final class XposedService {
public final static class ServiceException extends RuntimeException { public static final class ServiceException extends RuntimeException {
ServiceException(String message) { ServiceException(String message) {
super(message); super(message);
}
ServiceException(RemoteException e) {
super("Xposed service error", e);
}
} }
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 { default void onScopeRequestApproved(String packageName) {}
/**
* Callback when the request notification / window prompted.
*
* @param packageName Package name of requested app
*/
default void onScopeRequestPrompted(String packageName) {
}
/** /**
* Callback when the request is approved. * Callback when the request is denied.
* *
* @param packageName Package name of requested app * @param packageName Package name of requested app
*/ */
default void onScopeRequestApproved(String packageName) { default void onScopeRequestDenied(String packageName) {}
}
/** /**
* Callback when the request is denied. * Callback when the request is timeout or revoked.
* *
* @param packageName Package name of requested app * @param packageName Package name of requested app
*/ */
default void onScopeRequestDenied(String packageName) { default void onScopeRequestTimeout(String packageName) {}
}
/** /**
* Callback when the request is timeout or revoked. * Callback when the request is failed.
* *
* @param packageName Package name of requested app * @param packageName Package name of requested app
*/ * @param message Error message
default void onScopeRequestTimeout(String packageName) { */
} default void onScopeRequestFailed(String packageName, String message) {}
/** private IXposedScopeCallback asInterface() {
* Callback when the request is failed. return scopeCallbacks.computeIfAbsent(
* this,
* @param packageName Package name of requested app (listener) ->
* @param message Error message new IXposedScopeCallback.Stub() {
*/
default void onScopeRequestFailed(String packageName, String message) {
}
private IXposedScopeCallback asInterface() {
return scopeCallbacks.computeIfAbsent(this, (listener) -> new IXposedScopeCallback.Stub() {
@Override @Override
public void onScopeRequestPrompted(String packageName) { public void onScopeRequestPrompted(String packageName) {
listener.onScopeRequestPrompted(packageName); listener.onScopeRequestPrompted(packageName);
} }
@Override @Override
public void onScopeRequestApproved(String packageName) { public void onScopeRequestApproved(String packageName) {
listener.onScopeRequestApproved(packageName); listener.onScopeRequestApproved(packageName);
} }
@Override @Override
public void onScopeRequestDenied(String packageName) { public void onScopeRequestDenied(String packageName) {
listener.onScopeRequestDenied(packageName); listener.onScopeRequestDenied(packageName);
} }
@Override @Override
public void onScopeRequestTimeout(String packageName) { public void onScopeRequestTimeout(String packageName) {
listener.onScopeRequestTimeout(packageName); listener.onScopeRequestTimeout(packageName);
} }
@Override @Override
public void onScopeRequestFailed(String packageName, String message) { public void onScopeRequestFailed(String packageName, String message) {
listener.onScopeRequestFailed(packageName, message); listener.onScopeRequestFailed(packageName, message);
} }
}); });
}
} }
}
public enum Privilege { public enum Privilege {
/** /** Unknown privilege value. */
* Unknown privilege value. FRAMEWORK_PRIVILEGE_UNKNOWN,
*/
FRAMEWORK_PRIVILEGE_UNKNOWN,
/** /** The framework is running as root. */
* The framework is running as root. FRAMEWORK_PRIVILEGE_ROOT,
*/
FRAMEWORK_PRIVILEGE_ROOT,
/** /** The framework is running in a container with a fake system_server. */
* The framework is running in a container with a fake system_server. FRAMEWORK_PRIVILEGE_CONTAINER,
*/
FRAMEWORK_PRIVILEGE_CONTAINER,
/** /** The framework is running as a different app, which may have at most shell permission. */
* The framework is running as a different app, which may have at most shell permission. FRAMEWORK_PRIVILEGE_APP,
*/
FRAMEWORK_PRIVILEGE_APP,
/**
* The framework is embedded in the hooked app, which means {@link #getRemotePreferences} will be null and remote file is unsupported.
*/
FRAMEWORK_PRIVILEGE_EMBEDDED
}
private final IXposedService mService;
private final Map<String, RemotePreferences> mRemotePrefs = new HashMap<>();
final ReentrantReadWriteLock deletionLock = new ReentrantReadWriteLock();
XposedService(IXposedService service) {
mService = service;
}
IXposedService getRaw() {
return mService;
}
/** /**
* Get the Xposed API version of current implementation. * The framework is embedded in the hooked app, which means {@link #getRemotePreferences} will
* * be null and remote file is unsupported.
* @return API version
* @throws ServiceException If the service is dead or an error occurred
*/ */
public int getAPIVersion() { FRAMEWORK_PRIVILEGE_EMBEDDED
try { }
return mService.getAPIVersion();
} catch (RemoteException e) {
throw new ServiceException(e);
}
}
/** private final IXposedService mService;
* Get the Xposed framework name of current implementation. private final Map<String, RemotePreferences> mRemotePrefs = new HashMap<>();
*
* @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);
}
}
/** final ReentrantReadWriteLock deletionLock = new ReentrantReadWriteLock();
* 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);
}
}
/** XposedService(IXposedService service) {
* Get the Xposed framework version code of current implementation. mService = service;
* }
* @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);
}
}
/** IXposedService getRaw() {
* Get the Xposed framework privilege of current implementation. return mService;
* }
* @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. * Get the Xposed API version of current implementation.
* *
* @return Module scope * @return API version
* @throws ServiceException If the service is dead or an error occurred * @throws ServiceException If the service is dead or an error occurred
*/ */
@NonNull public int getAPIVersion() {
public List<String> getScope() { try {
try { return mService.getAPIVersion();
return mService.getScope(); } catch (RemoteException e) {
} catch (RemoteException e) { throw new ServiceException(e);
throw new ServiceException(e);
}
} }
}
/** /**
* Request to add a new app to the module scope. * Get the Xposed framework name of current implementation.
* *
* @param packageName Package name of the app to be added * @return Framework name
* @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
* @throws ServiceException If the service is dead or an error occurred */
*/ @NonNull
public void requestScope(@NonNull String packageName, @NonNull OnScopeEventListener callback) { public String getFrameworkName() {
try { try {
mService.requestScope(packageName, callback.asInterface()); return mService.getFrameworkName();
} catch (RemoteException e) { } catch (RemoteException e) {
throw new ServiceException(e); throw new ServiceException(e);
}
} }
}
/** /**
* Remove an app from the module scope. * Get the Xposed framework version of current implementation.
* *
* @param packageName Package name of the app to be added * @return Framework version
* @return null if successful, or non-null with error message * @throws ServiceException If the service is dead or an error occurred
* @throws ServiceException If the service is dead or an error occurred */
*/ @NonNull
@Nullable public String getFrameworkVersion() {
public String removeScope(@NonNull String packageName) { try {
try { return mService.getFrameworkVersion();
return mService.removeScope(packageName); } catch (RemoteException e) {
} catch (RemoteException e) { throw new ServiceException(e);
throw new ServiceException(e);
}
} }
}
/** /**
* Get remote preferences from Xposed framework. If the group does not exist, it will be created. * Get the Xposed framework version code of current implementation.
* *
* @param group Group name * @return Framework version code
* @return The preferences * @throws ServiceException If the service is dead or an error occurred
* @throws ServiceException If the service is dead or an error occurred */
* @throws UnsupportedOperationException If the framework is embedded public long getFrameworkVersionCode() {
*/ try {
@NonNull return mService.getFrameworkVersionCode();
public SharedPreferences getRemotePreferences(@NonNull String group) { } catch (RemoteException e) {
return mRemotePrefs.computeIfAbsent(group, k -> { throw new ServiceException(e);
try { }
RemotePreferences instance = RemotePreferences.newInstance(this, k); }
if (instance == null) {
throw new ServiceException("Framework returns null"); /**
} * Get the Xposed framework privilege of current implementation.
return instance; *
} catch (RemoteException e) { * @return Framework privilege
if (e.getCause() instanceof UnsupportedOperationException cause) { * @throws ServiceException If the service is dead or an error occurred
throw cause; */
} @NonNull
throw new ServiceException(e); 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. * Delete a group of remote preferences.
* *
* @param group Group name * @param group Group name
* @throws ServiceException If the service is dead or an error occurred * @throws ServiceException If the service is dead or an error occurred
* @throws UnsupportedOperationException If the framework is embedded * @throws UnsupportedOperationException If the framework is embedded
*/ */
public void deleteRemotePreferences(@NonNull String group) { public void deleteRemotePreferences(@NonNull String group) {
deletionLock.writeLock().lock(); deletionLock.writeLock().lock();
try { try {
mService.deleteRemotePreferences(group); mService.deleteRemotePreferences(group);
mRemotePrefs.computeIfPresent(group, (k, v) -> { mRemotePrefs.computeIfPresent(
v.setDeleted(); group,
return null; (k, v) -> {
}); v.setDeleted();
} catch (RemoteException e) { return null;
if (e.getCause() instanceof UnsupportedOperationException cause) { });
throw cause; } catch (RemoteException e) {
} if (e.getCause() instanceof UnsupportedOperationException cause) {
throw new ServiceException(e); throw cause;
} finally { }
deletionLock.writeLock().unlock(); throw new ServiceException(e);
} } finally {
deletionLock.writeLock().unlock();
} }
}
/** /**
* List all files in the module's shared data directory. * List all files in the module's shared data directory.
* *
* @return The file list * @return The file list
* @throws ServiceException If the service is dead or an error occurred * @throws ServiceException If the service is dead or an error occurred
* @throws UnsupportedOperationException If the framework is embedded * @throws UnsupportedOperationException If the framework is embedded
*/ */
@NonNull @NonNull
public String[] listRemoteFiles() { public String[] listRemoteFiles() {
try { try {
String[] files = mService.listRemoteFiles(); String[] files = mService.listRemoteFiles();
if (files == null) throw new ServiceException("Framework returns null"); if (files == null) throw new ServiceException("Framework returns null");
return files; return files;
} catch (RemoteException e) { } catch (RemoteException e) {
if (e.getCause() instanceof UnsupportedOperationException cause) { if (e.getCause() instanceof UnsupportedOperationException cause) {
throw cause; throw cause;
} }
throw new ServiceException(e); throw new ServiceException(e);
}
} }
}
/** /**
* Open a file in the module's shared data directory. The file will be created if not exists. * 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 .. * @param name File name, must not contain path separators and . or ..
* @return The file descriptor * @return The file descriptor
* @throws ServiceException If the service is dead or an error occurred * @throws ServiceException If the service is dead or an error occurred
* @throws UnsupportedOperationException If the framework is embedded * @throws UnsupportedOperationException If the framework is embedded
*/ */
@NonNull @NonNull
public ParcelFileDescriptor openRemoteFile(@NonNull String name) { public ParcelFileDescriptor openRemoteFile(@NonNull String name) {
try { try {
ParcelFileDescriptor file = mService.openRemoteFile(name); ParcelFileDescriptor file = mService.openRemoteFile(name);
if (file == null) throw new ServiceException("Framework returns null"); if (file == null) throw new ServiceException("Framework returns null");
return file; return file;
} catch (RemoteException e) { } catch (RemoteException e) {
if (e.getCause() instanceof UnsupportedOperationException cause) { if (e.getCause() instanceof UnsupportedOperationException cause) {
throw cause; throw cause;
} }
throw new ServiceException(e); throw new ServiceException(e);
}
} }
}
/** /**
* Delete a file in the module's shared data directory. * Delete a file in the module's shared data directory.
* *
* @param name File name, must not contain path separators and . or .. * @param name File name, must not contain path separators and . or ..
* @return true if successful, false if the file does not exist * @return true if successful, false if the file does not exist
* @throws ServiceException If the service is dead or an error occurred * @throws ServiceException If the service is dead or an error occurred
* @throws UnsupportedOperationException If the framework is embedded * @throws UnsupportedOperationException If the framework is embedded
*/ */
public boolean deleteRemoteFile(@NonNull String name) { public boolean deleteRemoteFile(@NonNull String name) {
try { try {
return mService.deleteRemoteFile(name); return mService.deleteRemoteFile(name);
} catch (RemoteException e) { } catch (RemoteException e) {
if (e.getCause() instanceof UnsupportedOperationException cause) { if (e.getCause() instanceof UnsupportedOperationException cause) {
throw cause; throw cause;
} }
throw new ServiceException(e); throw new ServiceException(e);
}
} }
}
} }

View File

@@ -2,9 +2,7 @@ package io.github.libxposed.service;
import android.os.IBinder; import android.os.IBinder;
import android.util.Log; import android.util.Log;
import androidx.annotation.NonNull; import androidx.annotation.NonNull;
import java.util.HashSet; import java.util.HashSet;
import java.util.Iterator; import java.util.Iterator;
import java.util.Set; import java.util.Set;
@@ -12,67 +10,63 @@ import java.util.Set;
@SuppressWarnings("unused") @SuppressWarnings("unused")
public final class XposedServiceHelper { public final class XposedServiceHelper {
/** Callback interface for Xposed service. */
public interface OnServiceListener {
/** /**
* Callback interface for Xposed service. * Callback when the service is connected.<br>
*/ * This method could be called multiple times if multiple Xposed frameworks exist.
public interface OnServiceListener {
/**
* Callback when the service is connected.<br/>
* This method could be called multiple times if multiple Xposed frameworks exist.
*
* @param service Service instance
*/
void onServiceBind(@NonNull XposedService service);
/**
* Callback when the service is dead.
*/
void onServiceDied(@NonNull XposedService service);
}
private static final String TAG = "XposedServiceHelper";
private static final Set<XposedService> mCache = new HashSet<>();
private static OnServiceListener mListener = null;
static void onBinderReceived(IBinder binder) {
if (binder == null) return;
synchronized (mCache) {
try {
XposedService service = new XposedService(IXposedService.Stub.asInterface(binder));
if (mListener == null) {
mCache.add(service);
} else {
binder.linkToDeath(() -> mListener.onServiceDied(service), 0);
mListener.onServiceBind(service);
}
} catch (Throwable t) {
Log.e(TAG, "onBinderReceived", t);
}
}
}
/**
* Register a ServiceListener to receive service binders from Xposed frameworks.<br/>
* This method should only be called once.
* *
* @param listener Listener to register * @param service Service instance
*/ */
public static void registerListener(OnServiceListener listener) { void onServiceBind(@NonNull XposedService service);
synchronized (mCache) {
mListener = listener; /** Callback when the service is dead. */
if (!mCache.isEmpty()) { void onServiceDied(@NonNull XposedService service);
for (Iterator<XposedService> it = mCache.iterator(); it.hasNext(); ) { }
try {
XposedService service = it.next(); private static final String TAG = "XposedServiceHelper";
service.getRaw().asBinder().linkToDeath(() -> mListener.onServiceDied(service), 0); private static final Set<XposedService> mCache = new HashSet<>();
mListener.onServiceBind(service); private static OnServiceListener mListener = null;
} catch (Throwable t) {
Log.e(TAG, "registerListener", t); static void onBinderReceived(IBinder binder) {
it.remove(); if (binder == null) return;
} synchronized (mCache) {
} try {
mCache.clear(); 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.constant.Bugs
import io.nekohasekai.sfa.utils.AppLifecycleObserver import io.nekohasekai.sfa.utils.AppLifecycleObserver
import io.nekohasekai.sfa.utils.HookModuleUpdateNotifier import io.nekohasekai.sfa.utils.HookModuleUpdateNotifier
import io.nekohasekai.sfa.utils.PrivilegeSettingsClient
import io.nekohasekai.sfa.utils.HookStatusClient import io.nekohasekai.sfa.utils.HookStatusClient
import io.nekohasekai.sfa.utils.PrivilegeSettingsClient
import io.nekohasekai.sfa.vendor.Vendor import io.nekohasekai.sfa.vendor.Vendor
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.GlobalScope

View File

@@ -8,7 +8,6 @@ import android.provider.DocumentsContract
import android.provider.DocumentsProvider import android.provider.DocumentsProvider
import android.webkit.MimeTypeMap import android.webkit.MimeTypeMap
import java.io.File import java.io.File
import java.io.FileNotFoundException
class WorkingDirectoryProvider : DocumentsProvider() { class WorkingDirectoryProvider : DocumentsProvider() {
@@ -47,7 +46,7 @@ class WorkingDirectoryProvider : DocumentsProvider() {
add( add(
DocumentsContract.Root.COLUMN_FLAGS, DocumentsContract.Root.COLUMN_FLAGS,
DocumentsContract.Root.FLAG_SUPPORTS_CREATE or 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_ICON, R.mipmap.ic_launcher)
add(DocumentsContract.Root.COLUMN_TITLE, context!!.getString(R.string.app_name)) add(DocumentsContract.Root.COLUMN_TITLE, context!!.getString(R.string.app_name))
@@ -64,11 +63,7 @@ class WorkingDirectoryProvider : DocumentsProvider() {
return result return result
} }
override fun queryChildDocuments( override fun queryChildDocuments(parentDocumentId: String, projection: Array<out String>?, sortOrder: String?): Cursor {
parentDocumentId: String,
projection: Array<out String>?,
sortOrder: String?
): Cursor {
val result = MatrixCursor(projection ?: DEFAULT_DOCUMENT_PROJECTION) val result = MatrixCursor(projection ?: DEFAULT_DOCUMENT_PROJECTION)
val parent = getFileForDocId(parentDocumentId) val parent = getFileForDocId(parentDocumentId)
parent.listFiles()?.forEach { file -> parent.listFiles()?.forEach { file ->
@@ -77,21 +72,13 @@ class WorkingDirectoryProvider : DocumentsProvider() {
return result return result
} }
override fun openDocument( override fun openDocument(documentId: String, mode: String, signal: CancellationSignal?): ParcelFileDescriptor {
documentId: String,
mode: String,
signal: CancellationSignal?
): ParcelFileDescriptor {
val file = getFileForDocId(documentId) val file = getFileForDocId(documentId)
val accessMode = ParcelFileDescriptor.parseMode(mode) val accessMode = ParcelFileDescriptor.parseMode(mode)
return ParcelFileDescriptor.open(file, accessMode) return ParcelFileDescriptor.open(file, accessMode)
} }
override fun createDocument( override fun createDocument(parentDocumentId: String, mimeType: String, displayName: String): String {
parentDocumentId: String,
mimeType: String,
displayName: String
): String {
val parent = getFileForDocId(parentDocumentId) val parent = getFileForDocId(parentDocumentId)
val file = File(parent, displayName) val file = File(parent, displayName)

View File

@@ -8,8 +8,8 @@ import android.os.Build
import android.util.Log import android.util.Log
import android.widget.Toast import android.widget.Toast
import io.nekohasekai.sfa.R import io.nekohasekai.sfa.R
import io.nekohasekai.sfa.database.Settings
import io.nekohasekai.sfa.compose.screen.profileoverride.PerAppProxyScanner import io.nekohasekai.sfa.compose.screen.profileoverride.PerAppProxyScanner
import io.nekohasekai.sfa.database.Settings
import io.nekohasekai.sfa.vendor.PackageQueryManager import io.nekohasekai.sfa.vendor.PackageQueryManager
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@@ -21,10 +21,7 @@ class AppChangeReceiver : BroadcastReceiver() {
private const val TAG = "AppChangeReceiver" private const val TAG = "AppChangeReceiver"
} }
override fun onReceive( override fun onReceive(context: Context, intent: Intent) {
context: Context,
intent: Intent,
) {
Log.d(TAG, "onReceive: ${intent.action}") Log.d(TAG, "onReceive: ${intent.action}")
if (!Settings.perAppProxyEnabled) { if (!Settings.perAppProxyEnabled) {
Log.d(TAG, "per app proxy disabled") Log.d(TAG, "per app proxy disabled")

View File

@@ -12,10 +12,7 @@ import kotlinx.coroutines.withContext
class BootReceiver : BroadcastReceiver() { class BootReceiver : BroadcastReceiver() {
@OptIn(DelicateCoroutinesApi::class) @OptIn(DelicateCoroutinesApi::class)
override fun onReceive( override fun onReceive(context: Context, intent: Intent) {
context: Context,
intent: Intent,
) {
when (intent.action) { when (intent.action) {
Intent.ACTION_BOOT_COMPLETED, Intent.ACTION_MY_PACKAGE_REPLACED -> { 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.libbox.SystemProxyStatus
import io.nekohasekai.sfa.Application import io.nekohasekai.sfa.Application
import io.nekohasekai.sfa.R import io.nekohasekai.sfa.R
import io.nekohasekai.sfa.compose.MainActivity
import io.nekohasekai.sfa.constant.Action import io.nekohasekai.sfa.constant.Action
import io.nekohasekai.sfa.constant.Alert import io.nekohasekai.sfa.constant.Alert
import io.nekohasekai.sfa.constant.Status import io.nekohasekai.sfa.constant.Status
import io.nekohasekai.sfa.database.ProfileManager import io.nekohasekai.sfa.database.ProfileManager
import io.nekohasekai.sfa.database.Settings import io.nekohasekai.sfa.database.Settings
import io.nekohasekai.sfa.ktx.hasPermission import io.nekohasekai.sfa.ktx.hasPermission
import io.nekohasekai.sfa.compose.MainActivity
import io.nekohasekai.sfa.vendor.Vendor import io.nekohasekai.sfa.vendor.Vendor
import kotlinx.coroutines.DelicateCoroutinesApi import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@@ -44,10 +44,7 @@ import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import java.io.File import java.io.File
class BoxService( class BoxService(private val service: Service, private val platformInterface: PlatformInterface) : CommandServerHandler {
private val service: Service,
private val platformInterface: PlatformInterface,
) : CommandServerHandler {
companion object { companion object {
private const val PROFILE_UPDATE_INTERVAL = 15L * 60 * 1000 // 15 minutes in milliseconds private const val PROFILE_UPDATE_INTERVAL = 15L * 60 * 1000 // 15 minutes in milliseconds
private const val TAG = "BoxService" private const val TAG = "BoxService"
@@ -81,10 +78,7 @@ class BoxService(
private var receiverRegistered = false private var receiverRegistered = false
private val receiver = private val receiver =
object : BroadcastReceiver() { object : BroadcastReceiver() {
override fun onReceive( override fun onReceive(context: Context, intent: Intent) {
context: Context,
intent: Intent,
) {
when (intent.action) { when (intent.action) {
Action.SERVICE_CLOSE -> { Action.SERVICE_CLOSE -> {
stopService() stopService()
@@ -316,10 +310,7 @@ class BoxService(
} }
} }
private suspend fun stopAndAlert( private suspend fun stopAndAlert(type: Alert, message: String? = null) {
type: Alert,
message: String? = null,
) {
Settings.startedByUser = false Settings.startedByUser = false
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
if (receiverRegistered) { if (receiverRegistered) {
@@ -368,9 +359,7 @@ class BoxService(
return Service.START_NOT_STICKY return Service.START_NOT_STICKY
} }
internal fun onBind(): IBinder { internal fun onBind(): IBinder = binder
return binder
}
internal fun onDestroy() { internal fun onDestroy() {
binder.close() binder.close()

View File

@@ -13,7 +13,6 @@ import java.io.StringWriter
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
import java.util.Date import java.util.Date
import java.util.Locale import java.util.Locale
import java.util.zip.Deflater
import java.util.zip.ZipEntry import java.util.zip.ZipEntry
import java.util.zip.ZipOutputStream import java.util.zip.ZipOutputStream
@@ -134,11 +133,7 @@ object DebugInfoExporter {
return count return count
} }
private fun addLogEntries( private fun addLogEntries(zip: ZipOutputStream, warnings: MutableList<String>, context: Context): Int {
zip: ZipOutputStream,
warnings: MutableList<String>,
context: Context,
): Int {
var count = 0 var count = 0
if (streamCommandToZip(zip, "logs/logcat.txt", warnings, listOf("logcat", "-d", "-b", "all")) != null) count++ 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++ if (streamCommandToZip(zip, "logs/dmesg.txt", warnings, listOf("dmesg")) != null) count++
@@ -185,11 +180,7 @@ object DebugInfoExporter {
} }
} }
private fun addSystemEntries( private fun addSystemEntries(zip: ZipOutputStream, warnings: MutableList<String>, packageName: String): Int {
zip: ZipOutputStream,
warnings: MutableList<String>,
packageName: String,
): Int {
var count = 0 var count = 0
if (streamCommandToZip(zip, "system/getprop.txt", warnings, listOf("getprop")) != null) count++ if (streamCommandToZip(zip, "system/getprop.txt", warnings, listOf("getprop")) != null) count++
if (streamCommandToZip(zip, "system/uname.txt", warnings, listOf("uname", "-a")) != null) count++ if (streamCommandToZip(zip, "system/uname.txt", warnings, listOf("uname", "-a")) != null) count++
@@ -210,27 +201,28 @@ object DebugInfoExporter {
if (cmdPackages != null) count++ if (cmdPackages != null) count++
if ((cmdPackages == null || cmdPackages.bytes == 0L) && (cmdPackages?.exitCode ?: 1) != 0) { if ((cmdPackages == null || cmdPackages.bytes == 0L) && (cmdPackages?.exitCode ?: 1) != 0) {
if (streamCommandToZip( if (streamCommandToZip(
zip, zip,
"system/packages_pm.txt", "system/packages_pm.txt",
warnings, warnings,
listOf("pm", "list", "packages", "-f"), listOf("pm", "list", "packages", "-f"),
) != null) count++ ) != null
) {
count++
}
} }
if (streamCommandToZip( if (streamCommandToZip(
zip, zip,
"system/dumpsys_package_${packageName}.txt", "system/dumpsys_package_$packageName.txt",
warnings, warnings,
listOf("dumpsys", "package", packageName), listOf("dumpsys", "package", packageName),
) != null) count++ ) != null
) {
count++
}
return count return count
} }
private fun addFileEntry( private fun addFileEntry(zip: ZipOutputStream, file: File, entryName: String, warnings: MutableList<String>): Boolean {
zip: ZipOutputStream,
file: File,
entryName: String,
warnings: MutableList<String>,
): Boolean {
if (!file.isFile) { if (!file.isFile) {
warnings.add("missing file: ${file.path}") warnings.add("missing file: ${file.path}")
return false return false
@@ -262,51 +254,40 @@ object DebugInfoExporter {
zip.closeEntry() zip.closeEntry()
} }
private data class CommandResult( private data class CommandResult(val exitCode: Int, val bytes: Long)
val exitCode: Int,
val bytes: Long,
)
private fun streamCommandToZip( private fun streamCommandToZip(
zip: ZipOutputStream, zip: ZipOutputStream,
entryName: String, entryName: String,
warnings: MutableList<String>, warnings: MutableList<String>,
command: List<String>, command: List<String>,
): CommandResult? { ): CommandResult? = try {
return try { val process = ProcessBuilder(command).redirectErrorStream(true).start()
val process = ProcessBuilder(command).redirectErrorStream(true).start() val entry = ZipEntry(entryName)
val entry = ZipEntry(entryName) zip.putNextEntry(entry)
zip.putNextEntry(entry) var bytes = 0L
var bytes = 0L process.inputStream.use { input ->
process.inputStream.use { input -> val buffer = ByteArray(16 * 1024)
val buffer = ByteArray(16 * 1024) while (true) {
while (true) { val read = input.read(buffer)
val read = input.read(buffer) if (read <= 0) break
if (read <= 0) break zip.write(buffer, 0, read)
zip.write(buffer, 0, read) bytes += 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( private fun buildError(stage: String, detail: String, throwable: Throwable?, warnings: List<String>, outputPath: String?): String {
stage: String,
detail: String,
throwable: Throwable?,
warnings: List<String>,
outputPath: String?,
): String {
val sb = StringBuilder() val sb = StringBuilder()
sb.append("stage=").append(stage).append('\n') sb.append("stage=").append(stage).append('\n')
if (!outputPath.isNullOrBlank()) { if (!outputPath.isNullOrBlank()) {

View File

@@ -60,103 +60,102 @@ object DefaultNetworkListener {
val listeners = mutableMapOf<Any, (Network?) -> Unit>() val listeners = mutableMapOf<Any, (Network?) -> Unit>()
var network: Network? = null var network: Network? = null
val pendingRequests = arrayListOf<NetworkMessage.Get>() val pendingRequests = arrayListOf<NetworkMessage.Get>()
for (message in channel) when (message) { for (message in channel) {
is NetworkMessage.Start -> { when (message) {
if (listeners.isEmpty()) register() is NetworkMessage.Start -> {
listeners[message.key] = message.listener if (listeners.isEmpty()) register()
if (network != null) message.listener(network) 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()
} }
is NetworkMessage.Put -> { is NetworkMessage.Get -> {
network = message.network check(listeners.isNotEmpty()) { "Getting network without any listeners is not supported" }
pendingRequests.forEach { it.response.complete(message.network) } if (network == null) {
pendingRequests.clear() pendingRequests += message
listeners.values.forEach { it(network) } } else {
} message.response.complete(
is NetworkMessage.Update ->
if (network == message.network) {
listeners.values.forEach {
it(
network, network,
) )
} }
} }
is NetworkMessage.Lost -> is NetworkMessage.Stop ->
if (network == message.network) { if (listeners.isNotEmpty() &&
network = null // was not empty
listeners.values.forEach { it(null) } 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( suspend fun start(key: Any, listener: (Network?) -> Unit) = networkActor.send(
key: Any,
listener: (Network?) -> Unit,
) = networkActor.send(
NetworkMessage.Start( NetworkMessage.Start(
key, key,
listener, listener,
), ),
) )
suspend fun get(): Network = if (fallback) @TargetApi(23) { suspend fun get(): Network = if (fallback) {
@TargetApi(23)
Application.connectivity.activeNetwork Application.connectivity.activeNetwork
?: error("missing default network") // failed to listen, return current if available ?: error("missing default network") // failed to listen, return current if available
} else NetworkMessage.Get().run { } else {
networkActor.send(this) NetworkMessage.Get().run {
response.await() networkActor.send(this)
response.await()
}
} }
suspend fun stop(key: Any) = networkActor.send(NetworkMessage.Stop(key)) suspend fun stop(key: Any) = networkActor.send(NetworkMessage.Stop(key))
// NB: this runs in ConnectivityThread, and this behavior cannot be changed until API 26 // NB: this runs in ConnectivityThread, and this behavior cannot be changed until API 26
private object Callback : ConnectivityManager.NetworkCallback() { private object Callback : ConnectivityManager.NetworkCallback() {
override fun onAvailable(network: Network) = override fun onAvailable(network: Network) = runBlocking {
runBlocking { networkActor.send(
networkActor.send( NetworkMessage.Put(
NetworkMessage.Put( network,
network, ),
), )
) }
}
override fun onCapabilitiesChanged( override fun onCapabilitiesChanged(network: Network, networkCapabilities: NetworkCapabilities) {
network: Network,
networkCapabilities: NetworkCapabilities,
) {
// it's a good idea to refresh capabilities // it's a good idea to refresh capabilities
runBlocking { networkActor.send(NetworkMessage.Update(network)) } runBlocking { networkActor.send(NetworkMessage.Update(network)) }
} }
override fun onLost(network: Network) = override fun onLost(network: Network) = runBlocking {
runBlocking { networkActor.send(
networkActor.send( NetworkMessage.Lost(
NetworkMessage.Lost( network,
network, ),
), )
) }
}
} }
private var fallback = false private var fallback = false

View File

@@ -40,9 +40,7 @@ object DefaultNetworkMonitor {
checkDefaultInterfaceUpdate(defaultNetwork) checkDefaultInterfaceUpdate(defaultNetwork)
} }
private fun checkDefaultInterfaceUpdate( private fun checkDefaultInterfaceUpdate(newNetwork: Network?) {
newNetwork: Network?
) {
val listener = listener ?: return val listener = listener ?: return
if (newNetwork != null) { if (newNetwork != null) {
val interfaceName = val interfaceName =
@@ -61,5 +59,4 @@ object DefaultNetworkMonitor {
listener.updateDefaultInterface("", -1, false, false) listener.updateDefaultInterface("", -1, false, false)
} }
} }
}
}

View File

@@ -19,15 +19,10 @@ import kotlin.coroutines.suspendCoroutine
object LocalResolver : LocalDNSTransport { object LocalResolver : LocalDNSTransport {
private const val RCODE_NXDOMAIN = 3 private const val RCODE_NXDOMAIN = 3
override fun raw(): Boolean { override fun raw(): Boolean = Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q
return Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q
}
@RequiresApi(Build.VERSION_CODES.Q) @RequiresApi(Build.VERSION_CODES.Q)
override fun exchange( override fun exchange(ctx: ExchangeContext, message: ByteArray) {
ctx: ExchangeContext,
message: ByteArray,
) {
return runBlocking { return runBlocking {
val defaultNetwork = DefaultNetworkMonitor.require() val defaultNetwork = DefaultNetworkMonitor.require()
suspendCoroutine { continuation -> suspendCoroutine { continuation ->
@@ -35,10 +30,7 @@ object LocalResolver : LocalDNSTransport {
ctx.onCancel(signal::cancel) ctx.onCancel(signal::cancel)
val callback = val callback =
object : DnsResolver.Callback<ByteArray> { object : DnsResolver.Callback<ByteArray> {
override fun onAnswer( override fun onAnswer(answer: ByteArray, rcode: Int) {
answer: ByteArray,
rcode: Int,
) {
if (rcode == 0) { if (rcode == 0) {
ctx.rawSuccess(answer) ctx.rawSuccess(answer)
} else { } else {
@@ -70,11 +62,7 @@ object LocalResolver : LocalDNSTransport {
} }
} }
override fun lookup( override fun lookup(ctx: ExchangeContext, network: String, domain: String) {
ctx: ExchangeContext,
network: String,
domain: String,
) {
return runBlocking { return runBlocking {
val defaultNetwork = DefaultNetworkMonitor.require() val defaultNetwork = DefaultNetworkMonitor.require()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
@@ -84,10 +72,7 @@ object LocalResolver : LocalDNSTransport {
val callback = val callback =
object : DnsResolver.Callback<Collection<InetAddress>> { object : DnsResolver.Callback<Collection<InetAddress>> {
@Suppress("ThrowableNotThrown") @Suppress("ThrowableNotThrown")
override fun onAnswer( override fun onAnswer(answer: Collection<InetAddress>, rcode: Int) {
answer: Collection<InetAddress>,
rcode: Int,
) {
if (rcode == 0) { if (rcode == 0) {
ctx.success( ctx.success(
(answer as Collection<InetAddress?>).mapNotNull { it?.hostAddress } (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.Parcel;
import android.os.Parcelable; import android.os.Parcelable;
import androidx.annotation.NonNull; import androidx.annotation.NonNull;
import androidx.annotation.Nullable; import androidx.annotation.Nullable;
public class LogEntry implements Parcelable { public class LogEntry implements Parcelable {
public static final int LEVEL_DEBUG = 0; public static final int LEVEL_DEBUG = 0;
public static final int LEVEL_INFO = 1; public static final int LEVEL_INFO = 1;
public static final int LEVEL_WARN = 2; public static final int LEVEL_WARN = 2;
public static final int LEVEL_ERROR = 3; public static final int LEVEL_ERROR = 3;
public final int level; public final int level;
public final long timestamp; public final long timestamp;
@NonNull @NonNull public final String source;
public final String source; @NonNull public final String message;
@NonNull @Nullable public final String stackTrace;
public final String message;
@Nullable
public final String stackTrace;
public LogEntry(int level, long timestamp, @NonNull String source, @NonNull String message, @Nullable String stackTrace) { public LogEntry(
this.level = level; int level,
this.timestamp = timestamp; long timestamp,
this.source = source; @NonNull String source,
this.message = message; @NonNull String message,
this.stackTrace = stackTrace; @Nullable String stackTrace) {
} this.level = level;
this.timestamp = timestamp;
this.source = source;
this.message = message;
this.stackTrace = stackTrace;
}
protected LogEntry(Parcel in) { protected LogEntry(Parcel in) {
level = in.readInt(); level = in.readInt();
timestamp = in.readLong(); timestamp = in.readLong();
source = in.readString(); source = in.readString();
message = in.readString(); message = in.readString();
stackTrace = in.readString(); stackTrace = in.readString();
} }
@Override @Override
public void writeToParcel(@NonNull Parcel dest, int flags) { public void writeToParcel(@NonNull Parcel dest, int flags) {
dest.writeInt(level); dest.writeInt(level);
dest.writeLong(timestamp); dest.writeLong(timestamp);
dest.writeString(source); dest.writeString(source);
dest.writeString(message); dest.writeString(message);
dest.writeString(stackTrace); dest.writeString(stackTrace);
} }
@Override @Override
public int describeContents() { public int describeContents() {
return 0; return 0;
} }
public static final Creator<LogEntry> CREATOR = new Creator<>() { public static final Creator<LogEntry> CREATOR =
new Creator<>() {
@Override @Override
public LogEntry createFromParcel(Parcel in) { public LogEntry createFromParcel(Parcel in) {
return new LogEntry(in); return new LogEntry(in);
} }
@Override @Override
public LogEntry[] newArray(int size) { 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.Parcel;
import android.os.Parcelable; import android.os.Parcelable;
import androidx.annotation.NonNull; import androidx.annotation.NonNull;
public class PackageEntry implements Parcelable { public class PackageEntry implements Parcelable {
@NonNull @NonNull public final String packageName;
public final String packageName;
public PackageEntry(@NonNull String packageName) { public PackageEntry(@NonNull String packageName) {
this.packageName = packageName; this.packageName = packageName;
} }
protected PackageEntry(Parcel in) { protected PackageEntry(Parcel in) {
packageName = in.readString(); packageName = in.readString();
} }
@Override @Override
public void writeToParcel(@NonNull Parcel dest, int flags) { public void writeToParcel(@NonNull Parcel dest, int flags) {
dest.writeString(packageName); dest.writeString(packageName);
} }
@Override @Override
public int describeContents() { public int describeContents() {
return 0; return 0;
} }
public static final Creator<PackageEntry> CREATOR = new Creator<>() { public static final Creator<PackageEntry> CREATOR =
new Creator<>() {
@Override @Override
public PackageEntry createFromParcel(Parcel in) { public PackageEntry createFromParcel(Parcel in) {
return new PackageEntry(in); return new PackageEntry(in);
} }
@Override @Override
public PackageEntry[] newArray(int size) { 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.Parcel;
import android.os.Parcelable; import android.os.Parcelable;
import android.os.RemoteException; import android.os.RemoteException;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; import java.util.List;
public class ParceledListSlice<T extends Parcelable> implements Parcelable { 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) { public ParceledListSlice(List<T> list) {
mList = 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) { int i = 0;
final int n = in.readInt(); while (i < n) {
mList = new ArrayList<>(n); if (in.readInt() == 0) {
if (n <= 0) { break;
return; }
} @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; public List<T> getList() {
while (i < n) { return mList;
if (in.readInt() == 0) { }
break;
} @Override
@SuppressWarnings("unchecked") public int describeContents() {
T item = (T) in.readParcelable(loader); int contents = 0;
mList.add(item); for (int i = 0; i < mList.size(); i++) {
i++; contents |= mList.get(i).describeContents();
} }
if (i >= n) { return contents;
return; }
}
final IBinder retriever = in.readStrongBinder(); @Override
while (i < n) { public void writeToParcel(Parcel dest, int flags) {
Parcel data = Parcel.obtain(); final int n = mList.size();
Parcel reply = Parcel.obtain(); dest.writeInt(n);
data.writeInt(i); if (n <= 0) {
try { return;
retriever.transact(IBinder.FIRST_CALL_TRANSACTION, data, reply, 0); }
} catch (RemoteException e) { int i = 0;
reply.recycle(); while (i < n && dest.dataSize() < MAX_IPC_SIZE) {
data.recycle(); dest.writeInt(1);
return; dest.writeParcelable(mList.get(i), flags);
} i++;
while (i < n && reply.readInt() != 0) { }
@SuppressWarnings("unchecked") if (i < n) {
T item = (T) reply.readParcelable(loader); dest.writeInt(0);
mList.add(item); 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++; i++;
}
if (i < n) {
reply.writeInt(0);
}
return true;
} }
reply.recycle(); };
data.recycle(); dest.writeStrongBinder(retriever);
}
} }
}
public List<T> getList() { public static final Parcelable.ClassLoaderCreator<ParceledListSlice> CREATOR =
return mList; new Parcelable.ClassLoaderCreator<ParceledListSlice>() {
} @Override
public ParceledListSlice createFromParcel(Parcel in) {
@Override return new ParceledListSlice(in, null);
public int describeContents() {
int contents = 0;
for (int i = 0; i < mList.size(); i++) {
contents |= mList.get(i).describeContents();
} }
return contents;
}
@Override @Override
public void writeToParcel(Parcel dest, int flags) { public ParceledListSlice createFromParcel(Parcel in, ClassLoader loader) {
final int n = mList.size(); return new ParceledListSlice(in, loader);
dest.writeInt(n);
if (n <= 0) {
return;
} }
int i = 0;
while (i < n && dest.dataSize() < MAX_IPC_SIZE) { @Override
dest.writeInt(1); public ParceledListSlice[] newArray(int size) {
dest.writeParcelable(mList.get(i), flags); return new ParceledListSlice[size];
i++;
} }
if (i < n) { };
dest.writeInt(0);
final int start = i;
Binder retriever = new Binder() {
@Override
protected boolean onTransact(int code, Parcel data, Parcel reply, int flags)
throws RemoteException {
if (code != FIRST_CALL_TRANSACTION) {
return super.onTransact(code, data, reply, flags);
}
int i = data.readInt();
if (i < start || i > n) {
return false;
}
while (i < n && reply.dataSize() < MAX_IPC_SIZE) {
reply.writeInt(1);
reply.writeParcelable(mList.get(i), flags);
i++;
}
if (i < n) {
reply.writeInt(0);
}
return true;
}
};
dest.writeStrongBinder(retriever);
}
}
public static final Parcelable.ClassLoaderCreator<ParceledListSlice> CREATOR =
new Parcelable.ClassLoaderCreator<ParceledListSlice>() {
@Override
public ParceledListSlice createFromParcel(Parcel in) {
return new ParceledListSlice(in, null);
}
@Override
public ParceledListSlice createFromParcel(Parcel in, ClassLoader loader) {
return new ParceledListSlice(in, loader);
}
@Override
public ParceledListSlice[] newArray(int size) {
return new ParceledListSlice[size];
}
};
} }

View File

@@ -27,9 +27,7 @@ import kotlin.io.encoding.ExperimentalEncodingApi
import io.nekohasekai.libbox.NetworkInterface as LibboxNetworkInterface import io.nekohasekai.libbox.NetworkInterface as LibboxNetworkInterface
interface PlatformInterfaceWrapper : PlatformInterface { interface PlatformInterfaceWrapper : PlatformInterface {
override fun usePlatformAutoDetectInterfaceControl(): Boolean { override fun usePlatformAutoDetectInterfaceControl(): Boolean = true
return true
}
override fun autoDetectInterfaceControl(fd: Int) { override fun autoDetectInterfaceControl(fd: Int) {
} }
@@ -38,9 +36,7 @@ interface PlatformInterfaceWrapper : PlatformInterface {
error("invalid argument") error("invalid argument")
} }
override fun useProcFS(): Boolean { override fun useProcFS(): Boolean = Build.VERSION.SDK_INT < Build.VERSION_CODES.Q
return Build.VERSION.SDK_INT < Build.VERSION_CODES.Q
}
@RequiresApi(Build.VERSION_CODES.Q) @RequiresApi(Build.VERSION_CODES.Q)
override fun findConnectionOwner( override fun findConnectionOwner(
@@ -136,13 +132,9 @@ interface PlatformInterfaceWrapper : PlatformInterface {
return InterfaceArray(interfaces.iterator()) return InterfaceArray(interfaces.iterator())
} }
override fun underNetworkExtension(): Boolean { override fun underNetworkExtension(): Boolean = false
return false
}
override fun includeAllNetworks(): Boolean { override fun includeAllNetworks(): Boolean = false
return false
}
override fun clearDNSCache() { override fun clearDNSCache() {
} }
@@ -161,9 +153,7 @@ interface PlatformInterfaceWrapper : PlatformInterface {
return WIFIState(ssid, wifiInfo.bssid) return WIFIState(ssid, wifiInfo.bssid)
} }
override fun localDNSTransport(): LocalDNSTransport? { override fun localDNSTransport(): LocalDNSTransport? = LocalResolver
return LocalResolver
}
@OptIn(ExperimentalEncodingApi::class) @OptIn(ExperimentalEncodingApi::class)
override fun systemCertificates(): StringIterator { override fun systemCertificates(): StringIterator {
@@ -182,15 +172,10 @@ interface PlatformInterfaceWrapper : PlatformInterface {
return StringArray(certificates.iterator()) return StringArray(certificates.iterator())
} }
private class InterfaceArray(private val iterator: Iterator<LibboxNetworkInterface>) : private class InterfaceArray(private val iterator: Iterator<LibboxNetworkInterface>) : NetworkInterfaceIterator {
NetworkInterfaceIterator { override fun hasNext(): Boolean = iterator.hasNext()
override fun hasNext(): Boolean {
return iterator.hasNext()
}
override fun next(): LibboxNetworkInterface { override fun next(): LibboxNetworkInterface = iterator.next()
return iterator.next()
}
} }
class StringArray(private val iterator: Iterator<String>) : StringIterator { class StringArray(private val iterator: Iterator<String>) : StringIterator {
@@ -199,21 +184,15 @@ interface PlatformInterfaceWrapper : PlatformInterface {
return 0 return 0
} }
override fun hasNext(): Boolean { override fun hasNext(): Boolean = iterator.hasNext()
return iterator.hasNext()
}
override fun next(): String { override fun next(): String = iterator.next()
return iterator.next()
}
} }
private fun InterfaceAddress.toPrefix(): String { private fun InterfaceAddress.toPrefix(): String = if (address is Inet6Address) {
return if (address is Inet6Address) { "${Inet6Address.getByAddress(address.address).hostAddress}/$networkPrefixLength"
"${Inet6Address.getByAddress(address.address).hostAddress}/$networkPrefixLength" } else {
} else { "${address.hostAddress}/$networkPrefixLength"
"${address.hostAddress}/$networkPrefixLength"
}
} }
private val NetworkInterface.flags: Int private val NetworkInterface.flags: Int

View File

@@ -4,14 +4,12 @@ import android.app.Service
import android.content.Intent import android.content.Intent
import io.nekohasekai.libbox.Notification import io.nekohasekai.libbox.Notification
class ProxyService : Service(), PlatformInterfaceWrapper { class ProxyService :
Service(),
PlatformInterfaceWrapper {
private val service = BoxService(this, this) private val service = BoxService(this, this)
override fun onStartCommand( override fun onStartCommand(intent: Intent?, flags: Int, startId: Int) = service.onStartCommand()
intent: Intent?,
flags: Int,
startId: Int,
) = service.onStartCommand()
override fun onBind(intent: Intent) = service.onBind() override fun onBind(intent: Intent) = service.onBind()

View File

@@ -25,7 +25,7 @@ object RootClient {
Shell.setDefaultBuilder( Shell.setDefaultBuilder(
Shell.Builder.create() Shell.Builder.create()
.setFlags(Shell.FLAG_MOUNT_MASTER) .setFlags(Shell.FLAG_MOUNT_MASTER)
.setTimeout(10) .setTimeout(10),
) )
} }
@@ -95,6 +95,7 @@ object RootClient {
val svc = bindService() val svc = bindService()
return try { return try {
val slice = svc.getInstalledPackages(flags, userId) val slice = svc.getInstalledPackages(flags, userId)
@Suppress("UNCHECKED_CAST") @Suppress("UNCHECKED_CAST")
val list = slice.list as List<PackageInfo> val list = slice.list as List<PackageInfo>
list list

View File

@@ -1,13 +1,12 @@
package io.nekohasekai.sfa.bg package io.nekohasekai.sfa.bg
import io.nekohasekai.sfa.vendor.PrivilegedServiceUtils
import android.content.Intent import android.content.Intent
import android.content.pm.PackageInfo import android.content.pm.PackageInfo
import android.os.IBinder import android.os.IBinder
import android.os.ParcelFileDescriptor import android.os.ParcelFileDescriptor
import com.topjohnwu.superuser.ipc.RootService import com.topjohnwu.superuser.ipc.RootService
import io.nekohasekai.sfa.BuildConfig import io.nekohasekai.sfa.BuildConfig
import io.nekohasekai.sfa.vendor.PrivilegedServiceUtils
import java.io.IOException import java.io.IOException
class RootServer : RootService() { class RootServer : RootService() {
@@ -17,10 +16,7 @@ class RootServer : RootService() {
stopSelf() stopSelf()
} }
override fun getInstalledPackages( override fun getInstalledPackages(flags: Int, userId: Int): ParceledListSlice<PackageInfo> {
flags: Int,
userId: Int
): ParceledListSlice<PackageInfo> {
val allPackages = PrivilegedServiceUtils.getInstalledPackages(flags, userId) val allPackages = PrivilegedServiceUtils.getInstalledPackages(flags, userId)
return ParceledListSlice(allPackages) return ParceledListSlice(allPackages)
} }
@@ -30,16 +26,12 @@ class RootServer : RootService() {
PrivilegedServiceUtils.installPackage(apk, size, userId) PrivilegedServiceUtils.installPackage(apk, size, userId)
} }
override fun exportDebugInfo(outputPath: String?): String { override fun exportDebugInfo(outputPath: String?): String = DebugInfoExporter.export(
return DebugInfoExporter.export( this@RootServer,
this@RootServer, outputPath!!,
outputPath!!, BuildConfig.APPLICATION_ID,
BuildConfig.APPLICATION_ID )
)
}
} }
override fun onBind(intent: Intent): IBinder { override fun onBind(intent: Intent): IBinder = binder
return binder
}
} }

View File

@@ -43,9 +43,7 @@ class ServiceBinder(private val status: MutableLiveData<Status>) : IService.Stub
} }
} }
override fun getStatus(): Int { override fun getStatus(): Int = (status.value ?: Status.Stopped).ordinal
return (status.value ?: Status.Stopped).ordinal
}
override fun registerCallback(callback: IServiceCallback) { override fun registerCallback(callback: IServiceCallback) {
callbacks.register(callback) callbacks.register(callback)

View File

@@ -18,11 +18,7 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
class ServiceConnection( class ServiceConnection(private val context: Context, callback: Callback, private val register: Boolean = true) : ServiceConnection {
private val context: Context,
callback: Callback,
private val register: Boolean = true,
) : ServiceConnection {
companion object { companion object {
private const val TAG = "ServiceConnection" private const val TAG = "ServiceConnection"
} }
@@ -66,10 +62,7 @@ class ServiceConnection(
Log.d(TAG, "request reconnect") Log.d(TAG, "request reconnect")
} }
override fun onServiceConnected( override fun onServiceConnected(name: ComponentName, binder: IBinder) {
name: ComponentName,
binder: IBinder,
) {
val service = IService.Stub.asInterface(binder) val service = IService.Stub.asInterface(binder)
this.service = service this.service = service
try { try {
@@ -98,10 +91,7 @@ class ServiceConnection(
interface Callback { interface Callback {
fun onServiceStatusChanged(status: Status) fun onServiceStatusChanged(status: Status)
fun onServiceAlert( fun onServiceAlert(type: Alert, message: String?) {
type: Alert,
message: String?,
) {
} }
} }
@@ -110,10 +100,7 @@ class ServiceConnection(
callback.onServiceStatusChanged(Status.values()[status]) callback.onServiceStatusChanged(Status.values()[status])
} }
override fun onServiceAlert( override fun onServiceAlert(type: Int, message: String?) {
type: Int,
message: String?,
) {
callback.onServiceAlert(Alert.values()[type], message) 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.Libbox
import io.nekohasekai.libbox.StatusMessage import io.nekohasekai.libbox.StatusMessage
import io.nekohasekai.sfa.Application import io.nekohasekai.sfa.Application
import io.nekohasekai.sfa.compose.MainActivity
import io.nekohasekai.sfa.R import io.nekohasekai.sfa.R
import io.nekohasekai.sfa.compose.MainActivity
import io.nekohasekai.sfa.constant.Action import io.nekohasekai.sfa.constant.Action
import io.nekohasekai.sfa.constant.Status import io.nekohasekai.sfa.constant.Status
import io.nekohasekai.sfa.database.Settings import io.nekohasekai.sfa.database.Settings
@@ -27,10 +27,9 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
class ServiceNotification( class ServiceNotification(private val status: MutableLiveData<Status>, private val service: Service) :
private val status: MutableLiveData<Status>, BroadcastReceiver(),
private val service: Service, CommandClient.Handler {
) : BroadcastReceiver(), CommandClient.Handler {
companion object { companion object {
private const val notificationId = 1 private const val notificationId = 1
private const val notificationChannel = "service" private const val notificationChannel = "service"
@@ -82,10 +81,7 @@ class ServiceNotification(
} }
} }
fun show( fun show(lastProfileName: String, @StringRes contentTextId: Int) {
lastProfileName: String,
@StringRes contentTextId: Int,
) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
Application.notification.createNotificationChannel( Application.notification.createNotificationChannel(
NotificationChannel( NotificationChannel(
@@ -132,10 +128,7 @@ class ServiceNotification(
) )
} }
override fun onReceive( override fun onReceive(context: Context, intent: Intent) {
context: Context,
intent: Intent,
) {
when (intent.action) { when (intent.action) {
Intent.ACTION_SCREEN_ON -> { Intent.ACTION_SCREEN_ON -> {
commandClient.connect() commandClient.connect()

View File

@@ -8,7 +8,9 @@ import androidx.annotation.RequiresApi
import io.nekohasekai.sfa.constant.Status import io.nekohasekai.sfa.constant.Status
@RequiresApi(24) @RequiresApi(24)
class TileService : TileService(), ServiceConnection.Callback { class TileService :
TileService(),
ServiceConnection.Callback {
private val connection = ServiceConnection(this, this) private val connection = ServiceConnection(this, this)
override fun onServiceStatusChanged(status: Status) { override fun onServiceStatusChanged(status: Status) {

View File

@@ -59,10 +59,7 @@ class UpdateProfileWork {
} }
} }
class UpdateTask( class UpdateTask(appContext: Context, params: WorkerParameters) : CoroutineWorker(appContext, params) {
appContext: Context,
params: WorkerParameters,
) : CoroutineWorker(appContext, params) {
override suspend fun doWork(): Result { override suspend fun doWork(): Result {
var selectedProfileUpdated = false var selectedProfileUpdated = false
val remoteProfiles = val remoteProfiles =

View File

@@ -15,18 +15,16 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
class VPNService : VpnService(), PlatformInterfaceWrapper { class VPNService :
VpnService(),
PlatformInterfaceWrapper {
companion object { companion object {
private const val TAG = "VPNService" private const val TAG = "VPNService"
} }
private val service = BoxService(this, this) private val service = BoxService(this, this)
override fun onStartCommand( override fun onStartCommand(intent: Intent?, flags: Int, startId: Int) = service.onStartCommand()
intent: Intent?,
flags: Int,
startId: Int,
) = service.onStartCommand()
override fun onBind(intent: Intent): IBinder { override fun onBind(intent: Intent): IBinder {
val binder = super.onBind(intent) val binder = super.onBind(intent)

View File

@@ -41,9 +41,9 @@ fun LineChart(
Canvas( Canvas(
modifier = modifier =
modifier modifier
.fillMaxWidth() .fillMaxWidth()
.height(80.dp), .height(80.dp),
) { ) {
val width = size.width val width = size.width
val height = size.height val height = size.height
@@ -96,11 +96,11 @@ fun LineChart(
path = path, path = path,
color = lineColor, color = lineColor,
style = style =
Stroke( Stroke(
width = 2.dp.toPx(), width = 2.dp.toPx(),
cap = StrokeCap.Round, cap = StrokeCap.Round,
join = StrokeJoin.Round, join = StrokeJoin.Round,
), ),
) )
// Draw gradient fill under the line // 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.animation.scaleOut
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues 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.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding 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.Icons
import androidx.compose.material.icons.filled.PlayArrow import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.filled.Stop import androidx.compose.material.icons.filled.Stop
import androidx.compose.material.icons.filled.UnfoldLess import androidx.compose.material.icons.filled.UnfoldLess
import androidx.compose.material.icons.filled.UnfoldMore import androidx.compose.material.icons.filled.UnfoldMore
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.AlertDialog
import androidx.compose.material3.CircularProgressIndicator
import dev.jeziellago.compose.markdowntext.MarkdownText
import androidx.compose.material3.Badge 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.BadgedBox
import androidx.compose.material3.ExtendedFloatingActionButton import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExtendedFloatingActionButton
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
@@ -53,9 +48,9 @@ import androidx.compose.material3.NavigationBarItem
import androidx.compose.material3.NavigationRail import androidx.compose.material3.NavigationRail
import androidx.compose.material3.NavigationRailItem import androidx.compose.material3.NavigationRailItem
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.material3.rememberModalBottomSheetState
@@ -69,9 +64,12 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.res.stringResource 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.core.content.ContextCompat
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.ViewModelProvider
@@ -81,6 +79,7 @@ import androidx.navigation.NavDestination.Companion.hierarchy
import androidx.navigation.NavGraph.Companion.findStartDestination import androidx.navigation.NavGraph.Companion.findStartDestination
import androidx.navigation.compose.currentBackStackEntryAsState import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.navigation.compose.rememberNavController import androidx.navigation.compose.rememberNavController
import dev.jeziellago.compose.markdowntext.MarkdownText
import io.nekohasekai.libbox.Libbox import io.nekohasekai.libbox.Libbox
import io.nekohasekai.sfa.Application import io.nekohasekai.sfa.Application
import io.nekohasekai.sfa.BuildConfig 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.SelectableMessageDialog
import io.nekohasekai.sfa.compose.base.UiEvent import io.nekohasekai.sfa.compose.base.UiEvent
import io.nekohasekai.sfa.compose.component.ServiceStatusBar import io.nekohasekai.sfa.compose.component.ServiceStatusBar
import io.nekohasekai.sfa.compose.component.UptimeText
import io.nekohasekai.sfa.compose.component.UpdateAvailableDialog 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.NewProfileArgs
import io.nekohasekai.sfa.compose.navigation.ProfileRoutes import io.nekohasekai.sfa.compose.navigation.ProfileRoutes
import io.nekohasekai.sfa.compose.navigation.SFANavHost import io.nekohasekai.sfa.compose.navigation.SFANavHost
import io.nekohasekai.sfa.compose.navigation.Screen import io.nekohasekai.sfa.compose.navigation.Screen
import io.nekohasekai.sfa.compose.navigation.bottomNavigationScreens import io.nekohasekai.sfa.compose.navigation.bottomNavigationScreens
import io.nekohasekai.sfa.compose.topbar.LocalTopBarController
import io.nekohasekai.sfa.compose.topbar.TopBarEntry
import io.nekohasekai.sfa.compose.topbar.TopBarController
import io.nekohasekai.sfa.compose.screen.dashboard.CardGroup
import io.nekohasekai.sfa.compose.screen.dashboard.DashboardViewModel
import io.nekohasekai.sfa.compose.screen.dashboard.GroupsCard
import io.nekohasekai.sfa.compose.screen.connections.ConnectionDetailsScreen import io.nekohasekai.sfa.compose.screen.connections.ConnectionDetailsScreen
import io.nekohasekai.sfa.compose.screen.connections.ConnectionsPage import io.nekohasekai.sfa.compose.screen.connections.ConnectionsPage
import io.nekohasekai.sfa.compose.screen.connections.ConnectionsViewModel 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.dashboard.groups.GroupsViewModel
import io.nekohasekai.sfa.compose.screen.log.LogViewModel import io.nekohasekai.sfa.compose.screen.log.LogViewModel
import io.nekohasekai.sfa.compose.theme.SFATheme 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.Alert
import io.nekohasekai.sfa.constant.ServiceMode import io.nekohasekai.sfa.constant.ServiceMode
import io.nekohasekai.sfa.constant.Status 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.update.UpdateState
import io.nekohasekai.sfa.vendor.Vendor import io.nekohasekai.sfa.vendor.Vendor
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
class MainActivity : ComponentActivity(), ServiceConnection.Callback { class MainActivity :
ComponentActivity(),
ServiceConnection.Callback {
private val connection = ServiceConnection(this, this) private val connection = ServiceConnection(this, this)
private lateinit var dashboardViewModel: DashboardViewModel private lateinit var dashboardViewModel: DashboardViewModel
private var currentServiceStatus by mutableStateOf(Status.Stopped) private var currentServiceStatus by mutableStateOf(Status.Stopped)
@@ -253,21 +254,20 @@ class MainActivity : ComponentActivity(), ServiceConnection.Callback {
} }
} }
private suspend fun prepare() = private suspend fun prepare() = withContext(Dispatchers.Main) {
withContext(Dispatchers.Main) { try {
try { val intent = VpnService.prepare(this@MainActivity)
val intent = VpnService.prepare(this@MainActivity) if (intent != null) {
if (intent != null) { prepareLauncher.launch(intent)
prepareLauncher.launch(intent)
true
} else {
false
}
} catch (e: Exception) {
onServiceAlert(Alert.RequestVPNPermission, e.message)
true true
} else {
false
} }
} catch (e: Exception) {
onServiceAlert(Alert.RequestVPNPermission, e.message)
true
} }
}
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
@@ -388,8 +388,11 @@ class MainActivity : ComponentActivity(), ServiceConnection.Callback {
text = { text = {
MarkdownText( MarkdownText(
markdown = stringResource( markdown = stringResource(
if (BuildConfig.FLAVOR == "play") R.string.check_update_prompt_play if (BuildConfig.FLAVOR == "play") {
else R.string.check_update_prompt_github R.string.check_update_prompt_play
} else {
R.string.check_update_prompt_github
},
), ),
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
) )
@@ -534,7 +537,7 @@ class MainActivity : ComponentActivity(), ServiceConnection.Callback {
@Suppress("UNCHECKED_CAST") @Suppress("UNCHECKED_CAST")
return GroupsViewModel(dashboardViewModel.commandClient) as T return GroupsViewModel(dashboardViewModel.commandClient) as T
} }
} },
) )
} else { } else {
null null
@@ -729,17 +732,17 @@ class MainActivity : ComponentActivity(), ServiceConnection.Callback {
icon = { icon = {
Icon( Icon(
imageVector = imageVector =
if (isRunning || isStopping) { if (isRunning || isStopping) {
Icons.Default.Stop Icons.Default.Stop
} else { } else {
Icons.Default.PlayArrow Icons.Default.PlayArrow
}, },
contentDescription = contentDescription =
if (isRunning || isStopping) { if (isRunning || isStopping) {
stringResource(R.string.stop) stringResource(R.string.stop)
} else { } else {
stringResource(R.string.action_start) stringResource(R.string.action_start)
}, },
) )
}, },
text = { text = {
@@ -873,9 +876,9 @@ class MainActivity : ComponentActivity(), ServiceConnection.Callback {
}, },
label = { Text(stringResource(screen.titleRes)) }, label = { Text(stringResource(screen.titleRes)) },
selected = selected =
currentDestination?.hierarchy?.any { currentDestination?.hierarchy?.any {
it.route == screen.route it.route == screen.route
} == true, } == true,
onClick = { onClick = {
navController.navigate(screen.route) { navController.navigate(screen.route) {
// Pop up to the start destination of the graph to // Pop up to the start destination of the graph to
@@ -909,7 +912,7 @@ class MainActivity : ComponentActivity(), ServiceConnection.Callback {
@Suppress("UNCHECKED_CAST") @Suppress("UNCHECKED_CAST")
return GroupsViewModel(dashboardViewModel.commandClient) as T return GroupsViewModel(dashboardViewModel.commandClient) as T
} }
} },
) )
val groupsUiState by groupsViewModel.uiState.collectAsState() val groupsUiState by groupsViewModel.uiState.collectAsState()
val allCollapsed = groupsUiState.expandedGroups.isEmpty() val allCollapsed = groupsUiState.expandedGroups.isEmpty()
@@ -943,12 +946,16 @@ class MainActivity : ComponentActivity(), ServiceConnection.Callback {
if (groupsUiState.groups.isNotEmpty()) { if (groupsUiState.groups.isNotEmpty()) {
IconButton(onClick = { groupsViewModel.toggleAllGroups() }) { IconButton(onClick = { groupsViewModel.toggleAllGroups() }) {
Icon( Icon(
imageVector = if (allCollapsed) Icons.Default.UnfoldMore imageVector = if (allCollapsed) {
else Icons.Default.UnfoldLess, Icons.Default.UnfoldMore
contentDescription = if (allCollapsed) } else {
Icons.Default.UnfoldLess
},
contentDescription = if (allCollapsed) {
stringResource(R.string.expand_all) stringResource(R.string.expand_all)
else } else {
stringResource(R.string.collapse_all), stringResource(R.string.collapse_all)
},
) )
} }
} }
@@ -1032,10 +1039,7 @@ class MainActivity : ComponentActivity(), ServiceConnection.Callback {
connection.reconnect() connection.reconnect()
} }
override fun onServiceAlert( override fun onServiceAlert(type: Alert, message: String?) {
type: Alert,
message: String?,
) {
when (type) { when (type) {
Alert.RequestLocationPermission -> { Alert.RequestLocationPermission -> {
return requestLocationPermission() return requestLocationPermission()
@@ -1071,11 +1075,7 @@ class MainActivity : ComponentActivity(), ServiceConnection.Callback {
} }
@Composable @Composable
private fun ServiceAlertDialog( private fun ServiceAlertDialog(alertType: Alert, message: String?, onDismiss: () -> Unit) {
alertType: Alert,
message: String?,
onDismiss: () -> Unit,
) {
val title = val title =
when (alertType) { when (alertType) {
Alert.RequestNotificationPermission -> stringResource(R.string.notification_permission_title) Alert.RequestNotificationPermission -> stringResource(R.string.notification_permission_title)
@@ -1106,10 +1106,7 @@ class MainActivity : ComponentActivity(), ServiceConnection.Callback {
} }
@Composable @Composable
private fun LocationPermissionDialog( private fun LocationPermissionDialog(onConfirm: () -> Unit, onDismiss: () -> Unit) {
onConfirm: () -> Unit,
onDismiss: () -> Unit,
) {
AlertDialog( AlertDialog(
onDismissRequest = onDismiss, onDismissRequest = onDismiss,
title = { Text(stringResource(R.string.location_permission_title)) }, title = { Text(stringResource(R.string.location_permission_title)) },
@@ -1128,10 +1125,7 @@ class MainActivity : ComponentActivity(), ServiceConnection.Callback {
} }
@Composable @Composable
private fun BackgroundLocationPermissionDialog( private fun BackgroundLocationPermissionDialog(onConfirm: () -> Unit, onDismiss: () -> Unit) {
onConfirm: () -> Unit,
onDismiss: () -> Unit,
) {
AlertDialog( AlertDialog(
onDismissRequest = onDismiss, onDismissRequest = onDismiss,
title = { Text(stringResource(R.string.location_permission_title)) }, title = { Text(stringResource(R.string.location_permission_title)) },

View File

@@ -56,10 +56,7 @@ abstract class BaseViewModel<State, Event> : ViewModel() {
sendGlobalEvent(UiEvent.ErrorMessage(message)) sendGlobalEvent(UiEvent.ErrorMessage(message))
} }
protected fun launch( protected fun launch(onError: ((Throwable) -> Unit)? = null, block: suspend CoroutineScope.() -> Unit) {
onError: ((Throwable) -> Unit)? = null,
block: suspend CoroutineScope.() -> Unit,
) {
val errorHandler = val errorHandler =
CoroutineExceptionHandler { _, throwable -> CoroutineExceptionHandler { _, throwable ->
onError?.invoke(throwable) ?: sendError(throwable) onError?.invoke(throwable) ?: sendError(throwable)

View File

@@ -29,7 +29,5 @@ object GlobalEventBus {
* Try to emit an event without suspending. * Try to emit an event without suspending.
* Returns true if the event was emitted successfully. * Returns true if the event was emitted successfully.
*/ */
fun tryEmit(event: UiEvent): Boolean { fun tryEmit(event: UiEvent): Boolean = _events.tryEmit(event)
return _events.tryEmit(event)
}
} }

View File

@@ -19,11 +19,7 @@ import androidx.compose.ui.unit.dp
import io.nekohasekai.sfa.R import io.nekohasekai.sfa.R
@Composable @Composable
fun SelectableMessageDialog( fun SelectableMessageDialog(title: String, message: String, onDismiss: () -> Unit) {
title: String,
message: String,
onDismiss: () -> Unit,
) {
val clipboard = LocalClipboardManager.current val clipboard = LocalClipboardManager.current
val context = LocalContext.current val context = LocalContext.current
val scrollState = rememberScrollState() 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 Error(val exception: Throwable, val message: String? = null) : UiState<Nothing>()
} }
data class BaseUiState<T>( data class BaseUiState<T>(val isLoading: Boolean = false, val data: T? = null, val error: String? = null)
val isLoading: Boolean = false,
val data: T? = null,
val error: String? = null,
)

View File

@@ -65,9 +65,9 @@ fun ServiceStatusBar(
) { ) {
Row( Row(
modifier = modifier =
Modifier Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 12.dp), .padding(horizontal = 16.dp, vertical = 12.dp),
horizontalArrangement = Arrangement.spacedBy(16.dp), horizontalArrangement = Arrangement.spacedBy(16.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
@@ -85,11 +85,11 @@ fun ServiceStatusBar(
// Connections button // Connections button
Row( Row(
modifier = modifier =
Modifier Modifier
.clip(RoundedCornerShape(8.dp)) .clip(RoundedCornerShape(8.dp))
.background(MaterialTheme.colorScheme.secondaryContainer) .background(MaterialTheme.colorScheme.secondaryContainer)
.clickable(onClick = onConnectionsClick) .clickable(onClick = onConnectionsClick)
.padding(horizontal = 12.dp, vertical = 8.dp), .padding(horizontal = 12.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center, horizontalArrangement = Arrangement.Center,
) { ) {
@@ -112,11 +112,11 @@ fun ServiceStatusBar(
if (hasGroups) { if (hasGroups) {
Row( Row(
modifier = modifier =
Modifier Modifier
.clip(RoundedCornerShape(8.dp)) .clip(RoundedCornerShape(8.dp))
.background(MaterialTheme.colorScheme.secondaryContainer) .background(MaterialTheme.colorScheme.secondaryContainer)
.clickable(onClick = onGroupsClick) .clickable(onClick = onGroupsClick)
.padding(horizontal = 12.dp, vertical = 8.dp), .padding(horizontal = 12.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center, horizontalArrangement = Arrangement.Center,
) { ) {
@@ -139,11 +139,11 @@ fun ServiceStatusBar(
// Stop button // Stop button
Row( Row(
modifier = modifier =
Modifier Modifier
.clip(RoundedCornerShape(8.dp)) .clip(RoundedCornerShape(8.dp))
.background(MaterialTheme.colorScheme.primaryContainer) .background(MaterialTheme.colorScheme.primaryContainer)
.clickable(onClick = onStopClick) .clickable(onClick = onStopClick)
.padding(horizontal = 12.dp, vertical = 8.dp), .padding(horizontal = 12.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center, horizontalArrangement = Arrangement.Center,
) { ) {
@@ -164,10 +164,7 @@ fun ServiceStatusBar(
} }
@Composable @Composable
private fun StatusItem( private fun StatusItem(text: String, modifier: Modifier = Modifier) {
text: String,
modifier: Modifier = Modifier,
) {
Text( Text(
text = text, text = text,
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.titleMedium,
@@ -178,10 +175,7 @@ private fun StatusItem(
} }
@Composable @Composable
fun UptimeText( fun UptimeText(startTime: Long, modifier: Modifier = Modifier) {
startTime: Long,
modifier: Modifier = Modifier,
) {
var currentTime by remember { mutableLongStateOf(System.currentTimeMillis()) } var currentTime by remember { mutableLongStateOf(System.currentTimeMillis()) }
LaunchedEffect(startTime) { LaunchedEffect(startTime) {

View File

@@ -27,11 +27,7 @@ import org.kodein.emoji.EmojiTemplateCatalog
import org.kodein.emoji.all import org.kodein.emoji.all
@Composable @Composable
fun UpdateAvailableDialog( fun UpdateAvailableDialog(updateInfo: UpdateInfo, onDismiss: () -> Unit, onUpdate: () -> Unit) {
updateInfo: UpdateInfo,
onDismiss: () -> Unit,
onUpdate: () -> Unit,
) {
val context = LocalContext.current val context = LocalContext.current
val emojiCatalog = remember { EmojiTemplateCatalog(Emoji.all()) } 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.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.wrapContentHeight import androidx.compose.foundation.layout.wrapContentHeight
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Card import androidx.compose.material3.Card
@@ -24,24 +23,21 @@ import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties import androidx.compose.ui.window.DialogProperties
@Composable @Composable
fun QRCodeDialog( fun QRCodeDialog(bitmap: Bitmap, onDismiss: () -> Unit) {
bitmap: Bitmap,
onDismiss: () -> Unit,
) {
Dialog( Dialog(
onDismissRequest = onDismiss, onDismissRequest = onDismiss,
properties = DialogProperties(usePlatformDefaultWidth = false), properties = DialogProperties(usePlatformDefaultWidth = false),
) { ) {
Card( Card(
modifier = modifier =
Modifier Modifier
.fillMaxWidth(0.9f) .fillMaxWidth(0.9f)
.wrapContentHeight(), .wrapContentHeight(),
shape = RoundedCornerShape(16.dp), shape = RoundedCornerShape(16.dp),
colors = colors =
CardDefaults.cardColors( CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surface, containerColor = MaterialTheme.colorScheme.surface,
), ),
) { ) {
Surface( Surface(
modifier = Modifier modifier = Modifier
@@ -52,9 +48,9 @@ fun QRCodeDialog(
) { ) {
Box( Box(
modifier = modifier =
Modifier Modifier
.fillMaxSize() .fillMaxSize()
.background(MaterialTheme.colorScheme.surface), .background(MaterialTheme.colorScheme.surface),
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center,
) { ) {
Image( Image(

View File

@@ -33,6 +33,7 @@ class QRSBitmapGenerator(
private val actualBufferSize = bufferSize.coerceAtMost(frames.size) private val actualBufferSize = bufferSize.coerceAtMost(frames.size)
private val bitmapBuffer = arrayOfNulls<Bitmap>(actualBufferSize) private val bitmapBuffer = arrayOfNulls<Bitmap>(actualBufferSize)
private var generationJob: Job? = null private var generationJob: Job? = null
@Volatile @Volatile
private var currentFrameIndex = 0 private var currentFrameIndex = 0
private var generatedUpTo = -1 private var generatedUpTo = -1

View File

@@ -1,9 +1,9 @@
package io.nekohasekai.sfa.compose.component.qr package io.nekohasekai.sfa.compose.component.qr
import android.content.Intent import android.content.Intent
import android.content.res.Configuration
import android.graphics.Color import android.graphics.Color
import android.net.Uri import android.net.Uri
import android.content.res.Configuration
import androidx.compose.foundation.Image import androidx.compose.foundation.Image
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
@@ -58,11 +58,7 @@ import io.nekohasekai.sfa.qrs.QRSEncoder
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
@Composable @Composable
fun QRSDialog( fun QRSDialog(profileData: ByteArray, profileName: String, onDismiss: () -> Unit) {
profileData: ByteArray,
profileName: String,
onDismiss: () -> Unit,
) {
val context = LocalContext.current val context = LocalContext.current
val configuration = LocalConfiguration.current val configuration = LocalConfiguration.current
val isTablet = configuration.isLayoutSizeAtLeast(Configuration.SCREENLAYOUT_SIZE_LARGE) val isTablet = configuration.isLayoutSizeAtLeast(Configuration.SCREENLAYOUT_SIZE_LARGE)
@@ -126,16 +122,16 @@ fun QRSDialog(
) { ) {
Card( Card(
modifier = modifier =
if (isTablet) { if (isTablet) {
Modifier Modifier
.fillMaxWidth(0.85f) .fillMaxWidth(0.85f)
.sizeIn(maxWidth = 960.dp) .sizeIn(maxWidth = 960.dp)
.wrapContentHeight() .wrapContentHeight()
} else { } else {
Modifier Modifier
.fillMaxWidth(0.9f) .fillMaxWidth(0.9f)
.wrapContentHeight() .wrapContentHeight()
}, },
shape = RoundedCornerShape(16.dp), shape = RoundedCornerShape(16.dp),
colors = CardDefaults.cardColors( colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surface, containerColor = MaterialTheme.colorScheme.surface,

View File

@@ -40,8 +40,8 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
@@ -54,18 +54,14 @@ import androidx.core.content.ContextCompat
import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import io.nekohasekai.sfa.R import io.nekohasekai.sfa.R
import io.nekohasekai.sfa.compose.screen.qrscan.QRCodeCropArea
import io.nekohasekai.sfa.compose.screen.qrscan.QRScanResult import io.nekohasekai.sfa.compose.screen.qrscan.QRScanResult
import io.nekohasekai.sfa.compose.screen.qrscan.QRScanViewModel import io.nekohasekai.sfa.compose.screen.qrscan.QRScanViewModel
import io.nekohasekai.sfa.compose.screen.qrscan.QRCodeCropArea
import kotlin.math.max import kotlin.math.max
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun QRScanSheet( fun QRScanSheet(onDismiss: () -> Unit, onScanResult: (QRScanResult) -> Unit, viewModel: QRScanViewModel = viewModel()) {
onDismiss: () -> Unit,
onScanResult: (QRScanResult) -> Unit,
viewModel: QRScanViewModel = viewModel(),
) {
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
val context = LocalContext.current val context = LocalContext.current
val lifecycleOwner = LocalLifecycleOwner.current val lifecycleOwner = LocalLifecycleOwner.current
@@ -74,12 +70,12 @@ fun QRScanSheet(
var hasPermission by remember { var hasPermission by remember {
mutableStateOf( mutableStateOf(
ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) == ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) ==
PackageManager.PERMISSION_GRANTED PackageManager.PERMISSION_GRANTED,
) )
} }
val permissionLauncher = rememberLauncherForActivityResult( val permissionLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.RequestPermission() contract = ActivityResultContracts.RequestPermission(),
) { isGranted -> ) { isGranted ->
if (isGranted) { if (isGranted) {
hasPermission = true hasPermission = true
@@ -113,7 +109,7 @@ fun QRScanSheet(
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.fillMaxHeight(0.9f) .fillMaxHeight(0.9f),
) { ) {
Row( Row(
modifier = Modifier modifier = Modifier
@@ -138,44 +134,44 @@ fun QRScanSheet(
} }
DropdownMenu( DropdownMenu(
expanded = showMenu, expanded = showMenu,
onDismissRequest = { showMenu = false } onDismissRequest = { showMenu = false },
) { ) {
DropdownMenuItem( DropdownMenuItem(
text = { text = {
Text( Text(
(if (uiState.useFrontCamera) "" else " ") + (if (uiState.useFrontCamera) "" else " ") +
stringResource(R.string.profile_add_scan_use_front_camera) stringResource(R.string.profile_add_scan_use_front_camera),
) )
}, },
onClick = { onClick = {
viewModel.toggleFrontCamera(lifecycleOwner) viewModel.toggleFrontCamera(lifecycleOwner)
showMenu = false showMenu = false
} },
) )
DropdownMenuItem( DropdownMenuItem(
text = { text = {
Text( Text(
(if (uiState.torchEnabled) "" else " ") + (if (uiState.torchEnabled) "" else " ") +
stringResource(R.string.profile_add_scan_enable_torch) stringResource(R.string.profile_add_scan_enable_torch),
) )
}, },
onClick = { onClick = {
viewModel.toggleTorch() viewModel.toggleTorch()
showMenu = false showMenu = false
} },
) )
if (uiState.vendorAnalyzerAvailable) { if (uiState.vendorAnalyzerAvailable) {
DropdownMenuItem( DropdownMenuItem(
text = { text = {
Text( Text(
(if (uiState.useVendorAnalyzer) "" else " ") + (if (uiState.useVendorAnalyzer) "" else " ") +
stringResource(R.string.profile_add_scan_use_vendor_analyzer) stringResource(R.string.profile_add_scan_use_vendor_analyzer),
) )
}, },
onClick = { onClick = {
viewModel.toggleVendorAnalyzer() viewModel.toggleVendorAnalyzer()
showMenu = false showMenu = false
} },
) )
} }
} }
@@ -185,7 +181,7 @@ fun QRScanSheet(
Box( Box(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.weight(1f) .weight(1f),
) { ) {
if (hasPermission) { if (hasPermission) {
CameraPreview( CameraPreview(
@@ -201,7 +197,7 @@ fun QRScanSheet(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.background(MaterialTheme.colorScheme.surface), .background(MaterialTheme.colorScheme.surface),
contentAlignment = Alignment.Center contentAlignment = Alignment.Center,
) { ) {
CircularProgressIndicator() CircularProgressIndicator()
} }
@@ -214,7 +210,7 @@ fun QRScanSheet(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.background(Color.Black.copy(alpha = 0.5f)), .background(Color.Black.copy(alpha = 0.5f)),
contentAlignment = Alignment.Center contentAlignment = Alignment.Center,
) { ) {
Box(contentAlignment = Alignment.Center) { Box(contentAlignment = Alignment.Center) {
CircularProgressIndicator( CircularProgressIndicator(
@@ -228,18 +224,18 @@ fun QRScanSheet(
Text( Text(
text = "${minOf(99, (progress * 100).toInt())}%", text = "${minOf(99, (progress * 100).toInt())}%",
style = MaterialTheme.typography.titleLarge.copy( style = MaterialTheme.typography.titleLarge.copy(
fontWeight = FontWeight.SemiBold fontWeight = FontWeight.SemiBold,
), ),
color = Color.White color = Color.White,
) )
} }
Text( Text(
text = "QRS", text = "QRS",
style = MaterialTheme.typography.headlineLarge.copy( style = MaterialTheme.typography.headlineLarge.copy(
fontWeight = FontWeight.Bold fontWeight = FontWeight.Bold,
), ),
color = Color.White, 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() }) { TextButton(onClick = { viewModel.dismissError() }) {
Text(stringResource(android.R.string.ok)) Text(stringResource(android.R.string.ok))
} }
} },
) )
} }
} }
@@ -294,7 +290,7 @@ private fun CameraPreview(
} }
} }
} }
} },
) )
Canvas(modifier = Modifier.fillMaxSize()) { Canvas(modifier = Modifier.fillMaxSize()) {
@@ -309,11 +305,7 @@ private fun CameraPreview(
} }
} }
private fun mapCropAreaToPreview( private fun mapCropAreaToPreview(area: QRCodeCropArea, viewWidth: Float, viewHeight: Float): Rect? {
area: QRCodeCropArea,
viewWidth: Float,
viewHeight: Float,
): Rect? {
if (viewWidth <= 0f || viewHeight <= 0f) return null if (viewWidth <= 0f || viewHeight <= 0f) return null
val rotation = ((area.rotationDegrees % 360) + 360) % 360 val rotation = ((area.rotationDegrees % 360) + 360) % 360

View File

@@ -1,18 +1,12 @@
package io.nekohasekai.sfa.compose.model package io.nekohasekai.sfa.compose.model
import androidx.compose.runtime.Immutable import androidx.compose.runtime.Immutable
import io.nekohasekai.sfa.ktx.toList
import io.nekohasekai.libbox.Connection as LibboxConnection import io.nekohasekai.libbox.Connection as LibboxConnection
import io.nekohasekai.libbox.ProcessInfo as LibboxProcessInfo import io.nekohasekai.libbox.ProcessInfo as LibboxProcessInfo
import io.nekohasekai.sfa.ktx.toList
@Immutable @Immutable
data class ProcessInfo( data class ProcessInfo(val processId: Long, val userId: Int, val userName: String, val processPath: String, val packageName: String) {
val processId: Long,
val userId: Int,
val userName: String,
val processPath: String,
val packageName: String,
) {
companion object { companion object {
fun from(processInfo: LibboxProcessInfo?): ProcessInfo? { fun from(processInfo: LibboxProcessInfo?): ProcessInfo? {
if (processInfo == null) return null if (processInfo == null) return null
@@ -68,59 +62,53 @@ data class Connection(
return true return true
} }
private fun performSearchPlain(content: String): Boolean { private fun performSearchPlain(content: String): Boolean = destination.contains(content, ignoreCase = true) ||
return destination.contains(content, ignoreCase = true) || domain.contains(content, ignoreCase = true) ||
domain.contains(content, ignoreCase = true) || outbound.contains(content, ignoreCase = true) ||
outbound.contains(content, ignoreCase = true) || rule.contains(content, ignoreCase = true) ||
rule.contains(content, ignoreCase = true) || processInfo?.packageName?.contains(content, ignoreCase = true) == true
processInfo?.packageName?.contains(content, ignoreCase = true) == true
}
private fun performSearchType(type: String, value: String): Boolean { private fun performSearchType(type: String, value: String): Boolean = when (type) {
return when (type) { "network" -> network.equals(value, ignoreCase = true)
"network" -> network.equals(value, ignoreCase = true) "inbound" -> inbound.contains(value, ignoreCase = true)
"inbound" -> inbound.contains(value, ignoreCase = true) "inbound.type" -> inboundType.equals(value, ignoreCase = true)
"inbound.type" -> inboundType.equals(value, ignoreCase = true) "source" -> source.contains(value, ignoreCase = true)
"source" -> source.contains(value, ignoreCase = true) "destination" -> destination.contains(value, ignoreCase = true)
"destination" -> destination.contains(value, ignoreCase = true) "outbound" -> outbound.contains(value, ignoreCase = true)
"outbound" -> outbound.contains(value, ignoreCase = true) "outbound.type" -> outboundType.equals(value, ignoreCase = true)
"outbound.type" -> outboundType.equals(value, ignoreCase = true) "rule" -> rule.contains(value, ignoreCase = true)
"rule" -> rule.contains(value, ignoreCase = true) "protocol" -> protocolName.equals(value, ignoreCase = true)
"protocol" -> protocolName.equals(value, ignoreCase = true) "user" -> user.contains(value, ignoreCase = true)
"user" -> user.contains(value, ignoreCase = true) "package" -> processInfo?.packageName?.contains(value, ignoreCase = true) == true
"package" -> processInfo?.packageName?.contains(value, ignoreCase = true) == true "chain" -> chain.any { it.contains(value, ignoreCase = true) }
"chain" -> chain.any { it.contains(value, ignoreCase = true) } else -> false
else -> false
}
} }
companion object { companion object {
fun from(connection: LibboxConnection): Connection { fun from(connection: LibboxConnection): Connection = Connection(
return Connection( id = connection.id,
id = connection.id, inbound = connection.inbound,
inbound = connection.inbound, inboundType = connection.inboundType,
inboundType = connection.inboundType, ipVersion = connection.ipVersion,
ipVersion = connection.ipVersion, network = connection.network,
network = connection.network, source = connection.source,
source = connection.source, destination = connection.destination,
destination = connection.destination, domain = connection.domain,
domain = connection.domain, displayDestination = connection.displayDestination(),
displayDestination = connection.displayDestination(), protocolName = connection.protocol,
protocolName = connection.protocol, user = connection.user,
user = connection.user, fromOutbound = connection.fromOutbound,
fromOutbound = connection.fromOutbound, createdAt = connection.createdAt,
createdAt = connection.createdAt, closedAt = if (connection.closedAt > 0) connection.closedAt else null,
closedAt = if (connection.closedAt > 0) connection.closedAt else null, upload = connection.uplink,
upload = connection.uplink, download = connection.downlink,
download = connection.downlink, uploadTotal = connection.uplinkTotal,
uploadTotal = connection.uplinkTotal, downloadTotal = connection.downlinkTotal,
downloadTotal = connection.downlinkTotal, rule = connection.rule,
rule = connection.rule, outbound = connection.outbound,
outbound = connection.outbound, outboundType = connection.outboundType,
outboundType = connection.outboundType, chain = connection.chain().toList(),
chain = connection.chain().toList(), processInfo = ProcessInfo.from(connection.processInfo),
processInfo = ProcessInfo.from(connection.processInfo), )
)
}
} }
} }

View File

@@ -24,12 +24,7 @@ data class Group(
} }
@Immutable @Immutable
data class GroupItem( data class GroupItem(val tag: String, val type: String, val urlTestTime: Long, val urlTestDelay: Int) {
val tag: String,
val type: String,
val urlTestTime: Long,
val urlTestDelay: Int,
) {
constructor(item: OutboundGroupItem) : this( constructor(item: OutboundGroupItem) : this(
item.tag, item.tag,
item.type, item.type,

View File

@@ -10,11 +10,7 @@ import androidx.compose.material.icons.filled.SwapVert
import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector
import io.nekohasekai.sfa.R import io.nekohasekai.sfa.R
sealed class Screen( sealed class Screen(val route: String, @StringRes val titleRes: Int, val icon: ImageVector) {
val route: String,
@StringRes val titleRes: Int,
val icon: ImageVector,
) {
object Dashboard : Screen( object Dashboard : Screen(
route = "dashboard", route = "dashboard",
titleRes = R.string.title_dashboard, titleRes = R.string.title_dashboard,

View File

@@ -1,10 +1,6 @@
package io.nekohasekai.sfa.compose.navigation package io.nekohasekai.sfa.compose.navigation
data class NewProfileArgs( data class NewProfileArgs(val importName: String? = null, val importUrl: String? = null, val qrsData: ByteArray? = null)
val importName: String? = null,
val importUrl: String? = null,
val qrsData: ByteArray? = null,
)
object ProfileRoutes { object ProfileRoutes {
const val NewProfile = "profile/new" const val NewProfile = "profile/new"

View File

@@ -12,18 +12,20 @@ import androidx.navigation.NavType
import androidx.navigation.compose.NavHost import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable import androidx.navigation.compose.composable
import androidx.navigation.navArgument import 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.DashboardScreen
import io.nekohasekai.sfa.compose.screen.dashboard.DashboardViewModel import io.nekohasekai.sfa.compose.screen.dashboard.DashboardViewModel
import io.nekohasekai.sfa.compose.screen.dashboard.GroupsCard import io.nekohasekai.sfa.compose.screen.dashboard.GroupsCard
import io.nekohasekai.sfa.compose.screen.dashboard.groups.GroupsViewModel import io.nekohasekai.sfa.compose.screen.dashboard.groups.GroupsViewModel
import io.nekohasekai.sfa.compose.screen.connections.ConnectionDetailsRoute
import io.nekohasekai.sfa.compose.screen.connections.ConnectionsPage
import io.nekohasekai.sfa.compose.screen.log.HookLogScreen import io.nekohasekai.sfa.compose.screen.log.HookLogScreen
import io.nekohasekai.sfa.compose.screen.log.LogScreen import io.nekohasekai.sfa.compose.screen.log.LogScreen
import io.nekohasekai.sfa.compose.screen.log.LogViewModel import io.nekohasekai.sfa.compose.screen.log.LogViewModel
import io.nekohasekai.sfa.compose.screen.connections.ConnectionsViewModel import io.nekohasekai.sfa.compose.screen.privilegesettings.PrivilegeSettingsManageScreen
import io.nekohasekai.sfa.compose.screen.configuration.NewProfileScreen
import io.nekohasekai.sfa.compose.screen.profile.EditProfileRoute 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.AppSettingsScreen
import io.nekohasekai.sfa.compose.screen.settings.CoreSettingsScreen import io.nekohasekai.sfa.compose.screen.settings.CoreSettingsScreen
import io.nekohasekai.sfa.compose.screen.settings.PrivilegeSettingsScreen import io.nekohasekai.sfa.compose.screen.settings.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.ServiceSettingsScreen
import io.nekohasekai.sfa.compose.screen.settings.SettingsScreen import io.nekohasekai.sfa.compose.screen.settings.SettingsScreen
import io.nekohasekai.sfa.constant.Status import io.nekohasekai.sfa.constant.Status
import io.nekohasekai.sfa.compose.screen.privilegesettings.PrivilegeSettingsManageScreen
import io.nekohasekai.sfa.compose.screen.profileoverride.PerAppProxyScreen
private val slideInFromRight: AnimatedContentTransitionScope<*>.() -> androidx.compose.animation.EnterTransition = { private val slideInFromRight: AnimatedContentTransitionScope<*>.() -> androidx.compose.animation.EnterTransition = {
slideIntoContainer(AnimatedContentTransitionScope.SlideDirection.Left, animationSpec = tween(300)) 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.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.navigationBars import androidx.compose.foundation.layout.navigationBars
@@ -60,6 +59,7 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@@ -149,9 +149,9 @@ fun NewProfileScreen(
} }
}, },
colors = colors =
TopAppBarDefaults.topAppBarColors( TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.surface, containerColor = MaterialTheme.colorScheme.surface,
), ),
) )
} }
@@ -164,20 +164,20 @@ fun NewProfileScreen(
Box(modifier = Modifier.fillMaxSize()) { Box(modifier = Modifier.fillMaxSize()) {
Column( Column(
modifier = modifier =
Modifier Modifier
.fillMaxSize() .fillMaxSize()
.verticalScroll(rememberScrollState()) .verticalScroll(rememberScrollState())
.padding(16.dp) .padding(16.dp)
.padding(bottom = bottomBarPadding), .padding(bottom = bottomBarPadding),
verticalArrangement = Arrangement.spacedBy(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp),
) { ) {
// Profile Name // Profile Name
Card( Card(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
colors = colors =
CardDefaults.cardColors( CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f), containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f),
), ),
) { ) {
Column( Column(
modifier = Modifier.padding(16.dp), modifier = Modifier.padding(16.dp),
@@ -211,9 +211,9 @@ fun NewProfileScreen(
Card( Card(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
colors = colors =
CardDefaults.cardColors( CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f), containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f),
), ),
) { ) {
Column( Column(
modifier = Modifier.padding(16.dp), modifier = Modifier.padding(16.dp),
@@ -233,30 +233,30 @@ fun NewProfileScreen(
onClick = { viewModel.updateProfileType(ProfileType.Local) }, onClick = { viewModel.updateProfileType(ProfileType.Local) },
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
shape = shape =
RoundedCornerShape( RoundedCornerShape(
topStart = 12.dp, topStart = 12.dp,
bottomStart = 12.dp, bottomStart = 12.dp,
topEnd = 0.dp, topEnd = 0.dp,
bottomEnd = 0.dp, bottomEnd = 0.dp,
), ),
colors = colors =
if (uiState.profileType == ProfileType.Local) { if (uiState.profileType == ProfileType.Local) {
ButtonDefaults.buttonColors( ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.primaryContainer, containerColor = MaterialTheme.colorScheme.primaryContainer,
contentColor = MaterialTheme.colorScheme.onPrimaryContainer, contentColor = MaterialTheme.colorScheme.onPrimaryContainer,
) )
} else { } else {
ButtonDefaults.outlinedButtonColors() ButtonDefaults.outlinedButtonColors()
}, },
border = border =
BorderStroke( BorderStroke(
1.dp, 1.dp,
if (uiState.profileType == ProfileType.Local) { if (uiState.profileType == ProfileType.Local) {
MaterialTheme.colorScheme.primary MaterialTheme.colorScheme.primary
} else { } else {
MaterialTheme.colorScheme.outline MaterialTheme.colorScheme.outline
}, },
), ),
) { ) {
Text(stringResource(R.string.profile_type_local)) Text(stringResource(R.string.profile_type_local))
} }
@@ -264,30 +264,30 @@ fun NewProfileScreen(
onClick = { viewModel.updateProfileType(ProfileType.Remote) }, onClick = { viewModel.updateProfileType(ProfileType.Remote) },
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
shape = shape =
RoundedCornerShape( RoundedCornerShape(
topStart = 0.dp, topStart = 0.dp,
bottomStart = 0.dp, bottomStart = 0.dp,
topEnd = 12.dp, topEnd = 12.dp,
bottomEnd = 12.dp, bottomEnd = 12.dp,
), ),
colors = colors =
if (uiState.profileType == ProfileType.Remote) { if (uiState.profileType == ProfileType.Remote) {
ButtonDefaults.buttonColors( ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.primaryContainer, containerColor = MaterialTheme.colorScheme.primaryContainer,
contentColor = MaterialTheme.colorScheme.onPrimaryContainer, contentColor = MaterialTheme.colorScheme.onPrimaryContainer,
) )
} else { } else {
ButtonDefaults.outlinedButtonColors() ButtonDefaults.outlinedButtonColors()
}, },
border = border =
BorderStroke( BorderStroke(
1.dp, 1.dp,
if (uiState.profileType == ProfileType.Remote) { if (uiState.profileType == ProfileType.Remote) {
MaterialTheme.colorScheme.primary MaterialTheme.colorScheme.primary
} else { } else {
MaterialTheme.colorScheme.outline MaterialTheme.colorScheme.outline
}, },
), ),
) { ) {
Text(stringResource(R.string.profile_type_remote)) Text(stringResource(R.string.profile_type_remote))
} }
@@ -304,9 +304,9 @@ fun NewProfileScreen(
Card( Card(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
colors = colors =
CardDefaults.cardColors( CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.secondaryContainer.copy(alpha = 0.3f), containerColor = MaterialTheme.colorScheme.secondaryContainer.copy(alpha = 0.3f),
), ),
) { ) {
Column( Column(
modifier = Modifier.padding(16.dp), modifier = Modifier.padding(16.dp),
@@ -326,30 +326,30 @@ fun NewProfileScreen(
onClick = { viewModel.updateProfileSource(ProfileSource.CreateNew) }, onClick = { viewModel.updateProfileSource(ProfileSource.CreateNew) },
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
shape = shape =
RoundedCornerShape( RoundedCornerShape(
topStart = 12.dp, topStart = 12.dp,
bottomStart = 12.dp, bottomStart = 12.dp,
topEnd = 0.dp, topEnd = 0.dp,
bottomEnd = 0.dp, bottomEnd = 0.dp,
), ),
colors = colors =
if (uiState.profileSource == ProfileSource.CreateNew) { if (uiState.profileSource == ProfileSource.CreateNew) {
ButtonDefaults.buttonColors( ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.secondaryContainer, containerColor = MaterialTheme.colorScheme.secondaryContainer,
contentColor = MaterialTheme.colorScheme.onSecondaryContainer, contentColor = MaterialTheme.colorScheme.onSecondaryContainer,
) )
} else { } else {
ButtonDefaults.outlinedButtonColors() ButtonDefaults.outlinedButtonColors()
}, },
border = border =
BorderStroke( BorderStroke(
1.dp, 1.dp,
if (uiState.profileSource == ProfileSource.CreateNew) { if (uiState.profileSource == ProfileSource.CreateNew) {
MaterialTheme.colorScheme.secondary MaterialTheme.colorScheme.secondary
} else { } else {
MaterialTheme.colorScheme.outline MaterialTheme.colorScheme.outline
}, },
), ),
) { ) {
Icon( Icon(
Icons.Default.CreateNewFolder, Icons.Default.CreateNewFolder,
@@ -363,30 +363,30 @@ fun NewProfileScreen(
onClick = { viewModel.updateProfileSource(ProfileSource.Import) }, onClick = { viewModel.updateProfileSource(ProfileSource.Import) },
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
shape = shape =
RoundedCornerShape( RoundedCornerShape(
topStart = 0.dp, topStart = 0.dp,
bottomStart = 0.dp, bottomStart = 0.dp,
topEnd = 12.dp, topEnd = 12.dp,
bottomEnd = 12.dp, bottomEnd = 12.dp,
), ),
colors = colors =
if (uiState.profileSource == ProfileSource.Import) { if (uiState.profileSource == ProfileSource.Import) {
ButtonDefaults.buttonColors( ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.secondaryContainer, containerColor = MaterialTheme.colorScheme.secondaryContainer,
contentColor = MaterialTheme.colorScheme.onSecondaryContainer, contentColor = MaterialTheme.colorScheme.onSecondaryContainer,
) )
} else { } else {
ButtonDefaults.outlinedButtonColors() ButtonDefaults.outlinedButtonColors()
}, },
border = border =
BorderStroke( BorderStroke(
1.dp, 1.dp,
if (uiState.profileSource == ProfileSource.Import) { if (uiState.profileSource == ProfileSource.Import) {
MaterialTheme.colorScheme.secondary MaterialTheme.colorScheme.secondary
} else { } else {
MaterialTheme.colorScheme.outline MaterialTheme.colorScheme.outline
}, },
), ),
) { ) {
Icon( Icon(
Icons.Default.FileUpload, Icons.Default.FileUpload,
@@ -408,20 +408,20 @@ fun NewProfileScreen(
onClick = { filePickerLauncher.launch("*/*") }, onClick = { filePickerLauncher.launch("*/*") },
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
border = border =
BorderStroke( BorderStroke(
1.dp, 1.dp,
if (uiState.importError != null) { if (uiState.importError != null) {
MaterialTheme.colorScheme.error MaterialTheme.colorScheme.error
} else { } else {
MaterialTheme.colorScheme.outline MaterialTheme.colorScheme.outline
}, },
), ),
) { ) {
Row( Row(
modifier = modifier =
Modifier Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(16.dp), .padding(16.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp), horizontalArrangement = Arrangement.spacedBy(12.dp),
) { ) {
@@ -429,11 +429,11 @@ fun NewProfileScreen(
Icons.Default.FileUpload, Icons.Default.FileUpload,
contentDescription = null, contentDescription = null,
tint = tint =
if (uiState.importError != null) { if (uiState.importError != null) {
MaterialTheme.colorScheme.error MaterialTheme.colorScheme.error
} else { } else {
MaterialTheme.colorScheme.primary MaterialTheme.colorScheme.primary
}, },
) )
Column(modifier = Modifier.weight(1f)) { Column(modifier = Modifier.weight(1f)) {
Text( Text(
@@ -473,9 +473,9 @@ fun NewProfileScreen(
Card( Card(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
colors = colors =
CardDefaults.cardColors( CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.tertiaryContainer.copy(alpha = 0.3f), containerColor = MaterialTheme.colorScheme.tertiaryContainer.copy(alpha = 0.3f),
), ),
) { ) {
Column( Column(
modifier = Modifier.padding(16.dp), modifier = Modifier.padding(16.dp),
@@ -550,18 +550,18 @@ fun NewProfileScreen(
Surface( Surface(
modifier = modifier =
Modifier Modifier
.fillMaxWidth() .fillMaxWidth()
.align(Alignment.BottomCenter), .align(Alignment.BottomCenter),
color = MaterialTheme.colorScheme.surface, color = MaterialTheme.colorScheme.surface,
tonalElevation = 3.dp, tonalElevation = 3.dp,
) { ) {
Box( Box(
modifier = modifier =
Modifier Modifier
.fillMaxWidth() .fillMaxWidth()
.windowInsetsPadding(WindowInsets.navigationBars) .windowInsetsPadding(WindowInsets.navigationBars)
.padding(16.dp), .padding(16.dp),
) { ) {
Button( Button(
onClick = { viewModel.validateAndCreateProfile() }, onClick = { viewModel.validateAndCreateProfile() },

View File

@@ -124,21 +124,18 @@ class NewProfileViewModel(application: Application) : AndroidViewModel(applicati
_uiState.update { it.copy(autoUpdateInterval = intValue.coerceAtLeast(15)) } _uiState.update { it.copy(autoUpdateInterval = intValue.coerceAtLeast(15)) }
} }
fun setImportUri( fun setImportUri(uri: Uri, fileName: String?) {
uri: Uri,
fileName: String?,
) {
_uiState.update { _uiState.update {
it.copy( it.copy(
importUri = uri, importUri = uri,
importFileName = fileName, importFileName = fileName,
importError = null, // Clear error when file is selected importError = null, // Clear error when file is selected
name = name =
if (it.name.isEmpty()) { if (it.name.isEmpty()) {
fileName?.substringBeforeLast(".") ?: "Imported Profile" fileName?.substringBeforeLast(".") ?: "Imported Profile"
} else { } else {
it.name it.name
}, },
) )
} }
} }

View File

@@ -23,8 +23,7 @@ class ProfileImportHandler(private val context: Context) {
} }
sealed class QRCodeParseResult { sealed class QRCodeParseResult {
data class RemoteProfile(val name: String, val host: String, val url: String) : data class RemoteProfile(val name: String, val host: String, val url: String) : QRCodeParseResult()
QRCodeParseResult()
data class LocalProfile(val name: 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() data class Error(val message: String) : UriParseResult()
} }
suspend fun importFromUri(uri: Uri): ImportResult = suspend fun importFromUri(uri: Uri): ImportResult = withContext(Dispatchers.IO) {
withContext(Dispatchers.IO) { try {
try { val data =
val data = context.contentResolver.openInputStream(uri)?.use { it.readBytes() }
context.contentResolver.openInputStream(uri)?.use { it.readBytes() } ?: return@withContext ImportResult.Error(context.getString(R.string.error_empty_file))
?: return@withContext ImportResult.Error(context.getString(R.string.error_empty_file))
// Get the filename from the URI // Get the filename from the URI
val filename = getFileNameFromUri(uri) val filename = getFileNameFromUri(uri)
// Try to detect if it's a JSON configuration file // Try to detect if it's a JSON configuration file
val dataString = String(data) val dataString = String(data)
if (isJsonConfiguration(dataString)) { if (isJsonConfiguration(dataString)) {
// It's a JSON configuration, import it directly as a local profile // It's a JSON configuration, import it directly as a local profile
return@withContext importJsonConfiguration(dataString, filename) 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")
} }
}
suspend fun parseUri(uri: Uri): UriParseResult = // Try to decode as ProfileContent (the old way)
withContext(Dispatchers.IO) { val content =
try { 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 {
Libbox.decodeProfileContent(data) Libbox.decodeProfileContent(data)
} catch (e: Exception) { } 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), 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 = importProfile(content)
withContext(Dispatchers.IO) { } catch (e: Exception) {
try { ImportResult.Error(e.message ?: "Unknown error")
val content = try { }
}
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) 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) { } catch (e: Exception) {
return@withContext ImportResult.Error( return@withContext ImportResult.Error(
context.getString(R.string.error_decode_profile, e.message), 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 { private suspend fun importProfile(content: ProfileContent): ImportResult {
val typedProfile = TypedProfile() val typedProfile = TypedProfile()
@@ -259,10 +252,7 @@ class ProfileImportHandler(private val context: Context) {
return ImportResult.Success(profile) return ImportResult.Success(profile)
} }
private suspend fun importRemoteProfile( private suspend fun importRemoteProfile(name: String, url: String): ImportResult {
name: String,
url: String,
): ImportResult {
val typedProfile = val typedProfile =
TypedProfile().apply { TypedProfile().apply {
type = TypedProfile.Type.Remote type = TypedProfile.Type.Remote
@@ -297,13 +287,11 @@ class ProfileImportHandler(private val context: Context) {
?: "Remote Profile" ?: "Remote Profile"
} }
private fun extractHostFromUrl(url: String): String { private fun extractHostFromUrl(url: String): String = try {
return try { val uri = Uri.parse(url)
val uri = Uri.parse(url) uri.host ?: url
uri.host ?: url } catch (e: Exception) {
} catch (e: Exception) { url
url
}
} }
private fun getFileNameFromUri(uri: Uri): String { private fun getFileNameFromUri(uri: Uri): String {
@@ -354,10 +342,7 @@ class ProfileImportHandler(private val context: Context) {
} }
} }
private suspend fun importJsonConfiguration( private suspend fun importJsonConfiguration(jsonContent: String, profileName: String): ImportResult {
jsonContent: String,
profileName: String,
): ImportResult {
return try { return try {
// Validate the JSON configuration using sing-box // Validate the JSON configuration using sing-box
try { try {

View File

@@ -1,5 +1,6 @@
package io.nekohasekai.sfa.compose.screen.connections package io.nekohasekai.sfa.compose.screen.connections
import androidx.compose.foundation.ScrollState
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column 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.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.ScrollState
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
@@ -32,14 +32,14 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset 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.NestedScrollConnection
import androidx.compose.ui.input.nestedscroll.NestedScrollSource import androidx.compose.ui.input.nestedscroll.NestedScrollSource
import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.unit.Velocity
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.Velocity
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import io.nekohasekai.libbox.Libbox import io.nekohasekai.libbox.Libbox
import io.nekohasekai.sfa.R import io.nekohasekai.sfa.R
@@ -286,10 +286,7 @@ fun ConnectionDetailsScreen(
} }
@Composable @Composable
private fun DetailSection( private fun DetailSection(title: String, content: @Composable ColumnScope.() -> Unit) {
title: String,
content: @Composable ColumnScope.() -> Unit,
) {
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
@@ -317,12 +314,7 @@ private fun DetailSection(
} }
@Composable @Composable
private fun DetailRow( private fun DetailRow(label: String, value: String, monospace: Boolean = false, valueColor: Color = MaterialTheme.colorScheme.onSurface) {
label: String,
value: String,
monospace: Boolean = false,
valueColor: Color = MaterialTheme.colorScheme.onSurface,
) {
Row( Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween, horizontalArrangement = Arrangement.SpaceBetween,
@@ -346,20 +338,10 @@ private fun DetailRow(
} }
@Composable @Composable
private fun rememberBounceBlockingNestedScrollConnection( private fun rememberBounceBlockingNestedScrollConnection(scrollState: ScrollState): NestedScrollConnection = remember(scrollState) {
scrollState: ScrollState
): NestedScrollConnection = remember(scrollState) {
object : NestedScrollConnection { object : NestedScrollConnection {
override fun onPostScroll( override fun onPostScroll(consumed: Offset, available: Offset, source: NestedScrollSource): Offset = if (available.y < 0) available else Offset.Zero
consumed: Offset,
available: Offset,
source: NestedScrollSource
): Offset {
return if (available.y < 0) available else Offset.Zero
}
override suspend fun onPostFling(consumed: Velocity, available: Velocity): Velocity { override suspend fun onPostFling(consumed: Velocity, available: Velocity): Velocity = if (available.y < 0) available else Velocity.Zero
return 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.libbox.Libbox
import io.nekohasekai.sfa.R import io.nekohasekai.sfa.R
import io.nekohasekai.sfa.compose.model.Connection import io.nekohasekai.sfa.compose.model.Connection
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
private fun Drawable.toBitmap(): Bitmap { private fun Drawable.toBitmap(): Bitmap {
if (this is BitmapDrawable) return bitmap if (this is BitmapDrawable) return bitmap
val bitmap = Bitmap.createBitmap( val bitmap = Bitmap.createBitmap(
intrinsicWidth.coerceAtLeast(1), intrinsicWidth.coerceAtLeast(1),
intrinsicHeight.coerceAtLeast(1), intrinsicHeight.coerceAtLeast(1),
Bitmap.Config.ARGB_8888 Bitmap.Config.ARGB_8888,
) )
val canvas = Canvas(bitmap) val canvas = Canvas(bitmap)
setBounds(0, 0, canvas.width, canvas.height) setBounds(0, 0, canvas.width, canvas.height)
@@ -62,10 +59,7 @@ private fun Drawable.toBitmap(): Bitmap {
return bitmap return bitmap
} }
data class AppInfo( data class AppInfo(val icon: ImageBitmap, val label: String)
val icon: ImageBitmap,
val label: String,
)
@Composable @Composable
private fun rememberAppInfo(packageName: String): AppInfo? { private fun rememberAppInfo(packageName: String): AppInfo? {
@@ -86,12 +80,7 @@ private fun rememberAppInfo(packageName: String): AppInfo? {
@OptIn(ExperimentalFoundationApi::class) @OptIn(ExperimentalFoundationApi::class)
@Composable @Composable
fun ConnectionItem( fun ConnectionItem(connection: Connection, onClick: () -> Unit, onClose: () -> Unit, modifier: Modifier = Modifier) {
connection: Connection,
onClick: () -> Unit,
onClose: () -> Unit,
modifier: Modifier = Modifier,
) {
var showContextMenu by remember { mutableStateOf(false) } var showContextMenu by remember { mutableStateOf(false) }
val packageName = connection.processInfo?.packageName?.takeIf { it.isNotEmpty() } val packageName = connection.processInfo?.packageName?.takeIf { it.isNotEmpty() }
val appInfo = packageName?.let { rememberAppInfo(it) } 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.LazyListState
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState 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.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Check 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.Modifier
import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.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.res.stringResource
import androidx.compose.ui.unit.Velocity
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import io.nekohasekai.sfa.R import io.nekohasekai.sfa.R
import io.nekohasekai.sfa.compose.topbar.OverrideTopBar import io.nekohasekai.sfa.compose.model.Connection
import io.nekohasekai.sfa.compose.model.ConnectionSort import io.nekohasekai.sfa.compose.model.ConnectionSort
import io.nekohasekai.sfa.compose.model.ConnectionStateFilter import io.nekohasekai.sfa.compose.model.ConnectionStateFilter
import io.nekohasekai.sfa.compose.topbar.OverrideTopBar
import io.nekohasekai.sfa.constant.Status import io.nekohasekai.sfa.constant.Status
import io.nekohasekai.sfa.compose.model.Connection
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
@@ -118,7 +118,7 @@ fun ConnectionsPage(
ConnectionStateFilter.All -> stringResource(R.string.connection_state_all) ConnectionStateFilter.All -> stringResource(R.string.connection_state_all)
ConnectionStateFilter.Active -> stringResource(R.string.connection_state_active) ConnectionStateFilter.Active -> stringResource(R.string.connection_state_active)
ConnectionStateFilter.Closed -> stringResource(R.string.connection_state_closed) ConnectionStateFilter.Closed -> stringResource(R.string.connection_state_closed)
} },
) )
}, },
) )
@@ -230,7 +230,7 @@ fun ConnectionsPage(
stringResource(R.string.close_search) stringResource(R.string.close_search)
} else { } else {
stringResource(R.string.search) stringResource(R.string.search)
} },
) )
}, },
onClick = { onClick = {
@@ -433,20 +433,10 @@ fun ConnectionsScreen(
} }
@Composable @Composable
private fun rememberBounceBlockingNestedScrollConnection( private fun rememberBounceBlockingNestedScrollConnection(lazyListState: LazyListState): NestedScrollConnection = remember(lazyListState) {
lazyListState: LazyListState
): NestedScrollConnection = remember(lazyListState) {
object : NestedScrollConnection { object : NestedScrollConnection {
override fun onPostScroll( override fun onPostScroll(consumed: Offset, available: Offset, source: NestedScrollSource): Offset = if (available.y < 0) available else Offset.Zero
consumed: Offset,
available: Offset,
source: NestedScrollSource
): Offset {
return if (available.y < 0) available else Offset.Zero
}
override suspend fun onPostFling(consumed: Velocity, available: Velocity): Velocity { override suspend fun onPostFling(consumed: Velocity, available: Velocity): Velocity = if (available.y < 0) available else Velocity.Zero
return 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.libbox.Libbox
import io.nekohasekai.sfa.compose.base.BaseViewModel import io.nekohasekai.sfa.compose.base.BaseViewModel
import io.nekohasekai.sfa.compose.base.ScreenEvent 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.Connection
import io.nekohasekai.sfa.compose.model.ConnectionSort import io.nekohasekai.sfa.compose.model.ConnectionSort
import io.nekohasekai.sfa.compose.model.ConnectionStateFilter import io.nekohasekai.sfa.compose.model.ConnectionStateFilter
import io.nekohasekai.sfa.constant.Status
import io.nekohasekai.sfa.ktx.toList
import io.nekohasekai.sfa.utils.AppLifecycleObserver import io.nekohasekai.sfa.utils.AppLifecycleObserver
import io.nekohasekai.sfa.utils.CommandClient import io.nekohasekai.sfa.utils.CommandClient
import java.util.concurrent.atomic.AtomicLong
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
@@ -22,6 +21,7 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import java.util.concurrent.atomic.AtomicLong
data class ConnectionsUiState( data class ConnectionsUiState(
val connections: List<Connection> = emptyList(), val connections: List<Connection> = emptyList(),
@@ -38,7 +38,9 @@ sealed class ConnectionsEvent : ScreenEvent {
data object AllConnectionsClosed : ConnectionsEvent() data object AllConnectionsClosed : ConnectionsEvent()
} }
class ConnectionsViewModel : BaseViewModel<ConnectionsUiState, ConnectionsEvent>(), CommandClient.Handler { class ConnectionsViewModel :
BaseViewModel<ConnectionsUiState, ConnectionsEvent>(),
CommandClient.Handler {
private val commandClient = CommandClient( private val commandClient = CommandClient(
viewModelScope, viewModelScope,
CommandClient.ConnectionType.Connections, CommandClient.ConnectionType.Connections,
@@ -62,7 +64,7 @@ class ConnectionsViewModel : BaseViewModel<ConnectionsUiState, ConnectionsEvent>
combine( combine(
AppLifecycleObserver.isForeground, AppLifecycleObserver.isForeground,
_isVisible, _isVisible,
_serviceStatus _serviceStatus,
) { foreground, visible, status -> ) { foreground, visible, status ->
Triple(foreground, visible, status) Triple(foreground, visible, status)
}.collect { (foreground, visible, status) -> }.collect { (foreground, visible, status) ->

View File

@@ -1,7 +1,6 @@
package io.nekohasekai.sfa.compose.screen.dashboard package io.nekohasekai.sfa.compose.screen.dashboard
import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column 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.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.UnfoldMore
import androidx.compose.material.icons.filled.Check import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.UnfoldMore
import androidx.compose.material.icons.outlined.Tune import androidx.compose.material.icons.outlined.Tune
import androidx.compose.material3.Card import androidx.compose.material3.Card
import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenu
@@ -44,20 +43,15 @@ import io.nekohasekai.sfa.R
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun ClashModeCard( fun ClashModeCard(modes: List<String>, selectedMode: String, onModeSelected: (String) -> Unit, modifier: Modifier = Modifier) {
modes: List<String>,
selectedMode: String,
onModeSelected: (String) -> Unit,
modifier: Modifier = Modifier,
) {
Card( Card(
modifier = modifier.fillMaxWidth(), modifier = modifier.fillMaxWidth(),
) { ) {
Column( Column(
modifier = modifier =
Modifier Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(16.dp), .padding(16.dp),
) { ) {
Row( Row(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
@@ -109,10 +103,10 @@ fun ClashModeCard(
modes.forEachIndexed { index, mode -> modes.forEachIndexed { index, mode ->
SegmentedButton( SegmentedButton(
shape = shape =
SegmentedButtonDefaults.itemShape( SegmentedButtonDefaults.itemShape(
index = index, index = index,
count = modes.size, count = modes.size,
), ),
onClick = { onModeSelected(mode) }, onClick = { onModeSelected(mode) },
selected = mode == selectedMode, selected = mode == selectedMode,
) { ) {
@@ -127,11 +121,7 @@ fun ClashModeCard(
} }
@Composable @Composable
private fun ModeDropdown( private fun ModeDropdown(modes: List<String>, selectedMode: String, onModeSelected: (String) -> Unit) {
modes: List<String>,
selectedMode: String,
onModeSelected: (String) -> Unit,
) {
var expanded by remember { mutableStateOf(false) } var expanded by remember { mutableStateOf(false) }
Box(modifier = Modifier.fillMaxWidth()) { Box(modifier = Modifier.fillMaxWidth()) {
@@ -147,9 +137,9 @@ private fun ModeDropdown(
) { ) {
Row( Row(
modifier = modifier =
Modifier Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 12.dp), .padding(horizontal = 16.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
Text( Text(

View File

@@ -24,19 +24,15 @@ import androidx.compose.ui.unit.dp
import io.nekohasekai.sfa.R import io.nekohasekai.sfa.R
@Composable @Composable
fun ConnectionsCard( fun ConnectionsCard(connectionsIn: String, connectionsOut: String, modifier: Modifier = Modifier) {
connectionsIn: String,
connectionsOut: String,
modifier: Modifier = Modifier,
) {
Card( Card(
modifier = modifier.fillMaxWidth(), modifier = modifier.fillMaxWidth(),
) { ) {
Column( Column(
modifier = modifier =
Modifier Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(16.dp), .padding(16.dp),
) { ) {
Row( Row(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,

View File

@@ -24,7 +24,6 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
@@ -37,10 +36,7 @@ import io.nekohasekai.sfa.compose.topbar.OverrideTopBar
import io.nekohasekai.sfa.constant.Status import io.nekohasekai.sfa.constant.Status
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
data class CardRenderItem( data class CardRenderItem(val cards: List<CardGroup>, val isRow: Boolean)
val cards: List<CardGroup>,
val isRow: Boolean,
)
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
@@ -87,18 +83,18 @@ fun DashboardScreen(
} }
}, },
dismissButton = dismissButton =
if (!note.migrationLink.isNullOrBlank()) { if (!note.migrationLink.isNullOrBlank()) {
{ {
TextButton(onClick = { TextButton(onClick = {
viewModel.sendGlobalEvent(UiEvent.OpenUrl(note.migrationLink)) viewModel.sendGlobalEvent(UiEvent.OpenUrl(note.migrationLink))
viewModel.dismissDeprecatedNote() viewModel.dismissDeprecatedNote()
}) { }) {
Text(stringResource(R.string.error_deprecated_documentation)) Text(stringResource(R.string.error_deprecated_documentation))
}
} }
} else { }
null } else {
}, null
},
) )
} }
@@ -134,9 +130,9 @@ fun DashboardScreen(
} }
LazyColumn( LazyColumn(
modifier = modifier =
Modifier Modifier
.fillMaxSize() .fillMaxSize()
.padding(horizontal = 16.dp), .padding(horizontal = 16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp),
contentPadding = PaddingValues(bottom = bottomPadding), contentPadding = PaddingValues(bottom = bottomPadding),
) { ) {
@@ -172,8 +168,8 @@ fun DashboardScreen(
DashboardCardRenderer( DashboardCardRenderer(
cardGroup = cardGroup, cardGroup = cardGroup,
cardWidth = cardWidth =
uiState.cardWidths[cardGroup] uiState.cardWidths[cardGroup]
?: CardWidth.Full, ?: CardWidth.Full,
uiState = uiState, uiState = uiState,
onClashModeSelected = viewModel::selectClashMode, onClashModeSelected = viewModel::selectClashMode,
onSystemProxyToggle = viewModel::toggleSystemProxy, onSystemProxyToggle = viewModel::toggleSystemProxy,
@@ -199,9 +195,9 @@ fun DashboardScreen(
onOpenNewProfile = onOpenNewProfile, onOpenNewProfile = onOpenNewProfile,
commandClient = viewModel.commandClient, commandClient = viewModel.commandClient,
modifier = modifier =
Modifier Modifier
.weight(1f) .weight(1f)
.fillMaxWidth(), .fillMaxWidth(),
) )
} }
} }
@@ -211,8 +207,8 @@ fun DashboardScreen(
DashboardCardRenderer( DashboardCardRenderer(
cardGroup = cardGroup, cardGroup = cardGroup,
cardWidth = cardWidth =
uiState.cardWidths[cardGroup] uiState.cardWidths[cardGroup]
?: CardWidth.Full, ?: CardWidth.Full,
uiState = uiState, uiState = uiState,
serviceStatus = serviceStatus, serviceStatus = serviceStatus,
onClashModeSelected = viewModel::selectClashMode, onClashModeSelected = viewModel::selectClashMode,
@@ -307,17 +303,12 @@ fun processCardsForRendering(
* This function is only relevant when the service is running. * This function is only relevant when the service is running.
* Note: Profiles card is always available and should not use this function. * Note: Profiles card is always available and should not use this function.
*/ */
fun isCardAvailableWhenServiceRunning( fun isCardAvailableWhenServiceRunning(cardGroup: CardGroup, uiState: DashboardUiState): Boolean = when (cardGroup) {
cardGroup: CardGroup, CardGroup.ClashMode -> uiState.clashModeVisible
uiState: DashboardUiState, CardGroup.UploadTraffic -> uiState.trafficVisible
): Boolean { CardGroup.DownloadTraffic -> uiState.trafficVisible
return when (cardGroup) { CardGroup.Debug -> true // Debug info is always available when service is running
CardGroup.ClashMode -> uiState.clashModeVisible CardGroup.Connections -> uiState.trafficVisible
CardGroup.UploadTraffic -> uiState.trafficVisible CardGroup.SystemProxy -> uiState.systemProxyVisible
CardGroup.DownloadTraffic -> uiState.trafficVisible CardGroup.Profiles -> true // This shouldn't be called for Profiles, but return true for safety
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.Icons
import androidx.compose.material.icons.filled.DragHandle import androidx.compose.material.icons.filled.DragHandle
import androidx.compose.material.icons.filled.RestartAlt 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.BugReport
import androidx.compose.material.icons.outlined.Cable import androidx.compose.material.icons.outlined.Cable
import androidx.compose.material.icons.outlined.Download 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.Person
import androidx.compose.material.icons.outlined.Route import androidx.compose.material.icons.outlined.Route
import androidx.compose.material.icons.outlined.SettingsEthernet 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.unit.dp
import androidx.compose.ui.zIndex import androidx.compose.ui.zIndex
import io.nekohasekai.sfa.R import io.nekohasekai.sfa.R
import io.nekohasekai.sfa.compat.animateItemCompat
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
@@ -96,12 +95,12 @@ fun DashboardSettingsBottomSheet(
var dragOffset by remember { mutableStateOf(0f) } var dragOffset by remember { mutableStateOf(0f) }
val density = LocalDensity.current val density = LocalDensity.current
fun onMove( fun onMove(fromIndex: Int, toIndex: Int) {
fromIndex: Int, if (fromIndex != toIndex &&
toIndex: Int, fromIndex >= 0 &&
) { toIndex >= 0 &&
if (fromIndex != toIndex && fromIndex >= 0 && toIndex >= 0 && fromIndex < reorderedList.size &&
fromIndex < reorderedList.size && toIndex < reorderedList.size toIndex < reorderedList.size
) { ) {
val newList = reorderedList.toMutableList() val newList = reorderedList.toMutableList()
val item = newList.removeAt(fromIndex) val item = newList.removeAt(fromIndex)
@@ -135,17 +134,17 @@ fun DashboardSettingsBottomSheet(
) { ) {
Column( Column(
modifier = modifier =
Modifier Modifier
.fillMaxWidth() .fillMaxWidth()
.fillMaxHeight(0.8f), .fillMaxHeight(0.8f),
) { ) {
// Header with reset button // Header with reset button
Row( Row(
modifier = modifier =
Modifier Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(horizontal = 24.dp) .padding(horizontal = 24.dp)
.padding(bottom = 16.dp), .padding(bottom = 16.dp),
horizontalArrangement = Arrangement.SpaceBetween, horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
@@ -199,18 +198,18 @@ fun DashboardSettingsBottomSheet(
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = modifier =
Modifier Modifier
.padding(horizontal = 24.dp) .padding(horizontal = 24.dp)
.padding(bottom = 12.dp), .padding(bottom = 12.dp),
) )
// Reorderable list // Reorderable list
LazyColumn( LazyColumn(
state = listState, state = listState,
modifier = modifier =
Modifier Modifier
.fillMaxWidth() .fillMaxWidth()
.weight(1f), .weight(1f),
contentPadding = PaddingValues(horizontal = 24.dp, vertical = 8.dp), contentPadding = PaddingValues(horizontal = 24.dp, vertical = 8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp),
) { ) {
@@ -275,13 +274,13 @@ fun DashboardSettingsBottomSheet(
dragOffset = 0f dragOffset = 0f
}, },
modifier = modifier =
animateItemCompat( animateItemCompat(
placementSpec = placementSpec =
spring( spring(
dampingRatio = Spring.DampingRatioMediumBouncy, dampingRatio = Spring.DampingRatioMediumBouncy,
stiffness = Spring.StiffnessLow, stiffness = Spring.StiffnessLow,
),
), ),
),
) )
} }
} }
@@ -315,40 +314,40 @@ fun DashboardItemCard(
Card( Card(
modifier = modifier =
modifier modifier
.fillMaxWidth() .fillMaxWidth()
.offset(y = with(LocalDensity.current) { offsetY.value.toDp() }) .offset(y = with(LocalDensity.current) { offsetY.value.toDp() })
.zIndex(if (isDragging) 1f else 0f) .zIndex(if (isDragging) 1f else 0f)
.clip(RoundedCornerShape(12.dp)), .clip(RoundedCornerShape(12.dp)),
elevation = elevation =
CardDefaults.cardElevation( CardDefaults.cardElevation(
defaultElevation = cardElevation, defaultElevation = cardElevation,
), ),
colors = colors =
CardDefaults.cardColors( CardDefaults.cardColors(
containerColor = containerColor =
if (isDragging) { if (isDragging) {
MaterialTheme.colorScheme.surface.copy(alpha = 0.95f) MaterialTheme.colorScheme.surface.copy(alpha = 0.95f)
} else { } else {
MaterialTheme.colorScheme.surface MaterialTheme.colorScheme.surface
}, },
), ),
border = border =
BorderStroke( BorderStroke(
width = 1.dp, width = 1.dp,
color = color =
if (isVisible) { if (isVisible) {
MaterialTheme.colorScheme.primary.copy(alpha = 0.3f) MaterialTheme.colorScheme.primary.copy(alpha = 0.3f)
} else { } else {
MaterialTheme.colorScheme.outline.copy(alpha = 0.12f) MaterialTheme.colorScheme.outline.copy(alpha = 0.12f)
}, },
), ),
) { ) {
Row( Row(
modifier = modifier =
Modifier Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(12.dp), .padding(12.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
// Drag handle // Drag handle
@@ -361,66 +360,66 @@ fun DashboardItemCard(
imageVector = Icons.Default.DragHandle, imageVector = Icons.Default.DragHandle,
contentDescription = stringResource(R.string.drag_to_reorder), contentDescription = stringResource(R.string.drag_to_reorder),
modifier = modifier =
Modifier Modifier
.size(24.dp) .size(24.dp)
.draggable( .draggable(
state = draggableState, state = draggableState,
orientation = Orientation.Vertical, orientation = Orientation.Vertical,
onDragStarted = { onDragStart() }, onDragStarted = { onDragStart() },
onDragStopped = { onDragEnd() }, onDragStopped = { onDragEnd() },
) )
.padding(4.dp), .padding(4.dp),
tint = tint =
if (isDragging) { if (isDragging) {
MaterialTheme.colorScheme.primary MaterialTheme.colorScheme.primary
} else { } else {
MaterialTheme.colorScheme.onSurfaceVariant MaterialTheme.colorScheme.onSurfaceVariant
}, },
) )
// Card icon // Card icon
Icon( Icon(
imageVector = imageVector =
when (cardGroup) { when (cardGroup) {
CardGroup.Debug -> Icons.Outlined.BugReport CardGroup.Debug -> Icons.Outlined.BugReport
CardGroup.Connections -> Icons.Outlined.Cable CardGroup.Connections -> Icons.Outlined.Cable
CardGroup.UploadTraffic -> Icons.Outlined.Upload CardGroup.UploadTraffic -> Icons.Outlined.Upload
CardGroup.DownloadTraffic -> Icons.Outlined.Download CardGroup.DownloadTraffic -> Icons.Outlined.Download
CardGroup.ClashMode -> Icons.Outlined.Route CardGroup.ClashMode -> Icons.Outlined.Route
CardGroup.SystemProxy -> Icons.Outlined.SettingsEthernet CardGroup.SystemProxy -> Icons.Outlined.SettingsEthernet
CardGroup.Profiles -> Icons.Outlined.Person CardGroup.Profiles -> Icons.Outlined.Person
}, },
contentDescription = null, contentDescription = null,
modifier = modifier =
Modifier Modifier
.size(24.dp) .size(24.dp)
.padding(horizontal = 4.dp), .padding(horizontal = 4.dp),
tint = tint =
if (isVisible) { if (isVisible) {
MaterialTheme.colorScheme.primary MaterialTheme.colorScheme.primary
} else { } else {
MaterialTheme.colorScheme.onSurfaceVariant MaterialTheme.colorScheme.onSurfaceVariant
}, },
) )
// Card info // Card info
Column( Column(
modifier = modifier =
Modifier Modifier
.weight(1f) .weight(1f)
.padding(horizontal = 8.dp), .padding(horizontal = 8.dp),
) { ) {
Text( Text(
text = text =
when (cardGroup) { when (cardGroup) {
CardGroup.Debug -> stringResource(R.string.title_debug) CardGroup.Debug -> stringResource(R.string.title_debug)
CardGroup.Connections -> stringResource(R.string.title_connections) CardGroup.Connections -> stringResource(R.string.title_connections)
CardGroup.UploadTraffic -> stringResource(R.string.upload) CardGroup.UploadTraffic -> stringResource(R.string.upload)
CardGroup.DownloadTraffic -> stringResource(R.string.download) CardGroup.DownloadTraffic -> stringResource(R.string.download)
CardGroup.ClashMode -> stringResource(R.string.clash_mode) CardGroup.ClashMode -> stringResource(R.string.clash_mode)
CardGroup.SystemProxy -> stringResource(R.string.system_proxy) CardGroup.SystemProxy -> stringResource(R.string.system_proxy)
CardGroup.Profiles -> stringResource(R.string.title_configuration) CardGroup.Profiles -> stringResource(R.string.title_configuration)
}, },
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Medium, fontWeight = FontWeight.Medium,
color = MaterialTheme.colorScheme.onSurface, color = MaterialTheme.colorScheme.onSurface,

View File

@@ -114,16 +114,15 @@ data class DashboardUiState(
), ),
val showCardSettingsDialog: Boolean = false, val showCardSettingsDialog: Boolean = false,
) { ) {
data class DeprecatedNote( data class DeprecatedNote(val message: String, val migrationLink: String?)
val message: String,
val migrationLink: String?,
)
} }
// DashboardViewModel now only uses UiEvent for all events // DashboardViewModel now only uses UiEvent for all events
// No need for DashboardEvent anymore as all events are handled globally // 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) private val _serviceStatus = MutableStateFlow(Status.Stopped)
val serviceStatus: StateFlow<Status> = _serviceStatus.asStateFlow() val serviceStatus: StateFlow<Status> = _serviceStatus.asStateFlow()
@@ -395,10 +394,7 @@ class DashboardViewModel : BaseViewModel<DashboardUiState, UiEvent>(), CommandCl
} }
} }
fun moveProfile( fun moveProfile(from: Int, to: Int) {
from: Int,
to: Int,
) {
val currentProfiles = currentState.profiles.toMutableList() val currentProfiles = currentState.profiles.toMutableList()
if (from < to) { if (from < to) {
@@ -614,10 +610,7 @@ class DashboardViewModel : BaseViewModel<DashboardUiState, UiEvent>(), CommandCl
} }
} }
override fun initializeClashMode( override fun initializeClashMode(modeList: List<String>, currentMode: String) {
modeList: List<String>,
currentMode: String,
) {
viewModelScope.launch(Dispatchers.Main) { viewModelScope.launch(Dispatchers.Main) {
updateState { updateState {
copy( copy(
@@ -702,16 +695,15 @@ class DashboardViewModel : BaseViewModel<DashboardUiState, UiEvent>(), CommandCl
} }
// Helper functions for serialization // Helper functions for serialization
private fun getDefaultItemOrder() = private fun getDefaultItemOrder() = listOf(
listOf( CardGroup.UploadTraffic,
CardGroup.UploadTraffic, CardGroup.DownloadTraffic,
CardGroup.DownloadTraffic, CardGroup.Debug,
CardGroup.Debug, CardGroup.Connections,
CardGroup.Connections, CardGroup.SystemProxy,
CardGroup.SystemProxy, CardGroup.ClashMode,
CardGroup.ClashMode, CardGroup.Profiles,
CardGroup.Profiles, )
)
private fun loadItemOrder(): List<CardGroup> { private fun loadItemOrder(): List<CardGroup> {
val savedOrder = Settings.dashboardItemOrder val savedOrder = Settings.dashboardItemOrder
@@ -766,11 +758,9 @@ class DashboardViewModel : BaseViewModel<DashboardUiState, UiEvent>(), CommandCl
private fun cardGroupToString(card: CardGroup): String = card.name private fun cardGroupToString(card: CardGroup): String = card.name
private fun stringToCardGroup(name: String): CardGroup? { private fun stringToCardGroup(name: String): CardGroup? = try {
return try { CardGroup.valueOf(name)
CardGroup.valueOf(name) } catch (e: IllegalArgumentException) {
} catch (e: IllegalArgumentException) { null
null
}
} }
} }

View File

@@ -24,19 +24,15 @@ import androidx.compose.ui.unit.dp
import io.nekohasekai.sfa.R import io.nekohasekai.sfa.R
@Composable @Composable
fun DebugCard( fun DebugCard(memory: String, goroutines: String, modifier: Modifier = Modifier) {
memory: String,
goroutines: String,
modifier: Modifier = Modifier,
) {
Card( Card(
modifier = modifier.fillMaxWidth(), modifier = modifier.fillMaxWidth(),
) { ) {
Column( Column(
modifier = modifier =
Modifier Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(16.dp), .padding(16.dp),
) { ) {
Row( Row(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,

View File

@@ -24,20 +24,15 @@ import io.nekohasekai.sfa.R
import io.nekohasekai.sfa.compose.LineChart import io.nekohasekai.sfa.compose.LineChart
@Composable @Composable
fun DownloadTrafficCard( fun DownloadTrafficCard(downlink: String, downlinkTotal: String, downlinkHistory: List<Float>, modifier: Modifier = Modifier) {
downlink: String,
downlinkTotal: String,
downlinkHistory: List<Float>,
modifier: Modifier = Modifier,
) {
Card( Card(
modifier = modifier.fillMaxWidth(), modifier = modifier.fillMaxWidth(),
) { ) {
Column( Column(
modifier = modifier =
Modifier Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(16.dp), .padding(16.dp),
) { ) {
Row( Row(
verticalAlignment = Alignment.CenterVertically, 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.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.UnfoldLess
import androidx.compose.material.icons.filled.UnfoldMore
import androidx.compose.material.icons.filled.ExpandMore import androidx.compose.material.icons.filled.ExpandMore
import androidx.compose.material.icons.filled.Speed import androidx.compose.material.icons.filled.Speed
import androidx.compose.material.icons.filled.UnfoldLess
import androidx.compose.material.icons.filled.UnfoldMore
import androidx.compose.material3.Card import androidx.compose.material3.Card
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api 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.NestedScrollSource
import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.Velocity
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Velocity
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import io.nekohasekai.libbox.Libbox import io.nekohasekai.libbox.Libbox
import io.nekohasekai.sfa.R import io.nekohasekai.sfa.R
import io.nekohasekai.sfa.compose.topbar.OverrideTopBar
import io.nekohasekai.sfa.compose.screen.dashboard.groups.GroupsViewModel
import io.nekohasekai.sfa.constant.Status
import io.nekohasekai.sfa.compose.model.Group import io.nekohasekai.sfa.compose.model.Group
import io.nekohasekai.sfa.compose.model.GroupItem import io.nekohasekai.sfa.compose.model.GroupItem
import io.nekohasekai.sfa.compose.screen.dashboard.groups.GroupsViewModel
import io.nekohasekai.sfa.compose.topbar.OverrideTopBar
import io.nekohasekai.sfa.constant.Status
import io.nekohasekai.sfa.utils.CommandClient import io.nekohasekai.sfa.utils.CommandClient
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@@ -84,12 +84,12 @@ fun GroupsCard(
) { ) {
val actualViewModel: GroupsViewModel = viewModel ?: viewModel( val actualViewModel: GroupsViewModel = viewModel ?: viewModel(
factory = factory =
object : ViewModelProvider.Factory { object : ViewModelProvider.Factory {
override fun <T : androidx.lifecycle.ViewModel> create(modelClass: Class<T>): T { override fun <T : androidx.lifecycle.ViewModel> create(modelClass: Class<T>): T {
@Suppress("UNCHECKED_CAST") @Suppress("UNCHECKED_CAST")
return GroupsViewModel(commandClient) as T return GroupsViewModel(commandClient) as T
} }
}, },
) )
val snackbarHostState = remember { SnackbarHostState() } val snackbarHostState = remember { SnackbarHostState() }
val uiState by actualViewModel.uiState.collectAsState() val uiState by actualViewModel.uiState.collectAsState()
@@ -104,17 +104,17 @@ fun GroupsCard(
IconButton(onClick = { actualViewModel.toggleAllGroups() }) { IconButton(onClick = { actualViewModel.toggleAllGroups() }) {
Icon( Icon(
imageVector = imageVector =
if (allCollapsed) { if (allCollapsed) {
Icons.Default.UnfoldMore Icons.Default.UnfoldMore
} else { } else {
Icons.Default.UnfoldLess Icons.Default.UnfoldLess
}, },
contentDescription = contentDescription =
if (allCollapsed) { if (allCollapsed) {
stringResource(R.string.expand_all) stringResource(R.string.expand_all)
} else { } else {
stringResource(R.string.collapse_all) stringResource(R.string.collapse_all)
}, },
) )
} }
} }
@@ -186,9 +186,9 @@ private fun GroupsCardContent(
if (uiState.isLoading) { if (uiState.isLoading) {
Box( Box(
modifier = modifier =
Modifier Modifier
.fillMaxWidth() .fillMaxWidth()
.height(200.dp), .height(200.dp),
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center,
) { ) {
CircularProgressIndicator() CircularProgressIndicator()
@@ -196,9 +196,9 @@ private fun GroupsCardContent(
} else if (uiState.groups.isEmpty()) { } else if (uiState.groups.isEmpty()) {
Box( Box(
modifier = modifier =
Modifier Modifier
.fillMaxWidth() .fillMaxWidth()
.height(100.dp), .height(100.dp),
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center,
) { ) {
Text( Text(
@@ -216,12 +216,12 @@ private fun GroupsCardContent(
.nestedScroll(bounceBlockingConnection), .nestedScroll(bounceBlockingConnection),
state = lazyListState, state = lazyListState,
contentPadding = contentPadding =
PaddingValues( PaddingValues(
start = 16.dp, start = 16.dp,
end = 16.dp, end = 16.dp,
top = 8.dp, top = 8.dp,
bottom = 16.dp, bottom = 16.dp,
), ),
verticalArrangement = Arrangement.spacedBy(12.dp), verticalArrangement = Arrangement.spacedBy(12.dp),
) { ) {
items( items(
@@ -347,17 +347,17 @@ private fun ProxyGroupItem(
imageVector = Icons.Default.ExpandMore, imageVector = Icons.Default.ExpandMore,
contentDescription = if (isExpanded) "Collapse" else "Expand", contentDescription = if (isExpanded) "Collapse" else "Expand",
modifier = modifier =
Modifier Modifier
.size(24.dp) .size(24.dp)
.graphicsLayer { rotationZ = rotationAngle }, .graphicsLayer { rotationZ = rotationAngle },
tint = MaterialTheme.colorScheme.onSurfaceVariant, tint = MaterialTheme.colorScheme.onSurfaceVariant,
) )
} }
}, },
colors = colors =
ListItemDefaults.colors( ListItemDefaults.colors(
containerColor = Color.Transparent, containerColor = Color.Transparent,
), ),
) )
} }
@@ -365,21 +365,21 @@ private fun ProxyGroupItem(
AnimatedVisibility( AnimatedVisibility(
visible = isExpanded && group.items.isNotEmpty(), visible = isExpanded && group.items.isNotEmpty(),
enter = enter =
expandVertically(animationSpec = tween(300)) + expandVertically(animationSpec = tween(300)) +
fadeIn( fadeIn(
animationSpec = animationSpec =
tween( tween(
300, 300,
),
), ),
),
exit = exit =
shrinkVertically(animationSpec = tween(300)) + shrinkVertically(animationSpec = tween(300)) +
fadeOut( fadeOut(
animationSpec = animationSpec =
tween( tween(
300, 300,
),
), ),
),
) { ) {
Column { Column {
HorizontalDivider( HorizontalDivider(
@@ -401,12 +401,7 @@ private fun ProxyGroupItem(
} }
@Composable @Composable
private fun ProxyItemsList( private fun ProxyItemsList(items: List<GroupItem>, selectedTag: String, isSelectable: Boolean, onItemSelected: (String) -> Unit) {
items: List<GroupItem>,
selectedTag: String,
isSelectable: Boolean,
onItemSelected: (String) -> Unit,
) {
val itemsPerRow = 2 val itemsPerRow = 2
val chunkedItems = val chunkedItems =
remember(items) { remember(items) {
@@ -415,9 +410,9 @@ private fun ProxyItemsList(
Column( Column(
modifier = modifier =
Modifier Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(16.dp), .padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp), verticalArrangement = Arrangement.spacedBy(12.dp),
) { ) {
chunkedItems.forEach { rowItems -> chunkedItems.forEach { rowItems ->
@@ -450,13 +445,7 @@ private fun ProxyItemsList(
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
private fun ProxyChip( private fun ProxyChip(item: GroupItem, isSelected: Boolean, isSelectable: Boolean, onClick: () -> Unit, modifier: Modifier = Modifier) {
item: GroupItem,
isSelected: Boolean,
isSelectable: Boolean,
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
// Use simpler, faster animations // Use simpler, faster animations
val animatedElevation by animateFloatAsState( val animatedElevation by animateFloatAsState(
targetValue = if (isSelected) 6.dp.value else 1.dp.value, targetValue = if (isSelected) 6.dp.value else 1.dp.value,
@@ -475,18 +464,18 @@ private fun ProxyChip(
androidx.compose.foundation.BorderStroke( androidx.compose.foundation.BorderStroke(
width = if (isSelected) 2.dp else 1.dp, width = if (isSelected) 2.dp else 1.dp,
color = color =
when { when {
isSelected -> MaterialTheme.colorScheme.primary.copy(alpha = 0.8f) isSelected -> MaterialTheme.colorScheme.primary.copy(alpha = 0.8f)
else -> MaterialTheme.colorScheme.outline.copy(alpha = 0.2f) else -> MaterialTheme.colorScheme.outline.copy(alpha = 0.2f)
}, },
) )
val content: @Composable () -> Unit = { val content: @Composable () -> Unit = {
Row( Row(
modifier = modifier =
Modifier Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(12.dp), .padding(12.dp),
horizontalArrangement = Arrangement.SpaceBetween, horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
@@ -500,11 +489,11 @@ private fun ProxyChip(
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
fontWeight = if (isSelected) FontWeight.SemiBold else FontWeight.Medium, fontWeight = if (isSelected) FontWeight.SemiBold else FontWeight.Medium,
color = color =
if (isSelected) { if (isSelected) {
MaterialTheme.colorScheme.onPrimaryContainer MaterialTheme.colorScheme.onPrimaryContainer
} else { } else {
MaterialTheme.colorScheme.onSurfaceVariant MaterialTheme.colorScheme.onSurfaceVariant
}, },
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
) )
@@ -520,11 +509,11 @@ private fun ProxyChip(
text = Libbox.proxyDisplayType(item.type), text = Libbox.proxyDisplayType(item.type),
style = MaterialTheme.typography.labelSmall, style = MaterialTheme.typography.labelSmall,
color = color =
if (isSelected) { if (isSelected) {
MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.7f) MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.7f)
} else { } else {
MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f) MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f)
}, },
) )
// Latency // Latency
@@ -566,11 +555,7 @@ private fun ProxyChip(
} }
@Composable @Composable
private fun ProxyLatencyBadge( private fun ProxyLatencyBadge(delay: Int, isSelected: Boolean, modifier: Modifier = Modifier) {
delay: Int,
isSelected: Boolean,
modifier: Modifier = Modifier,
) {
// Direct color calculation without animation for better performance // Direct color calculation without animation for better performance
val colorScheme = MaterialTheme.colorScheme val colorScheme = MaterialTheme.colorScheme
val latencyColor = val latencyColor =
@@ -624,15 +609,9 @@ private fun ProxyLatencyBadge(
} }
@Composable @Composable
private fun rememberBounceBlockingNestedScrollConnection( private fun rememberBounceBlockingNestedScrollConnection(lazyListState: LazyListState): NestedScrollConnection = remember(lazyListState) {
lazyListState: LazyListState
): NestedScrollConnection = remember(lazyListState) {
object : NestedScrollConnection { object : NestedScrollConnection {
override fun onPostScroll( override fun onPostScroll(consumed: Offset, available: Offset, source: NestedScrollSource): Offset {
consumed: Offset,
available: Offset,
source: NestedScrollSource
): Offset {
// Only block upward scroll (y < 0) at bottom to prevent sheet expansion // Only block upward scroll (y < 0) at bottom to prevent sheet expansion
// Allow downward scroll (y > 0) at top to let sheet collapse // Allow downward scroll (y > 0) at top to let sheet collapse
return if (available.y < 0) available else Offset.Zero 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.animateFloatAsState
import androidx.compose.animation.core.tween import androidx.compose.animation.core.tween
import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
@@ -42,9 +43,7 @@ import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.toArgb
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
@@ -52,6 +51,7 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight

View File

@@ -1,5 +1,6 @@
package io.nekohasekai.sfa.compose.screen.dashboard package io.nekohasekai.sfa.compose.screen.dashboard
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
@@ -16,7 +17,6 @@ import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
@@ -29,11 +29,7 @@ import io.nekohasekai.sfa.compose.util.ProfileIcons
import io.nekohasekai.sfa.database.Profile import io.nekohasekai.sfa.database.Profile
@Composable @Composable
fun ProfileSelectorButton( fun ProfileSelectorButton(selectedProfile: Profile?, onClick: () -> Unit, modifier: Modifier = Modifier) {
selectedProfile: Profile?,
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
Surface( Surface(
onClick = onClick, onClick = onClick,
modifier = modifier.fillMaxWidth().height(48.dp), modifier = modifier.fillMaxWidth().height(48.dp),

View File

@@ -5,6 +5,7 @@ import android.widget.Toast
import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column 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.Add
import androidx.compose.material.icons.filled.Check import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Cloud 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.Edit
import androidx.compose.material.icons.filled.IosShare import androidx.compose.material.icons.filled.IosShare
import androidx.compose.material.icons.filled.QrCode2 import androidx.compose.material.icons.filled.QrCode2
import androidx.compose.material.icons.filled.QrCodeScanner import androidx.compose.material.icons.filled.QrCodeScanner
import androidx.compose.material.icons.filled.Refresh 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.filled.Save
import androidx.compose.material.icons.outlined.CreateNewFolder import androidx.compose.material.icons.outlined.CreateNewFolder
import androidx.compose.material.icons.outlined.Description import androidx.compose.material.icons.outlined.Description
import androidx.compose.material.icons.outlined.FileUpload import androidx.compose.material.icons.outlined.FileUpload
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Card import androidx.compose.material3.Card
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.ListItem import androidx.compose.material3.ListItem
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.runtime.Composable 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.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
@@ -55,6 +53,7 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
@@ -64,11 +63,11 @@ import io.nekohasekai.libbox.Libbox
import io.nekohasekai.libbox.ProfileContent import io.nekohasekai.libbox.ProfileContent
import io.nekohasekai.sfa.R import io.nekohasekai.sfa.R
import io.nekohasekai.sfa.compose.component.qr.QRCodeDialog import io.nekohasekai.sfa.compose.component.qr.QRCodeDialog
import io.nekohasekai.sfa.compose.component.qr.QRScanSheet
import io.nekohasekai.sfa.compose.component.qr.QRSDialog 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.navigation.NewProfileArgs
import io.nekohasekai.sfa.compose.screen.qrscan.QRScanResult
import io.nekohasekai.sfa.compose.screen.configuration.ProfileImportHandler import io.nekohasekai.sfa.compose.screen.configuration.ProfileImportHandler
import io.nekohasekai.sfa.compose.screen.qrscan.QRScanResult
import io.nekohasekai.sfa.compose.util.QRCodeGenerator import io.nekohasekai.sfa.compose.util.QRCodeGenerator
import io.nekohasekai.sfa.compose.util.RelativeTimeFormatter import io.nekohasekai.sfa.compose.util.RelativeTimeFormatter
import io.nekohasekai.sfa.database.Profile import io.nekohasekai.sfa.database.Profile

View File

@@ -23,20 +23,15 @@ import androidx.compose.ui.unit.dp
import io.nekohasekai.sfa.R import io.nekohasekai.sfa.R
@Composable @Composable
fun SystemProxyCard( fun SystemProxyCard(enabled: Boolean, isSwitching: Boolean, onToggle: (Boolean) -> Unit, modifier: Modifier = Modifier) {
enabled: Boolean,
isSwitching: Boolean,
onToggle: (Boolean) -> Unit,
modifier: Modifier = Modifier,
) {
Card( Card(
modifier = modifier.fillMaxWidth(), modifier = modifier.fillMaxWidth(),
) { ) {
Row( Row(
modifier = modifier =
Modifier Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(16.dp), .padding(16.dp),
horizontalArrangement = Arrangement.SpaceBetween, horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {

View File

@@ -24,20 +24,15 @@ import io.nekohasekai.sfa.R
import io.nekohasekai.sfa.compose.LineChart import io.nekohasekai.sfa.compose.LineChart
@Composable @Composable
fun UploadTrafficCard( fun UploadTrafficCard(uplink: String, uplinkTotal: String, uplinkHistory: List<Float>, modifier: Modifier = Modifier) {
uplink: String,
uplinkTotal: String,
uplinkHistory: List<Float>,
modifier: Modifier = Modifier,
) {
Card( Card(
modifier = modifier.fillMaxWidth(), modifier = modifier.fillMaxWidth(),
) { ) {
Column( Column(
modifier = modifier =
Modifier Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(16.dp), .padding(16.dp),
) { ) {
Row( Row(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,

View File

@@ -54,9 +54,9 @@ import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import io.nekohasekai.libbox.Libbox import io.nekohasekai.libbox.Libbox
import io.nekohasekai.sfa.R import io.nekohasekai.sfa.R
import io.nekohasekai.sfa.constant.Status
import io.nekohasekai.sfa.compose.model.Group import io.nekohasekai.sfa.compose.model.Group
import io.nekohasekai.sfa.compose.model.GroupItem import io.nekohasekai.sfa.compose.model.GroupItem
import io.nekohasekai.sfa.constant.Status
@Composable @Composable
fun GroupsScreen( fun GroupsScreen(
@@ -121,12 +121,12 @@ fun GroupsScreen(
LazyColumn( LazyColumn(
modifier = modifier.fillMaxSize(), modifier = modifier.fillMaxSize(),
contentPadding = contentPadding =
PaddingValues( PaddingValues(
start = 16.dp, start = 16.dp,
end = 16.dp, end = 16.dp,
top = 8.dp, top = 8.dp,
bottom = 16.dp, bottom = 16.dp,
), ),
verticalArrangement = Arrangement.spacedBy(12.dp), verticalArrangement = Arrangement.spacedBy(12.dp),
) { ) {
items( items(
@@ -254,17 +254,17 @@ private fun ProxyGroupCard(
imageVector = Icons.Default.ExpandMore, imageVector = Icons.Default.ExpandMore,
contentDescription = if (isExpanded) collapseContentDescription else expandContentDescription, contentDescription = if (isExpanded) collapseContentDescription else expandContentDescription,
modifier = modifier =
Modifier Modifier
.size(24.dp) .size(24.dp)
.graphicsLayer { rotationZ = rotationAngle }, .graphicsLayer { rotationZ = rotationAngle },
tint = MaterialTheme.colorScheme.onSurfaceVariant, tint = MaterialTheme.colorScheme.onSurfaceVariant,
) )
} }
}, },
colors = colors =
ListItemDefaults.colors( ListItemDefaults.colors(
containerColor = Color.Transparent, containerColor = Color.Transparent,
), ),
) )
} }
@@ -294,12 +294,7 @@ private fun ProxyGroupCard(
} }
@Composable @Composable
private fun ProxyItemsList( private fun ProxyItemsList(items: List<GroupItem>, selectedTag: String, isSelectable: Boolean, onItemSelected: (String) -> Unit) {
items: List<GroupItem>,
selectedTag: String,
isSelectable: Boolean,
onItemSelected: (String) -> Unit,
) {
// Cache the chunked items to avoid re-chunking on every recomposition // Cache the chunked items to avoid re-chunking on every recomposition
val itemsPerRow = 2 val itemsPerRow = 2
val chunkedItems = val chunkedItems =
@@ -310,9 +305,9 @@ private fun ProxyItemsList(
// Use Column with Rows for better control over item sizing // Use Column with Rows for better control over item sizing
Column( Column(
modifier = modifier =
Modifier Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(16.dp), .padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp), verticalArrangement = Arrangement.spacedBy(12.dp),
) { ) {
chunkedItems.forEach { rowItems -> chunkedItems.forEach { rowItems ->
@@ -344,13 +339,7 @@ private fun ProxyItemsList(
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
private fun ProxyChip( private fun ProxyChip(item: GroupItem, isSelected: Boolean, isSelectable: Boolean, onClick: () -> Unit, modifier: Modifier = Modifier) {
item: GroupItem,
isSelected: Boolean,
isSelectable: Boolean,
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
// Use simpler, faster animations // Use simpler, faster animations
val animatedElevation by animateFloatAsState( val animatedElevation by animateFloatAsState(
targetValue = if (isSelected) 6.dp.value else 1.dp.value, targetValue = if (isSelected) 6.dp.value else 1.dp.value,
@@ -369,18 +358,18 @@ private fun ProxyChip(
androidx.compose.foundation.BorderStroke( androidx.compose.foundation.BorderStroke(
width = if (isSelected) 2.dp else 1.dp, width = if (isSelected) 2.dp else 1.dp,
color = color =
when { when {
isSelected -> MaterialTheme.colorScheme.primary.copy(alpha = 0.8f) isSelected -> MaterialTheme.colorScheme.primary.copy(alpha = 0.8f)
else -> MaterialTheme.colorScheme.outline.copy(alpha = 0.2f) else -> MaterialTheme.colorScheme.outline.copy(alpha = 0.2f)
}, },
) )
val content: @Composable () -> Unit = { val content: @Composable () -> Unit = {
Row( Row(
modifier = modifier =
Modifier Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(12.dp), .padding(12.dp),
horizontalArrangement = Arrangement.SpaceBetween, horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
@@ -394,11 +383,11 @@ private fun ProxyChip(
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
fontWeight = if (isSelected) FontWeight.SemiBold else FontWeight.Medium, fontWeight = if (isSelected) FontWeight.SemiBold else FontWeight.Medium,
color = color =
if (isSelected) { if (isSelected) {
MaterialTheme.colorScheme.onPrimaryContainer MaterialTheme.colorScheme.onPrimaryContainer
} else { } else {
MaterialTheme.colorScheme.onSurfaceVariant MaterialTheme.colorScheme.onSurfaceVariant
}, },
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
) )
@@ -414,11 +403,11 @@ private fun ProxyChip(
text = Libbox.proxyDisplayType(item.type), text = Libbox.proxyDisplayType(item.type),
style = MaterialTheme.typography.labelSmall, style = MaterialTheme.typography.labelSmall,
color = color =
if (isSelected) { if (isSelected) {
MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.7f) MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.7f)
} else { } else {
MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f) MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f)
}, },
) )
// Latency // Latency
@@ -460,11 +449,7 @@ private fun ProxyChip(
} }
@Composable @Composable
private fun ProxyLatencyBadge( private fun ProxyLatencyBadge(delay: Int, isSelected: Boolean, modifier: Modifier = Modifier) {
delay: Int,
isSelected: Boolean,
modifier: Modifier = Modifier,
) {
// Direct color calculation without animation for better performance // Direct color calculation without animation for better performance
val colorScheme = MaterialTheme.colorScheme val colorScheme = MaterialTheme.colorScheme
val latencyColor = val latencyColor =

View File

@@ -5,10 +5,10 @@ import io.nekohasekai.libbox.Libbox
import io.nekohasekai.libbox.OutboundGroup import io.nekohasekai.libbox.OutboundGroup
import io.nekohasekai.sfa.compose.base.BaseViewModel import io.nekohasekai.sfa.compose.base.BaseViewModel
import io.nekohasekai.sfa.compose.base.ScreenEvent 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.Group
import io.nekohasekai.sfa.compose.model.GroupItem import io.nekohasekai.sfa.compose.model.GroupItem
import io.nekohasekai.sfa.compose.model.toList import io.nekohasekai.sfa.compose.model.toList
import io.nekohasekai.sfa.constant.Status
import io.nekohasekai.sfa.utils.AppLifecycleObserver import io.nekohasekai.sfa.utils.AppLifecycleObserver
import io.nekohasekai.sfa.utils.CommandClient import io.nekohasekai.sfa.utils.CommandClient
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@@ -28,9 +28,9 @@ sealed class GroupsEvent : ScreenEvent {
data class GroupSelected(val groupTag: String, val itemTag: String) : GroupsEvent() data class GroupSelected(val groupTag: String, val itemTag: String) : GroupsEvent()
} }
class GroupsViewModel( class GroupsViewModel(private val sharedCommandClient: CommandClient? = null) :
private val sharedCommandClient: CommandClient? = null, BaseViewModel<GroupsUiState, GroupsEvent>(),
) : BaseViewModel<GroupsUiState, GroupsEvent>(), CommandClient.Handler { CommandClient.Handler {
private val commandClient: CommandClient private val commandClient: CommandClient
private val isUsingSharedClient: Boolean private val isUsingSharedClient: Boolean
@@ -154,10 +154,7 @@ class GroupsViewModel(
} }
} }
fun selectGroupItem( fun selectGroupItem(groupTag: String, itemTag: String) {
groupTag: String,
itemTag: String,
) {
// Check if this is actually a different selection // Check if this is actually a different selection
val currentGroup = uiState.value.groups.find { it.tag == groupTag } val currentGroup = uiState.value.groups.find { it.tag == groupTag }
if (currentGroup?.selected == itemTag) { if (currentGroup?.selected == itemTag) {
@@ -175,13 +172,13 @@ class GroupsViewModel(
updateState { updateState {
copy( copy(
groups = groups =
groups.map { group -> groups.map { group ->
if (group.tag == groupTag) { if (group.tag == groupTag) {
group.copy(selected = itemTag) group.copy(selected = itemTag)
} else { } else {
group group
} }
}, },
showCloseConnectionsSnackbar = true, showCloseConnectionsSnackbar = true,
) )
} }

View File

@@ -15,7 +15,9 @@ import java.util.LinkedList
import java.util.concurrent.atomic.AtomicLong import java.util.concurrent.atomic.AtomicLong
@OptIn(FlowPreview::class) @OptIn(FlowPreview::class)
abstract class BaseLogViewModel : ViewModel(), LogViewerViewModel { abstract class BaseLogViewModel :
ViewModel(),
LogViewerViewModel {
protected val _uiState = MutableStateFlow(LogUiState()) protected val _uiState = MutableStateFlow(LogUiState())
override val uiState: StateFlow<LogUiState> = _uiState.asStateFlow() override val uiState: StateFlow<LogUiState> = _uiState.asStateFlow()
@@ -119,9 +121,7 @@ abstract class BaseLogViewModel : ViewModel(), LogViewerViewModel {
.joinToString("\n") .joinToString("\n")
} }
override fun getAllLogsText(): String { override fun getAllLogsText(): String = _uiState.value.logs.joinToString("\n") { AnsiColorUtils.stripAnsi(it.entry.message) }
return _uiState.value.logs.joinToString("\n") { AnsiColorUtils.stripAnsi(it.entry.message) }
}
protected fun updateDisplayedLogs() { protected fun updateDisplayedLogs() {
val currentState = _uiState.value val currentState = _uiState.value

View File

@@ -3,16 +3,9 @@ package io.nekohasekai.sfa.compose.screen.log
import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.AnnotatedString
import io.nekohasekai.sfa.constant.Status import io.nekohasekai.sfa.constant.Status
data class LogEntryData( data class LogEntryData(val level: LogLevel, val message: String)
val level: LogLevel,
val message: String,
)
data class ProcessedLogEntry( data class ProcessedLogEntry(val id: Long, val entry: LogEntryData, val annotatedString: AnnotatedString)
val id: Long,
val entry: LogEntryData,
val annotatedString: AnnotatedString,
)
enum class LogLevel(val label: String, val priority: Int) { enum class LogLevel(val label: String, val priority: Int) {
Default("Default", 7), Default("Default", 7),

View File

@@ -1,9 +1,9 @@
package io.nekohasekai.sfa.compose.screen.log package io.nekohasekai.sfa.compose.screen.log
import android.content.ClipData import android.content.ClipData
import android.os.Build
import android.content.res.Configuration
import android.content.Intent import android.content.Intent
import android.content.res.Configuration
import android.os.Build
import android.widget.Toast import android.widget.Toast
import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
@@ -144,17 +144,17 @@ fun LogScreen(
IconButton(onClick = { resolvedViewModel.togglePause() }) { IconButton(onClick = { resolvedViewModel.togglePause() }) {
Icon( Icon(
imageVector = imageVector =
if (uiState.isPaused) { if (uiState.isPaused) {
Icons.Default.PlayArrow Icons.Default.PlayArrow
} else { } else {
Icons.Default.Pause Icons.Default.Pause
}, },
contentDescription = contentDescription =
if (uiState.isPaused) { if (uiState.isPaused) {
stringResource(R.string.content_description_resume_logs) stringResource(R.string.content_description_resume_logs)
} else { } else {
stringResource(R.string.content_description_pause_logs) stringResource(R.string.content_description_pause_logs)
}, },
) )
} }
} }
@@ -162,23 +162,23 @@ fun LogScreen(
IconButton(onClick = { resolvedViewModel.toggleSearch() }) { IconButton(onClick = { resolvedViewModel.toggleSearch() }) {
Icon( Icon(
imageVector = imageVector =
if (uiState.isSearchActive) { if (uiState.isSearchActive) {
Icons.Default.ExpandLess Icons.Default.ExpandLess
} else { } else {
Icons.Default.Search Icons.Default.Search
}, },
contentDescription = contentDescription =
if (uiState.isSearchActive) { if (uiState.isSearchActive) {
stringResource(R.string.content_description_collapse_search) stringResource(R.string.content_description_collapse_search)
} else { } else {
stringResource(R.string.content_description_search_logs) stringResource(R.string.content_description_search_logs)
}, },
tint = tint =
if (uiState.isSearchActive) { if (uiState.isSearchActive) {
MaterialTheme.colorScheme.primary MaterialTheme.colorScheme.primary
} else { } else {
MaterialTheme.colorScheme.onSurface MaterialTheme.colorScheme.onSurface
}, },
) )
} }
@@ -281,9 +281,9 @@ fun LogScreen(
) { ) {
Row( Row(
modifier = modifier =
Modifier Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(horizontal = 8.dp, vertical = 4.dp), .padding(horizontal = 8.dp, vertical = 4.dp),
horizontalArrangement = Arrangement.SpaceBetween, horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
@@ -298,10 +298,10 @@ fun LogScreen(
} }
Text( Text(
text = text =
stringResource( stringResource(
R.string.selected_count, R.string.selected_count,
uiState.selectedLogIndices.size, uiState.selectedLogIndices.size,
), ),
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.titleMedium,
modifier = Modifier.padding(start = 8.dp), modifier = Modifier.padding(start = 8.dp),
) )
@@ -343,18 +343,18 @@ fun LogScreen(
) { ) {
Row( Row(
modifier = modifier =
Modifier Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp), .padding(horizontal = 16.dp, vertical = 8.dp),
horizontalArrangement = Arrangement.SpaceBetween, horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
Text( Text(
text = text =
stringResource( stringResource(
R.string.filter_label, R.string.filter_label,
uiState.filterLogLevel.label, uiState.filterLogLevel.label,
), ),
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
) )
TextButton( TextButton(
@@ -375,19 +375,19 @@ fun LogScreen(
AnimatedVisibility( AnimatedVisibility(
visible = uiState.isSearchActive, visible = uiState.isSearchActive,
enter = enter =
expandVertically( expandVertically(
animationSpec = tween(300),
) +
fadeIn(
animationSpec = tween(300), animationSpec = tween(300),
) + ),
fadeIn(
animationSpec = tween(300),
),
exit = exit =
shrinkVertically( shrinkVertically(
animationSpec = tween(300),
) +
fadeOut(
animationSpec = tween(300), animationSpec = tween(300),
) + ),
fadeOut(
animationSpec = tween(300),
),
) { ) {
Surface( Surface(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
@@ -405,10 +405,10 @@ fun LogScreen(
value = uiState.searchQuery, value = uiState.searchQuery,
onValueChange = { resolvedViewModel.updateSearchQuery(it) }, onValueChange = { resolvedViewModel.updateSearchQuery(it) },
modifier = modifier =
Modifier Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(start = 16.dp, end = 16.dp, bottom = 12.dp) .padding(start = 16.dp, end = 16.dp, bottom = 12.dp)
.focusRequester(focusRequester), .focusRequester(focusRequester),
placeholder = { Text(stringResource(R.string.search_logs_placeholder)) }, placeholder = { Text(stringResource(R.string.search_logs_placeholder)) },
leadingIcon = { leadingIcon = {
Icon( Icon(
@@ -429,11 +429,11 @@ fun LogScreen(
singleLine = true, singleLine = true,
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search), keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
keyboardActions = keyboardActions =
KeyboardActions( KeyboardActions(
onSearch = { onSearch = {
focusManager.clearFocus() focusManager.clearFocus()
}, },
), ),
) )
} }
} }
@@ -495,12 +495,12 @@ fun LogScreen(
state = listState, state = listState,
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
contentPadding = contentPadding =
PaddingValues( PaddingValues(
start = 8.dp, start = 8.dp,
end = 8.dp, end = 8.dp,
top = 8.dp, top = 8.dp,
bottom = bottomPadding, bottom = bottomPadding,
), ),
verticalArrangement = Arrangement.spacedBy(2.dp), verticalArrangement = Arrangement.spacedBy(2.dp),
) { ) {
itemsIndexed( itemsIndexed(
@@ -532,9 +532,9 @@ fun LogScreen(
// Options Menu - Material 3 style // Options Menu - Material 3 style
Box( Box(
modifier = modifier =
Modifier Modifier
.align(Alignment.TopEnd) .align(Alignment.TopEnd)
.padding(end = 8.dp), .padding(end = 8.dp),
) { ) {
var expandedLogLevel by remember { mutableStateOf(false) } var expandedLogLevel by remember { mutableStateOf(false) }
var expandedSave by remember { mutableStateOf(false) } var expandedSave by remember { mutableStateOf(false) }
@@ -595,11 +595,11 @@ fun LogScreen(
trailingIcon = { trailingIcon = {
Icon( Icon(
imageVector = imageVector =
if (expandedLogLevel) { if (expandedLogLevel) {
Icons.Default.ExpandLess Icons.Default.ExpandLess
} else { } else {
Icons.Default.ExpandMore Icons.Default.ExpandMore
}, },
contentDescription = null, contentDescription = null,
) )
}, },
@@ -620,23 +620,23 @@ fun LogScreen(
leadingIcon = { leadingIcon = {
Icon( Icon(
imageVector = imageVector =
if (uiState.filterLogLevel == level) { if (uiState.filterLogLevel == level) {
Icons.Default.RadioButtonChecked Icons.Default.RadioButtonChecked
} else { } else {
Icons.Default.RadioButtonUnchecked Icons.Default.RadioButtonUnchecked
}, },
contentDescription = contentDescription =
if (uiState.filterLogLevel == level) { if (uiState.filterLogLevel == level) {
stringResource(R.string.group_selected_title) stringResource(R.string.group_selected_title)
} else { } else {
null null
}, },
tint = tint =
if (uiState.filterLogLevel == level) { if (uiState.filterLogLevel == level) {
MaterialTheme.colorScheme.primary MaterialTheme.colorScheme.primary
} else { } else {
MaterialTheme.colorScheme.onSurfaceVariant MaterialTheme.colorScheme.onSurfaceVariant
}, },
modifier = Modifier.padding(start = 24.dp), modifier = Modifier.padding(start = 24.dp),
) )
}, },
@@ -665,11 +665,11 @@ fun LogScreen(
trailingIcon = { trailingIcon = {
Icon( Icon(
imageVector = imageVector =
if (expandedSave) { if (expandedSave) {
Icons.Default.ExpandLess Icons.Default.ExpandLess
} else { } else {
Icons.Default.ExpandMore Icons.Default.ExpandMore
}, },
contentDescription = null, contentDescription = null,
) )
}, },
@@ -841,9 +841,9 @@ fun LogScreen(
val fabEndPadding = if (isTablet) 20.dp else 16.dp val fabEndPadding = if (isTablet) 20.dp else 16.dp
Column( Column(
modifier = modifier =
Modifier Modifier
.align(Alignment.BottomEnd) .align(Alignment.BottomEnd)
.padding(bottom = fabBottomPadding, end = fabEndPadding, top = 16.dp), .padding(bottom = fabBottomPadding, end = fabEndPadding, top = 16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp),
) { ) {
// Scroll to bottom FAB // Scroll to bottom FAB
@@ -880,34 +880,34 @@ fun LogItem(
) { ) {
Card( Card(
modifier = modifier =
Modifier Modifier
.fillMaxWidth() .fillMaxWidth()
.combinedClickable( .combinedClickable(
onClick = onClick, onClick = onClick,
onLongClick = onLongClick, onLongClick = onLongClick,
), ),
shape = RoundedCornerShape(4.dp), shape = RoundedCornerShape(4.dp),
colors = colors =
CardDefaults.cardColors( CardDefaults.cardColors(
containerColor = containerColor =
if (isSelected) {
MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.3f)
} else {
MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f)
},
),
border =
if (isSelected) { if (isSelected) {
CardDefaults.outlinedCardBorder().copy( MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.3f)
width = 2.dp,
brush =
androidx.compose.ui.graphics.SolidColor(
MaterialTheme.colorScheme.primary.copy(alpha = 0.5f),
),
)
} else { } 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( Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
@@ -917,13 +917,13 @@ fun LogItem(
Icon( Icon(
imageVector = if (isSelected) Icons.Default.CheckBox else Icons.Default.CheckBoxOutlineBlank, imageVector = if (isSelected) Icons.Default.CheckBox else Icons.Default.CheckBoxOutlineBlank,
contentDescription = contentDescription =
if (isSelected) { if (isSelected) {
stringResource(R.string.group_selected_title) stringResource(R.string.group_selected_title)
} else { } else {
stringResource( stringResource(
R.string.not_selected, R.string.not_selected,
) )
}, },
modifier = Modifier.padding(start = 12.dp, end = 4.dp), modifier = Modifier.padding(start = 12.dp, end = 4.dp),
tint = if (isSelected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant, tint = if (isSelected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant,
) )
@@ -931,14 +931,14 @@ fun LogItem(
Text( Text(
text = annotatedString, text = annotatedString,
modifier = modifier =
Modifier Modifier
.weight(1f) .weight(1f)
.padding( .padding(
start = if (isSelectionMode) 4.dp else 12.dp, start = if (isSelectionMode) 4.dp else 12.dp,
end = 12.dp, end = 12.dp,
top = 8.dp, top = 8.dp,
bottom = 8.dp, bottom = 8.dp,
), ),
fontSize = 13.sp, fontSize = 13.sp,
fontFamily = FontFamily.Monospace, fontFamily = FontFamily.Monospace,
lineHeight = 18.sp, lineHeight = 18.sp,

View File

@@ -13,7 +13,9 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import java.util.LinkedList import java.util.LinkedList
class LogViewModel : BaseLogViewModel(), CommandClient.Handler { class LogViewModel :
BaseLogViewModel(),
CommandClient.Handler {
companion object { companion object {
private val maxLines = 3000 private val maxLines = 3000
} }

View File

@@ -4,9 +4,9 @@ import android.content.pm.PackageManager
import android.os.Build import android.os.Build
import android.widget.Toast import android.widget.Toast
import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.expandVertically
import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut import androidx.compose.animation.fadeOut
import androidx.compose.animation.expandVertically
import androidx.compose.animation.shrinkVertically import androidx.compose.animation.shrinkVertically
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column 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.res.stringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import io.nekohasekai.sfa.R import io.nekohasekai.sfa.R
import io.nekohasekai.sfa.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.AppSelectionCard
import io.nekohasekai.sfa.compose.shared.PackageCache import io.nekohasekai.sfa.compose.shared.PackageCache
import io.nekohasekai.sfa.compose.shared.SortMode import io.nekohasekai.sfa.compose.shared.SortMode
import io.nekohasekai.sfa.compose.shared.buildDisplayPackages 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.utils.PrivilegeSettingsClient
import io.nekohasekai.sfa.vendor.PackageQueryManager import io.nekohasekai.sfa.vendor.PackageQueryManager
import io.nekohasekai.sfa.vendor.PrivilegedAccessRequiredException import io.nekohasekai.sfa.vendor.PrivilegedAccessRequiredException
@@ -68,11 +68,7 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import java.util.Locale 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" 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 hasManagement = permissions.any { it in managementPermissions }
val isSelf = packageCache.packageName == context.packageName val isSelf = packageCache.packageName == context.packageName
val hasVpnService = val hasVpnService =
!isSelf && ( !isSelf &&
permissions.any { it == VPN_SERVICE_PERMISSION } || (
packageCache.info.services?.any { it.permission == VPN_SERVICE_PERMISSION } == true permissions.any { it == VPN_SERVICE_PERMISSION } ||
) packageCache.info.services?.any { it.permission == VPN_SERVICE_PERMISSION } == true
)
return when { return when {
hasManagement && hasVpnService -> RiskCategory.BOTH hasManagement && hasVpnService -> RiskCategory.BOTH
hasManagement -> RiskCategory.MANAGEMENT_APP hasManagement -> RiskCategory.MANAGEMENT_APP
@@ -138,11 +135,9 @@ fun PrivilegeSettingsManageScreen(onBack: () -> Unit) {
} }
} }
fun buildPackageList(newUids: Set<Int>): Set<String> { fun buildPackageList(newUids: Set<Int>): Set<String> = newUids.mapNotNull { uid ->
return newUids.mapNotNull { uid -> packages.find { it.uid == uid }?.packageName
packages.find { it.uid == uid }?.packageName }.toSet()
}.toSet()
}
fun updateCurrentPackages(filterQuery: String) { fun updateCurrentPackages(filterQuery: String) {
currentPackages = currentPackages =
@@ -443,10 +438,10 @@ fun PrivilegeSettingsManageScreen(onBack: () -> Unit) {
) )
}, },
colors = colors =
TopAppBarDefaults.topAppBarColors( TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.surface, containerColor = MaterialTheme.colorScheme.surface,
titleContentColor = MaterialTheme.colorScheme.onSurface, titleContentColor = MaterialTheme.colorScheme.onSurface,
), ),
) )
} }
@@ -492,10 +487,10 @@ fun PrivilegeSettingsManageScreen(onBack: () -> Unit) {
updateCurrentPackages(it) updateCurrentPackages(it)
}, },
modifier = modifier =
Modifier Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp) .padding(horizontal = 16.dp, vertical = 8.dp)
.focusRequester(focusRequester), .focusRequester(focusRequester),
placeholder = { Text(stringResource(R.string.search)) }, placeholder = { Text(stringResource(R.string.search)) },
leadingIcon = { leadingIcon = {
Icon( Icon(
@@ -524,10 +519,10 @@ fun PrivilegeSettingsManageScreen(onBack: () -> Unit) {
LazyColumn( LazyColumn(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
contentPadding = contentPadding =
androidx.compose.foundation.layout.PaddingValues( androidx.compose.foundation.layout.PaddingValues(
horizontal = 16.dp, horizontal = 16.dp,
vertical = 12.dp, vertical = 12.dp,
), ),
verticalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp),
) { ) {
items(currentPackages, key = { it.packageName }) { packageCache -> items(currentPackages, key = { it.packageName }) { packageCache ->

View File

@@ -38,11 +38,7 @@ data class EditProfileContentUiState(
val profileName: String = "", // Add profile name val profileName: String = "", // Add profile name
) )
class EditProfileContentViewModel( class EditProfileContentViewModel(private val profileId: Long, initialProfileName: String = "", initialIsReadOnly: Boolean = false) : ViewModel() {
private val profileId: Long,
initialProfileName: String = "",
initialIsReadOnly: Boolean = false,
) : ViewModel() {
private val _uiState = private val _uiState =
MutableStateFlow( MutableStateFlow(
EditProfileContentUiState( EditProfileContentUiState(
@@ -56,10 +52,7 @@ class EditProfileContentViewModel(
private var editor: ManualScrollTextProcessor? = null private var editor: ManualScrollTextProcessor? = null
private var configCheckJob: Job? = null private var configCheckJob: Job? = null
fun setEditor( fun setEditor(textProcessor: ManualScrollTextProcessor, isReadOnly: Boolean = false) {
textProcessor: ManualScrollTextProcessor,
isReadOnly: Boolean = false,
) {
val isNewEditor = editor != textProcessor val isNewEditor = editor != textProcessor
editor = textProcessor editor = textProcessor
textProcessor.resumeAutoScroll() textProcessor.resumeAutoScroll()
@@ -89,18 +82,12 @@ class EditProfileContentViewModel(
// Customize text selection to remove Cut and Paste options // Customize text selection to remove Cut and Paste options
textProcessor.customSelectionActionModeCallback = textProcessor.customSelectionActionModeCallback =
object : android.view.ActionMode.Callback { object : android.view.ActionMode.Callback {
override fun onCreateActionMode( override fun onCreateActionMode(mode: android.view.ActionMode?, menu: android.view.Menu?): Boolean {
mode: android.view.ActionMode?,
menu: android.view.Menu?,
): Boolean {
// Allow the action mode to be created // Allow the action mode to be created
return true return true
} }
override fun onPrepareActionMode( override fun onPrepareActionMode(mode: android.view.ActionMode?, menu: android.view.Menu?): Boolean {
mode: android.view.ActionMode?,
menu: android.view.Menu?,
): Boolean {
// Remove editing-related menu items, keep only Copy and Select All // Remove editing-related menu items, keep only Copy and Select All
menu?.let { m -> menu?.let { m ->
// Remove all editing-related items // Remove all editing-related items
@@ -116,10 +103,7 @@ class EditProfileContentViewModel(
return true return true
} }
override fun onActionItemClicked( override fun onActionItemClicked(mode: android.view.ActionMode?, item: android.view.MenuItem?): Boolean {
mode: android.view.ActionMode?,
item: android.view.MenuItem?,
): Boolean {
// Let the default implementation handle allowed actions (copy, select all) // Let the default implementation handle allowed actions (copy, select all)
return false return false
} }

View File

@@ -13,11 +13,7 @@ import androidx.navigation.compose.rememberNavController
import androidx.navigation.navArgument import androidx.navigation.navArgument
@Composable @Composable
fun EditProfileRoute( fun EditProfileRoute(profileId: Long, onNavigateBack: () -> Unit, modifier: Modifier = Modifier) {
profileId: Long,
onNavigateBack: () -> Unit,
modifier: Modifier = Modifier,
) {
if (profileId == -1L) { if (profileId == -1L) {
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
onNavigateBack() onNavigateBack()
@@ -84,12 +80,12 @@ fun EditProfileRoute(
composable( composable(
route = "icon_selection/{currentIconId}", route = "icon_selection/{currentIconId}",
arguments = arguments =
listOf( listOf(
navArgument("currentIconId") { navArgument("currentIconId") {
type = NavType.StringType type = NavType.StringType
nullable = true nullable = true
}, },
), ),
enterTransition = { enterTransition = {
slideIntoContainer( slideIntoContainer(
AnimatedContentTransitionScope.SlideDirection.Left, AnimatedContentTransitionScope.SlideDirection.Left,
@@ -134,16 +130,16 @@ fun EditProfileRoute(
composable( composable(
route = "edit_content/{profileName}/{isReadOnly}", route = "edit_content/{profileName}/{isReadOnly}",
arguments = arguments =
listOf( listOf(
navArgument("profileName") { navArgument("profileName") {
type = NavType.StringType type = NavType.StringType
defaultValue = "" defaultValue = ""
}, },
navArgument("isReadOnly") { navArgument("isReadOnly") {
type = NavType.BoolType type = NavType.BoolType
defaultValue = false defaultValue = false
}, },
), ),
enterTransition = { enterTransition = {
slideIntoContainer( slideIntoContainer(
AnimatedContentTransitionScope.SlideDirection.Left, 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.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.navigationBars import androidx.compose.foundation.layout.navigationBars
@@ -61,6 +60,7 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
@@ -179,9 +179,9 @@ fun EditProfileScreen(
} }
}, },
colors = colors =
TopAppBarDefaults.topAppBarColors( TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.surface, containerColor = MaterialTheme.colorScheme.surface,
), ),
) )
} }
@@ -199,324 +199,324 @@ fun EditProfileScreen(
Box( Box(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
) { ) {
// Progress indicator at top (only for initial loading) // Progress indicator at top (only for initial loading)
if (uiState.isLoading) { if (uiState.isLoading) {
LinearProgressIndicator( LinearProgressIndicator(
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(), modifier = Modifier.fillMaxWidth(),
) colors =
} CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f),
if (!uiState.isLoading) { ),
Column(
modifier =
Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(16.dp)
.padding(bottom = bottomBarPadding),
verticalArrangement = Arrangement.spacedBy(16.dp),
) { ) {
// Basic Information Card Column(
Card( modifier = Modifier.padding(16.dp),
modifier = Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(12.dp),
colors =
CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f),
),
) { ) {
Column( Text(
modifier = Modifier.padding(16.dp), text = stringResource(R.string.basic_information),
verticalArrangement = Arrangement.spacedBy(12.dp), style = MaterialTheme.typography.titleSmall,
) { color = MaterialTheme.colorScheme.primary,
Text( )
text = stringResource(R.string.basic_information),
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.primary,
)
OutlinedTextField( OutlinedTextField(
value = uiState.name, value = uiState.name,
onValueChange = viewModel::updateName, onValueChange = viewModel::updateName,
label = { Text(stringResource(R.string.profile_name)) }, 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(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
colors = singleLine = true,
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,
)
}
}
}
}
OutlinedTextField( HorizontalDivider(
value = uiState.remoteUrl, modifier = Modifier.padding(vertical = 4.dp),
onValueChange = viewModel::updateRemoteUrl, color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f),
label = { Text(stringResource(R.string.profile_url)) }, )
modifier = Modifier.fillMaxWidth(),
singleLine = true, // 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 Icon(
Row( imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight,
modifier = Modifier.fillMaxWidth(), contentDescription = stringResource(R.string.select_icon),
verticalAlignment = Alignment.CenterVertically, modifier = Modifier.size(20.dp),
horizontalArrangement = Arrangement.SpaceBetween, tint = MaterialTheme.colorScheme.onSurfaceVariant,
) { )
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,
)
}
} }
} }
} }
}
// Content Card (for both Local and Remote profiles) - placed at the end // Remote Profile Options
if (uiState.profileType == TypedProfile.Type.Remote) {
Card( Card(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
colors = colors =
CardDefaults.cardColors( CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.secondaryContainer.copy(alpha = 0.3f), containerColor = MaterialTheme.colorScheme.tertiaryContainer.copy(alpha = 0.3f),
), ),
) { ) {
Column( Column(
modifier = Modifier.padding(16.dp), modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp), verticalArrangement = Arrangement.spacedBy(12.dp),
) { ) {
Row( Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically, 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(
text = stringResource(R.string.content), text = stringResource(R.string.profile_auto_update),
style = MaterialTheme.typography.titleSmall, style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.secondary, )
Switch(
checked = uiState.autoUpdate,
onCheckedChange = viewModel::updateAutoUpdate,
) )
} }
// JSON Editor/Viewer option AnimatedVisibility(visible = uiState.autoUpdate) {
Surface( OutlinedTextField(
modifier = value = uiState.autoUpdateInterval.toString(),
Modifier onValueChange = viewModel::updateAutoUpdateInterval,
.fillMaxWidth() label = { Text(stringResource(R.string.profile_auto_update_interval)) },
.clip(RoundedCornerShape(12.dp)) supportingText = {
.clickable { uiState.autoUpdateIntervalError?.let { error ->
onNavigateToEditContent( Text(
uiState.name, text = error,
uiState.profileType == TypedProfile.Type.Remote, color = MaterialTheme.colorScheme.error,
) )
}, } ?: Text(stringResource(R.string.profile_auto_update_interval_minimum_hint))
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f), },
shape = RoundedCornerShape(12.dp), 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( Icon(
modifier = imageVector = Icons.Default.Code,
Modifier contentDescription = null,
.fillMaxWidth() modifier = Modifier.size(24.dp),
.padding(16.dp), tint = MaterialTheme.colorScheme.primary,
verticalAlignment = Alignment.CenterVertically, )
horizontalArrangement = Arrangement.spacedBy(12.dp), Text(
) { text =
Icon( if (uiState.profileType == TypedProfile.Type.Remote) {
imageVector = Icons.Default.Code, stringResource(R.string.json_viewer)
contentDescription = null, } else {
modifier = Modifier.size(24.dp), stringResource(R.string.json_editor)
tint = MaterialTheme.colorScheme.primary, },
) style = MaterialTheme.typography.bodyLarge,
Text( color = MaterialTheme.colorScheme.onSurface,
text = modifier = Modifier.weight(1f),
if (uiState.profileType == TypedProfile.Type.Remote) { )
stringResource(R.string.json_viewer) Icon(
} else { imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight,
stringResource(R.string.json_editor) contentDescription = null,
}, modifier = Modifier.size(20.dp),
style = MaterialTheme.typography.bodyLarge, tint = MaterialTheme.colorScheme.onSurfaceVariant,
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( AnimatedVisibility(
visible = uiState.hasChanges, visible = uiState.hasChanges,
enter = fadeIn() + expandVertically(), enter = fadeIn() + expandVertically(),
@@ -530,10 +530,10 @@ fun EditProfileScreen(
) { ) {
Box( Box(
modifier = modifier =
Modifier Modifier
.fillMaxWidth() .fillMaxWidth()
.windowInsetsPadding(WindowInsets.navigationBars) .windowInsetsPadding(WindowInsets.navigationBars)
.padding(16.dp), .padding(16.dp),
) { ) {
Button( Button(
onClick = { viewModel.saveChanges() }, onClick = { viewModel.saveChanges() },

View File

@@ -109,9 +109,9 @@ class EditProfileViewModel(application: Application) : AndroidViewModel(applicat
state.copy( state.copy(
name = name, name = name,
hasChanges = hasChanges =
checkHasChanges( checkHasChanges(
state.copy(name = name), state.copy(name = name),
), ),
) )
} }
} }
@@ -121,9 +121,9 @@ class EditProfileViewModel(application: Application) : AndroidViewModel(applicat
state.copy( state.copy(
icon = icon, icon = icon,
hasChanges = hasChanges =
checkHasChanges( checkHasChanges(
state.copy(icon = icon), state.copy(icon = icon),
), ),
) )
} }
} }
@@ -141,9 +141,9 @@ class EditProfileViewModel(application: Application) : AndroidViewModel(applicat
state.copy( state.copy(
remoteUrl = url, remoteUrl = url,
hasChanges = hasChanges =
checkHasChanges( checkHasChanges(
state.copy(remoteUrl = url), state.copy(remoteUrl = url),
), ),
) )
} }
} }
@@ -153,9 +153,9 @@ class EditProfileViewModel(application: Application) : AndroidViewModel(applicat
state.copy( state.copy(
autoUpdate = enabled, autoUpdate = enabled,
hasChanges = hasChanges =
checkHasChanges( checkHasChanges(
state.copy(autoUpdate = enabled), state.copy(autoUpdate = enabled),
), ),
) )
} }
} }
@@ -174,22 +174,20 @@ class EditProfileViewModel(application: Application) : AndroidViewModel(applicat
autoUpdateInterval = intValue, autoUpdateInterval = intValue,
autoUpdateIntervalError = error, autoUpdateIntervalError = error,
hasChanges = hasChanges =
if (error == null) { if (error == null) {
checkHasChanges(state.copy(autoUpdateInterval = intValue)) checkHasChanges(state.copy(autoUpdateInterval = intValue))
} else { } else {
state.hasChanges state.hasChanges
}, },
) )
} }
} }
private fun checkHasChanges(state: EditProfileUiState): Boolean { private fun checkHasChanges(state: EditProfileUiState): Boolean = state.name != state.originalName ||
return state.name != state.originalName || state.icon != state.originalIcon ||
state.icon != state.originalIcon || state.remoteUrl != state.originalRemoteUrl ||
state.remoteUrl != state.originalRemoteUrl || state.autoUpdate != state.originalAutoUpdate ||
state.autoUpdate != state.originalAutoUpdate || state.autoUpdateInterval != state.originalAutoUpdateInterval
state.autoUpdateInterval != state.originalAutoUpdateInterval
}
fun saveChanges() { fun saveChanges() {
val state = _uiState.value val state = _uiState.value
@@ -343,10 +341,7 @@ class EditProfileViewModel(application: Application) : AndroidViewModel(applicat
} }
} }
fun saveExportToUri( fun saveExportToUri(context: Context, uri: Uri) {
context: Context,
uri: Uri,
) {
val content = pendingExportContent ?: return val content = pendingExportContent ?: return
viewModelScope.launch(Dispatchers.IO) { viewModelScope.launch(Dispatchers.IO) {

View File

@@ -37,17 +37,13 @@ import io.nekohasekai.sfa.compose.util.ProfileIcon
import io.nekohasekai.sfa.compose.util.ProfileIcons import io.nekohasekai.sfa.compose.util.ProfileIcons
@Composable @Composable
fun IconSelectionDialog( fun IconSelectionDialog(currentIconId: String?, onIconSelected: (String?) -> Unit, onDismiss: () -> Unit) {
currentIconId: String?,
onIconSelected: (String?) -> Unit,
onDismiss: () -> Unit,
) {
Dialog(onDismissRequest = onDismiss) { Dialog(onDismissRequest = onDismiss) {
Card( Card(
modifier = modifier =
Modifier Modifier
.fillMaxWidth() .fillMaxWidth()
.heightIn(max = 500.dp), .heightIn(max = 500.dp),
shape = RoundedCornerShape(16.dp), shape = RoundedCornerShape(16.dp),
) { ) {
Column( Column(
@@ -65,9 +61,9 @@ fun IconSelectionDialog(
verticalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp),
modifier = modifier =
Modifier Modifier
.fillMaxWidth() .fillMaxWidth()
.weight(1f), .weight(1f),
) { ) {
// Add option to remove custom icon (use default) // Add option to remove custom icon (use default)
item { item {
@@ -110,40 +106,35 @@ fun IconSelectionDialog(
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
private fun IconOption( private fun IconOption(icon: ProfileIcon?, label: String, isSelected: Boolean, onClick: () -> Unit) {
icon: ProfileIcon?,
label: String,
isSelected: Boolean,
onClick: () -> Unit,
) {
Card( Card(
modifier = modifier =
Modifier Modifier
.fillMaxWidth() .fillMaxWidth()
.aspectRatio(1f) .aspectRatio(1f)
.clip(RoundedCornerShape(8.dp)) .clip(RoundedCornerShape(8.dp))
.clickable { onClick() }, .clickable { onClick() },
colors = colors =
CardDefaults.cardColors( CardDefaults.cardColors(
containerColor = containerColor =
if (isSelected) {
MaterialTheme.colorScheme.primaryContainer
} else {
MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)
},
),
border =
if (isSelected) { if (isSelected) {
CardDefaults.outlinedCardBorder() MaterialTheme.colorScheme.primaryContainer
} else { } else {
null MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)
}, },
),
border =
if (isSelected) {
CardDefaults.outlinedCardBorder()
} else {
null
},
) { ) {
Column( Column(
modifier = modifier =
Modifier Modifier
.fillMaxSize() .fillMaxSize()
.padding(8.dp), .padding(8.dp),
verticalArrangement = Arrangement.Center, verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
) { ) {
@@ -153,11 +144,11 @@ private fun IconOption(
contentDescription = label, contentDescription = label,
modifier = Modifier.size(28.dp), modifier = Modifier.size(28.dp),
tint = tint =
if (isSelected) { if (isSelected) {
MaterialTheme.colorScheme.onPrimaryContainer MaterialTheme.colorScheme.onPrimaryContainer
} else { } else {
MaterialTheme.colorScheme.onSurfaceVariant MaterialTheme.colorScheme.onSurfaceVariant
}, },
) )
} else { } else {
// Default icon indicator // Default icon indicator
@@ -165,11 +156,11 @@ private fun IconOption(
text = stringResource(R.string.auto), text = stringResource(R.string.auto),
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
color = color =
if (isSelected) { if (isSelected) {
MaterialTheme.colorScheme.onPrimaryContainer MaterialTheme.colorScheme.onPrimaryContainer
} else { } else {
MaterialTheme.colorScheme.onSurfaceVariant MaterialTheme.colorScheme.onSurfaceVariant
}, },
) )
} }
@@ -179,11 +170,11 @@ private fun IconOption(
text = label, text = label,
style = MaterialTheme.typography.labelSmall, style = MaterialTheme.typography.labelSmall,
color = color =
if (isSelected) { if (isSelected) {
MaterialTheme.colorScheme.onPrimaryContainer MaterialTheme.colorScheme.onPrimaryContainer
} else { } else {
MaterialTheme.colorScheme.onSurfaceVariant MaterialTheme.colorScheme.onSurfaceVariant
}, },
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
textAlign = TextAlign.Center, 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.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
@@ -60,6 +59,7 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.ImeAction
@@ -74,11 +74,7 @@ import io.nekohasekai.sfa.compose.util.icons.MaterialIconsLibrary
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun IconSelectionScreen( fun IconSelectionScreen(currentIconId: String?, onIconSelected: (String?) -> Unit, onNavigateBack: () -> Unit) {
currentIconId: String?,
onIconSelected: (String?) -> Unit,
onNavigateBack: () -> Unit,
) {
var searchQuery by remember { mutableStateOf("") } var searchQuery by remember { mutableStateOf("") }
var selectedCategory by remember { mutableStateOf<String?>(null) } var selectedCategory by remember { mutableStateOf<String?>(null) }
var viewMode by remember { mutableStateOf(IconViewMode.CATEGORIES) } var viewMode by remember { mutableStateOf(IconViewMode.CATEGORIES) }
@@ -126,26 +122,26 @@ fun IconSelectionScreen(
Icon( Icon(
imageVector = Icons.Default.Search, imageVector = Icons.Default.Search,
contentDescription = contentDescription =
if (isSearchActive) { if (isSearchActive) {
stringResource(R.string.close_search) stringResource(R.string.close_search)
} else { } else {
stringResource( stringResource(
R.string.search_icons, R.string.search_icons,
) )
}, },
tint = tint =
if (isSearchActive) { if (isSearchActive) {
MaterialTheme.colorScheme.primary MaterialTheme.colorScheme.primary
} else { } else {
MaterialTheme.colorScheme.onSurface MaterialTheme.colorScheme.onSurface
}, },
) )
} }
}, },
colors = colors =
TopAppBarDefaults.topAppBarColors( TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.surface, containerColor = MaterialTheme.colorScheme.surface,
), ),
) )
} }
@@ -167,27 +163,27 @@ fun IconSelectionScreen(
Box(modifier = Modifier.fillMaxSize()) { Box(modifier = Modifier.fillMaxSize()) {
Column( Column(
modifier = modifier =
Modifier Modifier
.fillMaxSize() .fillMaxSize()
.padding(bottom = bottomBarPadding), .padding(bottom = bottomBarPadding),
) { ) {
// Show search bar with animation // Show search bar with animation
AnimatedVisibility( AnimatedVisibility(
visible = isSearchActive, visible = isSearchActive,
enter = enter =
expandVertically( expandVertically(
animationSpec = tween(300),
) +
fadeIn(
animationSpec = tween(300), animationSpec = tween(300),
) + ),
fadeIn(
animationSpec = tween(300),
),
exit = exit =
shrinkVertically( shrinkVertically(
animationSpec = tween(300),
) +
fadeOut(
animationSpec = tween(300), animationSpec = tween(300),
) + ),
fadeOut(
animationSpec = tween(300),
),
) { ) {
Surface( Surface(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
@@ -212,10 +208,10 @@ fun IconSelectionScreen(
} }
}, },
modifier = modifier =
Modifier Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(start = 16.dp, end = 16.dp, bottom = 12.dp) .padding(start = 16.dp, end = 16.dp, bottom = 12.dp)
.focusRequester(focusRequester), .focusRequester(focusRequester),
placeholder = { Text(stringResource(R.string.search_icons_placeholder)) }, placeholder = { Text(stringResource(R.string.search_icons_placeholder)) },
leadingIcon = { leadingIcon = {
Icon( Icon(
@@ -240,20 +236,20 @@ fun IconSelectionScreen(
singleLine = true, singleLine = true,
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search), keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
keyboardActions = keyboardActions =
KeyboardActions( KeyboardActions(
onSearch = { onSearch = {
focusManager.clearFocus() focusManager.clearFocus()
}, },
), ),
) )
} }
} }
Column( Column(
modifier = modifier =
Modifier Modifier
.fillMaxSize() .fillMaxSize()
.padding(horizontal = 16.dp), .padding(horizontal = 16.dp),
) { ) {
// View mode tabs (only show when not searching) // View mode tabs (only show when not searching)
AnimatedVisibility(visible = searchQuery.isEmpty()) { AnimatedVisibility(visible = searchQuery.isEmpty()) {
@@ -269,11 +265,11 @@ fun IconSelectionScreen(
}, },
label = { Text(stringResource(R.string.categories)) }, label = { Text(stringResource(R.string.categories)) },
leadingIcon = leadingIcon =
if (viewMode == IconViewMode.CATEGORIES && selectedCategory == null) { if (viewMode == IconViewMode.CATEGORIES && selectedCategory == null) {
{ Icon(Icons.Default.Check, contentDescription = null, Modifier.size(16.dp)) } { Icon(Icons.Default.Check, contentDescription = null, Modifier.size(16.dp)) }
} else { } else {
null null
}, },
) )
FilterChip( FilterChip(
@@ -284,11 +280,11 @@ fun IconSelectionScreen(
}, },
label = { Text(stringResource(R.string.all_icons)) }, label = { Text(stringResource(R.string.all_icons)) },
leadingIcon = leadingIcon =
if (viewMode == IconViewMode.ALL) { if (viewMode == IconViewMode.ALL) {
{ Icon(Icons.Default.Check, contentDescription = null, Modifier.size(16.dp)) } { Icon(Icons.Default.Check, contentDescription = null, Modifier.size(16.dp)) }
} else { } else {
null null
}, },
) )
FilterChip( FilterChip(
@@ -329,9 +325,9 @@ fun IconSelectionScreen(
// Main content area // Main content area
Box( Box(
modifier = modifier =
Modifier Modifier
.fillMaxWidth() .fillMaxWidth()
.weight(1f), .weight(1f),
) { ) {
when { when {
// Search results // Search results
@@ -387,21 +383,21 @@ fun IconSelectionScreen(
currentIcon?.let { (id, icon) -> currentIcon?.let { (id, icon) ->
Card( Card(
modifier = modifier =
Modifier Modifier
.fillMaxWidth() .fillMaxWidth()
.align(Alignment.BottomCenter) .align(Alignment.BottomCenter)
.windowInsetsPadding(WindowInsets.navigationBars) .windowInsetsPadding(WindowInsets.navigationBars)
.padding(horizontal = 16.dp, vertical = 8.dp), .padding(horizontal = 16.dp, vertical = 8.dp),
colors = colors =
CardDefaults.cardColors( CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.3f), containerColor = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.3f),
), ),
) { ) {
Row( Row(
modifier = modifier =
Modifier Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(12.dp), .padding(12.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
Icon( Icon(
@@ -415,10 +411,10 @@ fun IconSelectionScreen(
val iconInfo = MaterialIconsLibrary.getAllIcons().find { it.id == id } val iconInfo = MaterialIconsLibrary.getAllIcons().find { it.id == id }
Text( Text(
text = text =
stringResource( stringResource(
R.string.current_icon_format, R.string.current_icon_format,
iconInfo?.label ?: id, iconInfo?.label ?: id,
), ),
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
) )
MaterialIconsLibrary.getCategoryForIcon(id)?.let { category -> MaterialIconsLibrary.getCategoryForIcon(id)?.let { category ->
@@ -436,11 +432,7 @@ fun IconSelectionScreen(
} }
@Composable @Composable
private fun CategoryList( private fun CategoryList(categories: List<IconCategory>, currentIconId: String?, onCategoryClick: (IconCategory) -> Unit) {
categories: List<IconCategory>,
currentIconId: String?,
onCategoryClick: (IconCategory) -> Unit,
) {
LazyColumn( LazyColumn(
verticalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp),
) { ) {
@@ -456,29 +448,25 @@ private fun CategoryList(
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
private fun CategoryCard( private fun CategoryCard(category: IconCategory, hasSelectedIcon: Boolean, onClick: () -> Unit) {
category: IconCategory,
hasSelectedIcon: Boolean,
onClick: () -> Unit,
) {
Card( Card(
onClick = onClick, onClick = onClick,
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
colors = colors =
CardDefaults.cardColors( CardDefaults.cardColors(
containerColor = containerColor =
if (hasSelectedIcon) { if (hasSelectedIcon) {
MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.5f) MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.5f)
} else { } else {
MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f) MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)
}, },
), ),
) { ) {
Row( Row(
modifier = modifier =
Modifier Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(16.dp), .padding(16.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween, horizontalArrangement = Arrangement.SpaceBetween,
) { ) {
@@ -517,11 +505,7 @@ private fun CategoryCard(
} }
@Composable @Composable
private fun IconGrid( private fun IconGrid(icons: List<ProfileIcon>, currentIconId: String?, onIconClick: (ProfileIcon) -> Unit) {
icons: List<ProfileIcon>,
currentIconId: String?,
onIconClick: (ProfileIcon) -> Unit,
) {
LazyVerticalGrid( LazyVerticalGrid(
columns = GridCells.Adaptive(minSize = 72.dp), columns = GridCells.Adaptive(minSize = 72.dp),
verticalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp),
@@ -539,38 +523,34 @@ private fun IconGrid(
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
private fun IconGridItem( private fun IconGridItem(icon: ProfileIcon, isSelected: Boolean, onClick: () -> Unit) {
icon: ProfileIcon,
isSelected: Boolean,
onClick: () -> Unit,
) {
Card( Card(
onClick = onClick, onClick = onClick,
modifier = modifier =
Modifier Modifier
.fillMaxWidth() .fillMaxWidth()
.aspectRatio(1f), .aspectRatio(1f),
colors = colors =
CardDefaults.cardColors( CardDefaults.cardColors(
containerColor = containerColor =
if (isSelected) {
MaterialTheme.colorScheme.primaryContainer
} else {
MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f)
},
),
border =
if (isSelected) { if (isSelected) {
CardDefaults.outlinedCardBorder() MaterialTheme.colorScheme.primaryContainer
} else { } else {
null MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f)
}, },
),
border =
if (isSelected) {
CardDefaults.outlinedCardBorder()
} else {
null
},
) { ) {
Column( Column(
modifier = modifier =
Modifier Modifier
.fillMaxSize() .fillMaxSize()
.padding(8.dp), .padding(8.dp),
verticalArrangement = Arrangement.Center, verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
) { ) {
@@ -579,11 +559,11 @@ private fun IconGridItem(
contentDescription = icon.label, contentDescription = icon.label,
modifier = Modifier.size(28.dp), modifier = Modifier.size(28.dp),
tint = tint =
if (isSelected) { if (isSelected) {
MaterialTheme.colorScheme.onPrimaryContainer MaterialTheme.colorScheme.onPrimaryContainer
} else { } else {
MaterialTheme.colorScheme.onSurfaceVariant MaterialTheme.colorScheme.onSurfaceVariant
}, },
) )
Spacer(modifier = Modifier.height(4.dp)) Spacer(modifier = Modifier.height(4.dp))
@@ -592,11 +572,11 @@ private fun IconGridItem(
text = icon.label, text = icon.label,
style = MaterialTheme.typography.labelSmall, style = MaterialTheme.typography.labelSmall,
color = color =
if (isSelected) { if (isSelected) {
MaterialTheme.colorScheme.onPrimaryContainer MaterialTheme.colorScheme.onPrimaryContainer
} else { } else {
MaterialTheme.colorScheme.onSurfaceVariant MaterialTheme.colorScheme.onSurfaceVariant
}, },
maxLines = 2, maxLines = 2,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
textAlign = TextAlign.Center, textAlign = TextAlign.Center,
@@ -610,9 +590,9 @@ private fun IconGridItem(
private fun EmptySearchResult(query: String) { private fun EmptySearchResult(query: String) {
Column( Column(
modifier = modifier =
Modifier Modifier
.fillMaxSize() .fillMaxSize()
.padding(32.dp), .padding(32.dp),
verticalArrangement = Arrangement.Center, verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
) { ) {

View File

@@ -4,129 +4,129 @@ import android.content.Context;
import android.util.AttributeSet; import android.util.AttributeSet;
import android.view.MotionEvent; import android.view.MotionEvent;
import android.view.ViewConfiguration; import android.view.ViewConfiguration;
import com.blacksquircle.ui.editorkit.widget.TextProcessor; import com.blacksquircle.ui.editorkit.widget.TextProcessor;
public class ManualScrollTextProcessor extends TextProcessor { public class ManualScrollTextProcessor extends TextProcessor {
private final int touchSlop; private final int touchSlop;
private boolean allowCursorAutoScroll = true; private boolean allowCursorAutoScroll = true;
private float downX; private float downX;
private float downY; private float downY;
private boolean userDragging; private boolean userDragging;
private int downSelectionStart = -1; private int downSelectionStart = -1;
private int downSelectionEnd = -1; private int downSelectionEnd = -1;
private boolean restoringSelection; private boolean restoringSelection;
public ManualScrollTextProcessor(Context context) { public ManualScrollTextProcessor(Context context) {
this(context, null); 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) { @Override
this(context, attrs, android.R.attr.autoCompleteTextViewStyle); public boolean onTouchEvent(MotionEvent event) {
} int action = event.getActionMasked();
switch (action) {
public ManualScrollTextProcessor(Context context, AttributeSet attrs, int defStyleAttr) { case MotionEvent.ACTION_DOWN:
super(context, attrs, defStyleAttr); downX = event.getX();
touchSlop = ViewConfiguration.get(context).getScaledTouchSlop(); downY = event.getY();
}
public void resumeAutoScroll() {
allowCursorAutoScroll = true;
userDragging = false; 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 boolean handled = super.onTouchEvent(event);
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;
}
switch (action) {
case MotionEvent.ACTION_MOVE:
if (userDragging) { if (userDragging) {
if (downSelectionStart >= 0 && (selStart != downSelectionStart || selEnd != downSelectionEnd)) { maybeRestoreSelection();
restoringSelection = true;
int targetEnd = downSelectionEnd >= 0 ? downSelectionEnd : downSelectionStart;
setSelection(downSelectionStart, targetEnd);
return;
}
} }
break;
downSelectionStart = selStart; case MotionEvent.ACTION_UP:
downSelectionEnd = selEnd; case MotionEvent.ACTION_CANCEL:
super.onSelectionChanged(selStart, selEnd); 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.util.Log
import android.widget.Toast import android.widget.Toast
import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.expandVertically
import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut import androidx.compose.animation.fadeOut
import androidx.compose.animation.expandVertically
import androidx.compose.animation.shrinkVertically import androidx.compose.animation.shrinkVertically
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
@@ -54,7 +54,6 @@ import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.Switch
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBar
@@ -66,9 +65,7 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier 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.focus.focusRequester import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
@@ -79,13 +76,13 @@ import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties import androidx.compose.ui.window.DialogProperties
import com.android.tools.smali.dexlib2.dexbacked.DexBackedDexFile import com.android.tools.smali.dexlib2.dexbacked.DexBackedDexFile
import io.nekohasekai.sfa.R 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.AppSelectionCard
import io.nekohasekai.sfa.compose.shared.PackageCache import io.nekohasekai.sfa.compose.shared.PackageCache
import io.nekohasekai.sfa.compose.shared.SortMode import io.nekohasekai.sfa.compose.shared.SortMode
import io.nekohasekai.sfa.compose.shared.buildDisplayPackages 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.PackageQueryManager
import io.nekohasekai.sfa.vendor.PrivilegedAccessRequiredException import io.nekohasekai.sfa.vendor.PrivilegedAccessRequiredException
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@@ -97,16 +94,9 @@ import java.io.File
import java.util.concurrent.atomic.AtomicInteger import java.util.concurrent.atomic.AtomicInteger
import java.util.zip.ZipFile import java.util.zip.ZipFile
private data class LoadResult( private data class LoadResult(val proxyMode: Int, val packages: List<PackageCache>, val selectedUids: Set<Int>)
val proxyMode: Int,
val packages: List<PackageCache>,
val selectedUids: Set<Int>,
)
private data class ScanProgress( private data class ScanProgress(val current: Int, val max: Int)
val current: Int,
val max: Int,
)
private sealed class ScanResult { private sealed class ScanResult {
data object Empty : ScanResult() data object Empty : ScanResult()
@@ -139,11 +129,9 @@ fun PerAppProxyScreen(onBack: () -> Unit) {
var scanProgress by remember { mutableStateOf<ScanProgress?>(null) } var scanProgress by remember { mutableStateOf<ScanProgress?>(null) }
var scanResult by remember { mutableStateOf<ScanResult?>(null) } var scanResult by remember { mutableStateOf<ScanResult?>(null) }
fun buildPackageList(newUids: Set<Int>): Set<String> { fun buildPackageList(newUids: Set<Int>): Set<String> = newUids.mapNotNull { uid ->
return newUids.mapNotNull { uid -> packages.find { it.uid == uid }?.packageName
packages.find { it.uid == uid }?.packageName }.toSet()
}.toSet()
}
fun updateCurrentPackages(filterQuery: String) { fun updateCurrentPackages(filterQuery: String) {
currentPackages = currentPackages =
@@ -411,10 +399,10 @@ fun PerAppProxyScreen(onBack: () -> Unit) {
) )
}, },
colors = colors =
TopAppBarDefaults.topAppBarColors( TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.surface, containerColor = MaterialTheme.colorScheme.surface,
titleContentColor = MaterialTheme.colorScheme.onSurface, titleContentColor = MaterialTheme.colorScheme.onSurface,
), ),
) )
} }
@@ -435,11 +423,11 @@ fun PerAppProxyScreen(onBack: () -> Unit) {
) { ) {
Text( Text(
text = text =
if (proxyMode == Settings.PER_APP_PROXY_INCLUDE) { if (proxyMode == Settings.PER_APP_PROXY_INCLUDE) {
stringResource(R.string.per_app_proxy_mode_include_description) stringResource(R.string.per_app_proxy_mode_include_description)
} else { } else {
stringResource(R.string.per_app_proxy_mode_exclude_description) stringResource(R.string.per_app_proxy_mode_exclude_description)
}, },
modifier = Modifier.padding(horizontal = 16.dp, vertical = 10.dp), modifier = Modifier.padding(horizontal = 16.dp, vertical = 10.dp),
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
) )
@@ -465,10 +453,10 @@ fun PerAppProxyScreen(onBack: () -> Unit) {
updateCurrentPackages(it) updateCurrentPackages(it)
}, },
modifier = modifier =
Modifier Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp) .padding(horizontal = 16.dp, vertical = 8.dp)
.focusRequester(focusRequester), .focusRequester(focusRequester),
placeholder = { Text(stringResource(R.string.search)) }, placeholder = { Text(stringResource(R.string.search)) },
leadingIcon = { leadingIcon = {
Icon( Icon(
@@ -497,10 +485,10 @@ fun PerAppProxyScreen(onBack: () -> Unit) {
LazyColumn( LazyColumn(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
contentPadding = contentPadding =
androidx.compose.foundation.layout.PaddingValues( androidx.compose.foundation.layout.PaddingValues(
horizontal = 16.dp, horizontal = 16.dp,
vertical = 12.dp, vertical = 12.dp,
), ),
verticalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp),
) { ) {
items(currentPackages, key = { it.packageName }) { packageCache -> items(currentPackages, key = { it.packageName }) { packageCache ->
@@ -609,10 +597,10 @@ fun PerAppProxyScreen(onBack: () -> Unit) {
Spacer(modifier = Modifier.height(16.dp)) Spacer(modifier = Modifier.height(16.dp))
Box( Box(
modifier = modifier =
Modifier Modifier
.fillMaxWidth() .fillMaxWidth()
.heightIn(max = 360.dp) .heightIn(max = 360.dp)
.verticalScroll(rememberScrollState()), .verticalScroll(rememberScrollState()),
) { ) {
Text( Text(
text = dialogContent, text = dialogContent,
@@ -722,11 +710,11 @@ private fun PerAppProxyMenus(
trailingIcon = { trailingIcon = {
Icon( Icon(
imageVector = imageVector =
if (showModeMenu) { if (showModeMenu) {
Icons.Default.ExpandLess Icons.Default.ExpandLess
} else { } else {
Icons.Default.ExpandMore Icons.Default.ExpandMore
}, },
contentDescription = null, contentDescription = null,
) )
}, },
@@ -742,18 +730,18 @@ private fun PerAppProxyMenus(
leadingIcon = { leadingIcon = {
Icon( Icon(
imageVector = imageVector =
if (proxyMode == Settings.PER_APP_PROXY_INCLUDE) { if (proxyMode == Settings.PER_APP_PROXY_INCLUDE) {
Icons.Default.RadioButtonChecked Icons.Default.RadioButtonChecked
} else { } else {
Icons.Default.RadioButtonUnchecked Icons.Default.RadioButtonUnchecked
}, },
contentDescription = null, contentDescription = null,
tint = tint =
if (proxyMode == Settings.PER_APP_PROXY_INCLUDE) { if (proxyMode == Settings.PER_APP_PROXY_INCLUDE) {
MaterialTheme.colorScheme.primary MaterialTheme.colorScheme.primary
} else { } else {
MaterialTheme.colorScheme.onSurfaceVariant MaterialTheme.colorScheme.onSurfaceVariant
}, },
modifier = Modifier.padding(start = 24.dp), modifier = Modifier.padding(start = 24.dp),
) )
}, },
@@ -768,18 +756,18 @@ private fun PerAppProxyMenus(
leadingIcon = { leadingIcon = {
Icon( Icon(
imageVector = imageVector =
if (proxyMode == Settings.PER_APP_PROXY_EXCLUDE) { if (proxyMode == Settings.PER_APP_PROXY_EXCLUDE) {
Icons.Default.RadioButtonChecked Icons.Default.RadioButtonChecked
} else { } else {
Icons.Default.RadioButtonUnchecked Icons.Default.RadioButtonUnchecked
}, },
contentDescription = null, contentDescription = null,
tint = tint =
if (proxyMode == Settings.PER_APP_PROXY_EXCLUDE) { if (proxyMode == Settings.PER_APP_PROXY_EXCLUDE) {
MaterialTheme.colorScheme.primary MaterialTheme.colorScheme.primary
} else { } else {
MaterialTheme.colorScheme.onSurfaceVariant MaterialTheme.colorScheme.onSurfaceVariant
}, },
modifier = Modifier.padding(start = 24.dp), modifier = Modifier.padding(start = 24.dp),
) )
}, },
@@ -799,11 +787,11 @@ private fun PerAppProxyMenus(
trailingIcon = { trailingIcon = {
Icon( Icon(
imageVector = imageVector =
if (showSortMenu) { if (showSortMenu) {
Icons.Default.ExpandLess Icons.Default.ExpandLess
} else { } else {
Icons.Default.ExpandMore Icons.Default.ExpandMore
}, },
contentDescription = null, contentDescription = null,
) )
}, },
@@ -819,18 +807,18 @@ private fun PerAppProxyMenus(
leadingIcon = { leadingIcon = {
Icon( Icon(
imageVector = imageVector =
if (sortMode == SortMode.NAME) { if (sortMode == SortMode.NAME) {
Icons.Default.RadioButtonChecked Icons.Default.RadioButtonChecked
} else { } else {
Icons.Default.RadioButtonUnchecked Icons.Default.RadioButtonUnchecked
}, },
contentDescription = null, contentDescription = null,
tint = tint =
if (sortMode == SortMode.NAME) { if (sortMode == SortMode.NAME) {
MaterialTheme.colorScheme.primary MaterialTheme.colorScheme.primary
} else { } else {
MaterialTheme.colorScheme.onSurfaceVariant MaterialTheme.colorScheme.onSurfaceVariant
}, },
modifier = Modifier.padding(start = 24.dp), modifier = Modifier.padding(start = 24.dp),
) )
}, },
@@ -845,18 +833,18 @@ private fun PerAppProxyMenus(
leadingIcon = { leadingIcon = {
Icon( Icon(
imageVector = imageVector =
if (sortMode == SortMode.PACKAGE_NAME) { if (sortMode == SortMode.PACKAGE_NAME) {
Icons.Default.RadioButtonChecked Icons.Default.RadioButtonChecked
} else { } else {
Icons.Default.RadioButtonUnchecked Icons.Default.RadioButtonUnchecked
}, },
contentDescription = null, contentDescription = null,
tint = tint =
if (sortMode == SortMode.PACKAGE_NAME) { if (sortMode == SortMode.PACKAGE_NAME) {
MaterialTheme.colorScheme.primary MaterialTheme.colorScheme.primary
} else { } else {
MaterialTheme.colorScheme.onSurfaceVariant MaterialTheme.colorScheme.onSurfaceVariant
}, },
modifier = Modifier.padding(start = 24.dp), modifier = Modifier.padding(start = 24.dp),
) )
}, },
@@ -871,18 +859,18 @@ private fun PerAppProxyMenus(
leadingIcon = { leadingIcon = {
Icon( Icon(
imageVector = imageVector =
if (sortMode == SortMode.UID) { if (sortMode == SortMode.UID) {
Icons.Default.RadioButtonChecked Icons.Default.RadioButtonChecked
} else { } else {
Icons.Default.RadioButtonUnchecked Icons.Default.RadioButtonUnchecked
}, },
contentDescription = null, contentDescription = null,
tint = tint =
if (sortMode == SortMode.UID) { if (sortMode == SortMode.UID) {
MaterialTheme.colorScheme.primary MaterialTheme.colorScheme.primary
} else { } else {
MaterialTheme.colorScheme.onSurfaceVariant MaterialTheme.colorScheme.onSurfaceVariant
}, },
modifier = Modifier.padding(start = 24.dp), modifier = Modifier.padding(start = 24.dp),
) )
}, },
@@ -897,18 +885,18 @@ private fun PerAppProxyMenus(
leadingIcon = { leadingIcon = {
Icon( Icon(
imageVector = imageVector =
if (sortMode == SortMode.INSTALL_TIME) { if (sortMode == SortMode.INSTALL_TIME) {
Icons.Default.RadioButtonChecked Icons.Default.RadioButtonChecked
} else { } else {
Icons.Default.RadioButtonUnchecked Icons.Default.RadioButtonUnchecked
}, },
contentDescription = null, contentDescription = null,
tint = tint =
if (sortMode == SortMode.INSTALL_TIME) { if (sortMode == SortMode.INSTALL_TIME) {
MaterialTheme.colorScheme.primary MaterialTheme.colorScheme.primary
} else { } else {
MaterialTheme.colorScheme.onSurfaceVariant MaterialTheme.colorScheme.onSurfaceVariant
}, },
modifier = Modifier.padding(start = 24.dp), modifier = Modifier.padding(start = 24.dp),
) )
}, },
@@ -923,18 +911,18 @@ private fun PerAppProxyMenus(
leadingIcon = { leadingIcon = {
Icon( Icon(
imageVector = imageVector =
if (sortMode == SortMode.UPDATE_TIME) { if (sortMode == SortMode.UPDATE_TIME) {
Icons.Default.RadioButtonChecked Icons.Default.RadioButtonChecked
} else { } else {
Icons.Default.RadioButtonUnchecked Icons.Default.RadioButtonUnchecked
}, },
contentDescription = null, contentDescription = null,
tint = tint =
if (sortMode == SortMode.UPDATE_TIME) { if (sortMode == SortMode.UPDATE_TIME) {
MaterialTheme.colorScheme.primary MaterialTheme.colorScheme.primary
} else { } else {
MaterialTheme.colorScheme.onSurfaceVariant MaterialTheme.colorScheme.onSurfaceVariant
}, },
modifier = Modifier.padding(start = 24.dp), modifier = Modifier.padding(start = 24.dp),
) )
}, },
@@ -949,18 +937,18 @@ private fun PerAppProxyMenus(
leadingIcon = { leadingIcon = {
Icon( Icon(
imageVector = imageVector =
if (sortReverse) { if (sortReverse) {
Icons.Default.Check Icons.Default.Check
} else { } else {
Icons.Default.RadioButtonUnchecked Icons.Default.RadioButtonUnchecked
}, },
contentDescription = null, contentDescription = null,
tint = tint =
if (sortReverse) { if (sortReverse) {
MaterialTheme.colorScheme.primary MaterialTheme.colorScheme.primary
} else { } else {
MaterialTheme.colorScheme.onSurfaceVariant MaterialTheme.colorScheme.onSurfaceVariant
}, },
modifier = Modifier.padding(start = 24.dp), modifier = Modifier.padding(start = 24.dp),
) )
}, },
@@ -980,11 +968,11 @@ private fun PerAppProxyMenus(
trailingIcon = { trailingIcon = {
Icon( Icon(
imageVector = imageVector =
if (showFilterMenu) { if (showFilterMenu) {
Icons.Default.ExpandLess Icons.Default.ExpandLess
} else { } else {
Icons.Default.ExpandMore Icons.Default.ExpandMore
}, },
contentDescription = null, contentDescription = null,
) )
}, },
@@ -1000,18 +988,18 @@ private fun PerAppProxyMenus(
leadingIcon = { leadingIcon = {
Icon( Icon(
imageVector = imageVector =
if (hideSystemApps) { if (hideSystemApps) {
Icons.Default.Check Icons.Default.Check
} else { } else {
Icons.Default.RadioButtonUnchecked Icons.Default.RadioButtonUnchecked
}, },
contentDescription = null, contentDescription = null,
tint = tint =
if (hideSystemApps) { if (hideSystemApps) {
MaterialTheme.colorScheme.primary MaterialTheme.colorScheme.primary
} else { } else {
MaterialTheme.colorScheme.onSurfaceVariant MaterialTheme.colorScheme.onSurfaceVariant
}, },
modifier = Modifier.padding(start = 24.dp), modifier = Modifier.padding(start = 24.dp),
) )
}, },
@@ -1026,18 +1014,18 @@ private fun PerAppProxyMenus(
leadingIcon = { leadingIcon = {
Icon( Icon(
imageVector = imageVector =
if (hideOfflineApps) { if (hideOfflineApps) {
Icons.Default.Check Icons.Default.Check
} else { } else {
Icons.Default.RadioButtonUnchecked Icons.Default.RadioButtonUnchecked
}, },
contentDescription = null, contentDescription = null,
tint = tint =
if (hideOfflineApps) { if (hideOfflineApps) {
MaterialTheme.colorScheme.primary MaterialTheme.colorScheme.primary
} else { } else {
MaterialTheme.colorScheme.onSurfaceVariant MaterialTheme.colorScheme.onSurfaceVariant
}, },
modifier = Modifier.padding(start = 24.dp), modifier = Modifier.padding(start = 24.dp),
) )
}, },
@@ -1052,18 +1040,18 @@ private fun PerAppProxyMenus(
leadingIcon = { leadingIcon = {
Icon( Icon(
imageVector = imageVector =
if (hideDisabledApps) { if (hideDisabledApps) {
Icons.Default.Check Icons.Default.Check
} else { } else {
Icons.Default.RadioButtonUnchecked Icons.Default.RadioButtonUnchecked
}, },
contentDescription = null, contentDescription = null,
tint = tint =
if (hideDisabledApps) { if (hideDisabledApps) {
MaterialTheme.colorScheme.primary MaterialTheme.colorScheme.primary
} else { } else {
MaterialTheme.colorScheme.onSurfaceVariant MaterialTheme.colorScheme.onSurfaceVariant
}, },
modifier = Modifier.padding(start = 24.dp), modifier = Modifier.padding(start = 24.dp),
) )
}, },
@@ -1083,11 +1071,11 @@ private fun PerAppProxyMenus(
trailingIcon = { trailingIcon = {
Icon( Icon(
imageVector = imageVector =
if (showSelectMenu) { if (showSelectMenu) {
Icons.Default.ExpandLess Icons.Default.ExpandLess
} else { } else {
Icons.Default.ExpandMore Icons.Default.ExpandMore
}, },
contentDescription = null, contentDescription = null,
) )
}, },
@@ -1140,11 +1128,11 @@ private fun PerAppProxyMenus(
trailingIcon = { trailingIcon = {
Icon( Icon(
imageVector = imageVector =
if (showBackupMenu) { if (showBackupMenu) {
Icons.Default.ExpandLess Icons.Default.ExpandLess
} else { } else {
Icons.Default.ExpandMore Icons.Default.ExpandMore
}, },
contentDescription = null, contentDescription = null,
) )
}, },
@@ -1197,11 +1185,11 @@ private fun PerAppProxyMenus(
trailingIcon = { trailingIcon = {
Icon( Icon(
imageVector = imageVector =
if (showScanMenu) { if (showScanMenu) {
Icons.Default.ExpandLess Icons.Default.ExpandLess
} else { } else {
Icons.Default.ExpandMore Icons.Default.ExpandMore
}, },
contentDescription = null, contentDescription = null,
) )
}, },
@@ -1331,7 +1319,7 @@ object PerAppProxyScanner {
if (!( if (!(
packageEntry.name.startsWith("classes") && packageEntry.name.startsWith("classes") &&
packageEntry.name.endsWith(".dex") packageEntry.name.endsWith(".dex")
) )
) { ) {
continue continue
} }

View File

@@ -14,12 +14,7 @@ data class QRCodeCropArea(
) )
object QRCodeSmartCrop { object QRCodeSmartCrop {
fun findCropArea( fun findCropArea(yData: ByteArray, width: Int, height: Int, rotationDegrees: Int): QRCodeCropArea? {
yData: ByteArray,
width: Int,
height: Int,
rotationDegrees: Int,
): QRCodeCropArea? {
val minDim = min(width, height) val minDim = min(width, height)
if (minDim <= 0) return null if (minDim <= 0) return null
@@ -94,14 +89,7 @@ object QRCodeSmartCrop {
return bestArea return bestArea
} }
private data class CropComponent( private data class CropComponent(val minX: Int, val minY: Int, val maxX: Int, val maxY: Int, val count: Int, val score: Float)
val minX: Int,
val minY: Int,
val maxX: Int,
val maxY: Int,
val count: Int,
val score: Float,
)
private fun findBestComponent( private fun findBestComponent(
yData: ByteArray, yData: ByteArray,
@@ -233,13 +221,7 @@ object QRCodeSmartCrop {
return best return best
} }
private fun buildCropArea( private fun buildCropArea(component: CropComponent, step: Int, width: Int, height: Int, rotationDegrees: Int): QRCodeCropArea? {
component: CropComponent,
step: Int,
width: Int,
height: Int,
rotationDegrees: Int,
): QRCodeCropArea? {
val left = component.minX * step val left = component.minX * step
val top = component.minY * step val top = component.minY * step
val right = min(width, (component.maxX + 1) * step) val right = min(width, (component.maxX + 1) * step)

View File

@@ -83,7 +83,7 @@ class QRScanViewModel(application: Application) : AndroidViewModel(application)
_uiState.update { _uiState.update {
it.copy( it.copy(
vendorAnalyzerAvailable = vendorAnalyzer != null, vendorAnalyzerAvailable = vendorAnalyzer != null,
useVendorAnalyzer = vendorAnalyzer != null useVendorAnalyzer = vendorAnalyzer != null,
) )
} }
} }
@@ -196,7 +196,7 @@ class QRScanViewModel(application: Application) : AndroidViewModel(application)
lifecycleOwner, lifecycleOwner,
cameraSelector, cameraSelector,
preview, preview,
analysis analysis,
) )
val maxZoom = camera?.cameraInfo?.zoomState?.value?.maxZoomRatio ?: 1f val maxZoom = camera?.cameraInfo?.zoomState?.value?.maxZoomRatio ?: 1f
_uiState.update { it.copy(maxZoomRatio = maxZoom, zoomRatio = 1f) } _uiState.update { it.copy(maxZoomRatio = maxZoom, zoomRatio = 1f) }

View File

@@ -109,12 +109,10 @@ class ZxingQRCodeAnalyzer(
return yData return yData
} }
private fun tryDecode(bitmap: BinaryBitmap): Result? { private fun tryDecode(bitmap: BinaryBitmap): Result? = try {
return try { qrCodeReader.decode(bitmap)
qrCodeReader.decode(bitmap) } catch (_: NotFoundException) {
} catch (_: NotFoundException) { qrCodeReader.reset()
qrCodeReader.reset() null
null
}
} }
} }

View File

@@ -4,7 +4,6 @@ import android.content.Intent
import android.net.Uri import android.net.Uri
import android.os.Build import android.os.Build
import android.util.Log import android.util.Log
import android.provider.Settings as AndroidSettings
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column 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.Refresh
import androidx.compose.material.icons.outlined.Settings import androidx.compose.material.icons.outlined.Settings
import androidx.compose.material.icons.outlined.SystemUpdateAlt import androidx.compose.material.icons.outlined.SystemUpdateAlt
import androidx.compose.material3.Switch
import androidx.compose.material3.AlertDialog import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Badge import androidx.compose.material3.Badge
import androidx.compose.material3.Card import androidx.compose.material3.Card
@@ -42,6 +40,7 @@ import androidx.compose.material3.ListItem
import androidx.compose.material3.ListItemDefaults import androidx.compose.material3.ListItemDefaults
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.RadioButton import androidx.compose.material3.RadioButton
import androidx.compose.material3.Switch
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBar
@@ -53,8 +52,6 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.compose.LifecycleEventEffect
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip 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.platform.LocalContext
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.compose.LifecycleEventEffect
import androidx.navigation.NavController import androidx.navigation.NavController
import io.nekohasekai.sfa.BuildConfig import io.nekohasekai.sfa.BuildConfig
import io.nekohasekai.sfa.R import io.nekohasekai.sfa.R
import io.nekohasekai.sfa.database.Settings
import io.nekohasekai.sfa.compose.topbar.OverrideTopBar
import io.nekohasekai.sfa.compose.component.UpdateAvailableDialog import io.nekohasekai.sfa.compose.component.UpdateAvailableDialog
import io.nekohasekai.sfa.compose.topbar.OverrideTopBar
import io.nekohasekai.sfa.database.Settings
import io.nekohasekai.sfa.update.UpdateCheckException import io.nekohasekai.sfa.update.UpdateCheckException
import io.nekohasekai.sfa.update.UpdateState import io.nekohasekai.sfa.update.UpdateState
import io.nekohasekai.sfa.update.UpdateTrack import io.nekohasekai.sfa.update.UpdateTrack
import io.nekohasekai.sfa.vendor.Vendor
import io.nekohasekai.sfa.utils.HookStatusClient import io.nekohasekai.sfa.utils.HookStatusClient
import io.nekohasekai.sfa.vendor.Vendor
import io.nekohasekai.sfa.xposed.XposedActivation import io.nekohasekai.sfa.xposed.XposedActivation
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import android.provider.Settings as AndroidSettings
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
@@ -136,10 +136,12 @@ fun AppSettingsScreen(navController: NavController) {
isMethodAvailable = success isMethodAvailable = success
silentInstallError = if (success) { silentInstallError = if (success) {
null null
} else when (silentInstallMethod) { } else {
"PACKAGE_INSTALLER" -> context.getString(R.string.package_installer_not_available) when (silentInstallMethod) {
"SHIZUKU" -> context.getString(R.string.shizuku_not_available) "PACKAGE_INSTALLER" -> context.getString(R.string.package_installer_not_available)
else -> context.getString(R.string.silent_install_verify_failed, silentInstallMethod) "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 isMethodAvailable = success
silentInstallError = if (success) { silentInstallError = if (success) {
null null
} else when (method) { } else {
"PACKAGE_INSTALLER" -> context.getString(R.string.package_installer_not_available) when (method) {
"SHIZUKU" -> context.getString(R.string.shizuku_not_available) "PACKAGE_INSTALLER" -> context.getString(R.string.package_installer_not_available)
else -> context.getString(R.string.silent_install_verify_failed, method) "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( Column(
modifier = modifier =
Modifier Modifier
.fillMaxSize() .fillMaxSize()
.background(MaterialTheme.colorScheme.surface) .background(MaterialTheme.colorScheme.surface)
.verticalScroll(rememberScrollState()) .verticalScroll(rememberScrollState())
.padding(vertical = 8.dp), .padding(vertical = 8.dp),
) { ) {
// Info Card // Info Card
Card( Card(
modifier = modifier =
Modifier Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(horizontal = 16.dp), .padding(horizontal = 16.dp),
colors = colors =
CardDefaults.cardColors( CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceContainer, containerColor = MaterialTheme.colorScheme.surfaceContainer,
), ),
) { ) {
Column { Column {
ListItem( ListItem(
@@ -303,12 +307,12 @@ fun AppSettingsScreen(navController: NavController) {
} }
}, },
modifier = modifier =
Modifier Modifier
.clip(RoundedCornerShape(12.dp)), .clip(RoundedCornerShape(12.dp)),
colors = colors =
ListItemDefaults.colors( ListItemDefaults.colors(
containerColor = Color.Transparent, containerColor = Color.Transparent,
), ),
) )
} }
} }
@@ -324,13 +328,13 @@ fun AppSettingsScreen(navController: NavController) {
Card( Card(
modifier = modifier =
Modifier Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(horizontal = 16.dp), .padding(horizontal = 16.dp),
colors = colors =
CardDefaults.cardColors( CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceContainer, containerColor = MaterialTheme.colorScheme.surfaceContainer,
), ),
) { ) {
Column { Column {
val updateItemCount = val updateItemCount =
@@ -393,12 +397,12 @@ fun AppSettingsScreen(navController: NavController) {
) )
}, },
modifier = modifier =
updateItemModifier() updateItemModifier()
.clickable { showTrackDialog = true }, .clickable { showTrackDialog = true },
colors = colors =
ListItemDefaults.colors( ListItemDefaults.colors(
containerColor = Color.Transparent, containerColor = Color.Transparent,
), ),
) )
} }
@@ -429,9 +433,9 @@ fun AppSettingsScreen(navController: NavController) {
}, },
modifier = updateItemModifier(), modifier = updateItemModifier(),
colors = colors =
ListItemDefaults.colors( ListItemDefaults.colors(
containerColor = Color.Transparent, containerColor = Color.Transparent,
), ),
) )
if (Vendor.supportsSilentInstall()) { if (Vendor.supportsSilentInstall()) {
@@ -478,10 +482,12 @@ fun AppSettingsScreen(navController: NavController) {
isMethodAvailable = success isMethodAvailable = success
silentInstallError = if (success) { silentInstallError = if (success) {
null null
} else when (silentInstallMethod) { } else {
"PACKAGE_INSTALLER" -> context.getString(R.string.package_installer_not_available) when (silentInstallMethod) {
"SHIZUKU" -> context.getString(R.string.shizuku_not_available) "PACKAGE_INSTALLER" -> context.getString(R.string.package_installer_not_available)
else -> context.getString(R.string.silent_install_verify_failed, silentInstallMethod) "SHIZUKU" -> context.getString(R.string.shizuku_not_available)
else -> context.getString(R.string.silent_install_verify_failed, silentInstallMethod)
}
} }
} }
} else { } else {
@@ -493,9 +499,9 @@ fun AppSettingsScreen(navController: NavController) {
}, },
modifier = updateItemModifier(), modifier = updateItemModifier(),
colors = colors =
ListItemDefaults.colors( ListItemDefaults.colors(
containerColor = Color.Transparent, containerColor = Color.Transparent,
), ),
) )
if (silentInstallEnabled) { if (silentInstallEnabled) {
@@ -510,11 +516,13 @@ fun AppSettingsScreen(navController: NavController) {
Text( Text(
if (xposedActivated) { if (xposedActivated) {
stringResource(R.string.install_method_root) stringResource(R.string.install_method_root)
} else when (silentInstallMethod) { } else {
"PACKAGE_INSTALLER" -> stringResource(R.string.install_method_package_installer) when (silentInstallMethod) {
"SHIZUKU" -> stringResource(R.string.install_method_shizuku) "PACKAGE_INSTALLER" -> stringResource(R.string.install_method_package_installer)
"ROOT" -> stringResource(R.string.install_method_root) "SHIZUKU" -> stringResource(R.string.install_method_shizuku)
else -> silentInstallMethod "ROOT" -> stringResource(R.string.install_method_root)
else -> silentInstallMethod
}
}, },
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
) )
@@ -527,12 +535,12 @@ fun AppSettingsScreen(navController: NavController) {
) )
}, },
modifier = modifier =
updateItemModifier() updateItemModifier()
.let { if (!xposedActivated) it.clickable { showInstallMethodMenu = true } else it }, .let { if (!xposedActivated) it.clickable { showInstallMethodMenu = true } else it },
colors = colors =
ListItemDefaults.colors( ListItemDefaults.colors(
containerColor = Color.Transparent, containerColor = Color.Transparent,
), ),
) )
if (silentInstallMethod == "SHIZUKU" && !isMethodAvailable) { if (silentInstallMethod == "SHIZUKU" && !isMethodAvailable) {
@@ -558,15 +566,15 @@ fun AppSettingsScreen(navController: NavController) {
) )
}, },
modifier = modifier =
updateItemModifier() updateItemModifier()
.clickable { .clickable {
val intent = Intent(Intent.ACTION_VIEW, Uri.parse("https://shizuku.rikka.app/")) val intent = Intent(Intent.ACTION_VIEW, Uri.parse("https://shizuku.rikka.app/"))
context.startActivity(intent) context.startActivity(intent)
}, },
colors = colors =
ListItemDefaults.colors( ListItemDefaults.colors(
containerColor = Color.Transparent, containerColor = Color.Transparent,
), ),
) )
} }
@@ -593,18 +601,18 @@ fun AppSettingsScreen(navController: NavController) {
) )
}, },
modifier = modifier =
updateItemModifier() updateItemModifier()
.clickable { .clickable {
val intent = Intent( val intent = Intent(
AndroidSettings.ACTION_MANAGE_UNKNOWN_APP_SOURCES, AndroidSettings.ACTION_MANAGE_UNKNOWN_APP_SOURCES,
Uri.parse("package:${context.packageName}") Uri.parse("package:${context.packageName}"),
) )
context.startActivity(intent) context.startActivity(intent)
}, },
colors = colors =
ListItemDefaults.colors( ListItemDefaults.colors(
containerColor = Color.Transparent, containerColor = Color.Transparent,
), ),
) )
} }
} }
@@ -646,9 +654,9 @@ fun AppSettingsScreen(navController: NavController) {
}, },
modifier = updateItemModifier(), modifier = updateItemModifier(),
colors = colors =
ListItemDefaults.colors( ListItemDefaults.colors(
containerColor = Color.Transparent, containerColor = Color.Transparent,
), ),
) )
} }
} }
@@ -666,13 +674,13 @@ fun AppSettingsScreen(navController: NavController) {
Card( Card(
modifier = modifier =
Modifier Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(horizontal = 16.dp), .padding(horizontal = 16.dp),
colors = colors =
CardDefaults.cardColors( CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceContainer, containerColor = MaterialTheme.colorScheme.surfaceContainer,
), ),
) { ) {
Column { Column {
ListItem( ListItem(
@@ -698,40 +706,40 @@ fun AppSettingsScreen(navController: NavController) {
} }
}, },
modifier = modifier =
Modifier Modifier
.clip( .clip(
if (hasUpdate) { if (hasUpdate) {
RoundedCornerShape(topStart = 12.dp, topEnd = 12.dp) RoundedCornerShape(topStart = 12.dp, topEnd = 12.dp)
} else { } else {
RoundedCornerShape(12.dp) 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
}
}
}, },
)
.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 = colors =
ListItemDefaults.colors( ListItemDefaults.colors(
containerColor = Color.Transparent, containerColor = Color.Transparent,
), ),
) )
if (hasUpdate && updateInfo != null) { if (hasUpdate && updateInfo != null) {
@@ -756,15 +764,15 @@ fun AppSettingsScreen(navController: NavController) {
) )
}, },
modifier = modifier =
Modifier Modifier
.clip(RoundedCornerShape(bottomStart = 12.dp, bottomEnd = 12.dp)) .clip(RoundedCornerShape(bottomStart = 12.dp, bottomEnd = 12.dp))
.clickable { .clickable {
showUpdateAvailableDialog = true showUpdateAvailableDialog = true
}, },
colors = colors =
ListItemDefaults.colors( ListItemDefaults.colors(
containerColor = Color.Transparent, containerColor = Color.Transparent,
), ),
) )
} }
} }
@@ -791,11 +799,11 @@ private fun UpdateTrackDialog(
tracks.forEach { (value, label) -> tracks.forEach { (value, label) ->
Row( Row(
modifier = modifier =
Modifier Modifier
.fillMaxWidth() .fillMaxWidth()
.clip(RoundedCornerShape(8.dp)) .clip(RoundedCornerShape(8.dp))
.clickable { onTrackSelected(value) } .clickable { onTrackSelected(value) }
.padding(vertical = 8.dp), .padding(vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
RadioButton( RadioButton(
@@ -841,11 +849,11 @@ private fun InstallMethodDialog(
methods.forEach { (value, label) -> methods.forEach { (value, label) ->
Row( Row(
modifier = modifier =
Modifier Modifier
.fillMaxWidth() .fillMaxWidth()
.clip(RoundedCornerShape(8.dp)) .clip(RoundedCornerShape(8.dp))
.clickable { onMethodSelected(value) } .clickable { onMethodSelected(value) }
.padding(vertical = 8.dp), .padding(vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
RadioButton( RadioButton(

View File

@@ -1,5 +1,10 @@
package io.nekohasekai.sfa.compose.screen.settings 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.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column 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.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll 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.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.outlined.DeleteForever import androidx.compose.material.icons.outlined.DeleteForever
@@ -95,22 +95,22 @@ fun CoreSettingsScreen(navController: NavController) {
Column( Column(
modifier = modifier =
Modifier Modifier
.fillMaxSize() .fillMaxSize()
.background(MaterialTheme.colorScheme.surface) .background(MaterialTheme.colorScheme.surface)
.verticalScroll(rememberScrollState()) .verticalScroll(rememberScrollState())
.padding(vertical = 8.dp), .padding(vertical = 8.dp),
) { ) {
// Core Information Card // Core Information Card
Card( Card(
modifier = modifier =
Modifier Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp), .padding(horizontal = 16.dp, vertical = 8.dp),
colors = colors =
CardDefaults.cardColors( CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceContainer, containerColor = MaterialTheme.colorScheme.surfaceContainer,
), ),
) { ) {
Column { Column {
// Version Info // Version Info
@@ -138,9 +138,9 @@ fun CoreSettingsScreen(navController: NavController) {
}, },
modifier = Modifier.clip(RoundedCornerShape(topStart = 12.dp, topEnd = 12.dp)), modifier = Modifier.clip(RoundedCornerShape(topStart = 12.dp, topEnd = 12.dp)),
colors = colors =
ListItemDefaults.colors( ListItemDefaults.colors(
containerColor = Color.Transparent, containerColor = Color.Transparent,
), ),
) )
// Data Size // Data Size
@@ -167,16 +167,16 @@ fun CoreSettingsScreen(navController: NavController) {
) )
}, },
modifier = modifier =
Modifier.clip( Modifier.clip(
RoundedCornerShape( RoundedCornerShape(
bottomStart = 12.dp, bottomStart = 12.dp,
bottomEnd = 12.dp, bottomEnd = 12.dp,
),
), ),
),
colors = colors =
ListItemDefaults.colors( ListItemDefaults.colors(
containerColor = Color.Transparent, containerColor = Color.Transparent,
), ),
) )
} }
} }
@@ -193,13 +193,13 @@ fun CoreSettingsScreen(navController: NavController) {
Card( Card(
modifier = modifier =
Modifier Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(horizontal = 16.dp), .padding(horizontal = 16.dp),
colors = colors =
CardDefaults.cardColors( CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceContainer, containerColor = MaterialTheme.colorScheme.surfaceContainer,
), ),
) { ) {
ListItem( ListItem(
headlineContent = { headlineContent = {
@@ -228,9 +228,9 @@ fun CoreSettingsScreen(navController: NavController) {
}, },
modifier = Modifier.clip(RoundedCornerShape(12.dp)), modifier = Modifier.clip(RoundedCornerShape(12.dp)),
colors = colors =
ListItemDefaults.colors( ListItemDefaults.colors(
containerColor = Color.Transparent, containerColor = Color.Transparent,
), ),
) )
} }
@@ -246,13 +246,13 @@ fun CoreSettingsScreen(navController: NavController) {
// Working Directory Card // Working Directory Card
Card( Card(
modifier = modifier =
Modifier Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(horizontal = 16.dp), .padding(horizontal = 16.dp),
colors = colors =
CardDefaults.cardColors( CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceContainer, containerColor = MaterialTheme.colorScheme.surfaceContainer,
), ),
) { ) {
// Browse // Browse
ListItem( ListItem(
@@ -270,15 +270,15 @@ fun CoreSettingsScreen(navController: NavController) {
) )
}, },
modifier = modifier =
Modifier Modifier
.clip(RoundedCornerShape(topStart = 12.dp, topEnd = 12.dp)) .clip(RoundedCornerShape(topStart = 12.dp, topEnd = 12.dp))
.clickable { .clickable {
openInFileManager(context) openInFileManager(context)
}, },
colors = colors =
ListItemDefaults.colors( ListItemDefaults.colors(
containerColor = Color.Transparent, containerColor = Color.Transparent,
), ),
) )
// Destroy // Destroy
@@ -298,28 +298,28 @@ fun CoreSettingsScreen(navController: NavController) {
) )
}, },
modifier = modifier =
Modifier Modifier
.clip(RoundedCornerShape(bottomStart = 12.dp, bottomEnd = 12.dp)) .clip(RoundedCornerShape(bottomStart = 12.dp, bottomEnd = 12.dp))
.clickable { .clickable {
scope.launch(Dispatchers.IO) { scope.launch(Dispatchers.IO) {
val filesDir = context.getExternalFilesDir(null) ?: context.filesDir val filesDir = context.getExternalFilesDir(null) ?: context.filesDir
filesDir.deleteRecursively() filesDir.deleteRecursively()
filesDir.mkdirs() filesDir.mkdirs()
// Recalculate data size // Recalculate data size
val newSize = val newSize =
filesDir.walkTopDown() filesDir.walkTopDown()
.filter { it.isFile } .filter { it.isFile }
.map { it.length() } .map { it.length() }
.sum() .sum()
val formattedSize = Libbox.formatBytes(newSize) val formattedSize = Libbox.formatBytes(newSize)
dataSize = formattedSize dataSize = formattedSize
} }
}, },
colors = colors =
ListItemDefaults.colors( ListItemDefaults.colors(
containerColor = Color.Transparent, containerColor = Color.Transparent,
), ),
) )
} }
@@ -343,7 +343,7 @@ private fun openInFileManager(context: Context) {
Toast.makeText( Toast.makeText(
context, context,
context.getString(R.string.no_file_manager), context.getString(R.string.no_file_manager),
Toast.LENGTH_SHORT Toast.LENGTH_SHORT,
).show() ).show()
} }
} }

View File

@@ -1,7 +1,6 @@
package io.nekohasekai.sfa.compose.screen.settings package io.nekohasekai.sfa.compose.screen.settings
import android.content.Intent import android.content.Intent
import android.net.Uri
import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.background import androidx.compose.foundation.background
@@ -61,18 +60,18 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.core.content.FileProvider import androidx.core.content.FileProvider
import androidx.navigation.NavController import androidx.navigation.NavController
import io.nekohasekai.sfa.R
import io.nekohasekai.libbox.Libbox import io.nekohasekai.libbox.Libbox
import io.nekohasekai.sfa.R
import io.nekohasekai.sfa.compose.base.GlobalEventBus import io.nekohasekai.sfa.compose.base.GlobalEventBus
import io.nekohasekai.sfa.compose.base.SelectableMessageDialog import io.nekohasekai.sfa.compose.base.SelectableMessageDialog
import io.nekohasekai.sfa.compose.base.UiEvent import io.nekohasekai.sfa.compose.base.UiEvent
import io.nekohasekai.sfa.compose.topbar.OverrideTopBar import io.nekohasekai.sfa.compose.topbar.OverrideTopBar
import io.nekohasekai.sfa.database.Settings
import io.nekohasekai.sfa.constant.Status import io.nekohasekai.sfa.constant.Status
import io.nekohasekai.sfa.database.Settings
import io.nekohasekai.sfa.utils.DetectionResult import io.nekohasekai.sfa.utils.DetectionResult
import io.nekohasekai.sfa.utils.HookModuleUpdateNotifier import io.nekohasekai.sfa.utils.HookModuleUpdateNotifier
import io.nekohasekai.sfa.utils.PrivilegeSettingsClient
import io.nekohasekai.sfa.utils.HookStatusClient import io.nekohasekai.sfa.utils.HookStatusClient
import io.nekohasekai.sfa.utils.PrivilegeSettingsClient
import io.nekohasekai.sfa.utils.VpnDetectionTest import io.nekohasekai.sfa.utils.VpnDetectionTest
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -122,7 +121,7 @@ fun PrivilegeSettingsScreen(navController: NavController, serviceStatus: Status
var messageDialogMessage by remember { mutableStateOf("") } var messageDialogMessage by remember { mutableStateOf("") }
val saveFileLauncher = rememberLauncherForActivityResult( val saveFileLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.CreateDocument("application/zip") contract = ActivityResultContracts.CreateDocument("application/zip"),
) { uri -> ) { uri ->
val file = exportedFile val file = exportedFile
if (uri != null && file != null) { if (uri != null && file != null) {
@@ -146,9 +145,9 @@ fun PrivilegeSettingsScreen(navController: NavController, serviceStatus: Status
HookStatusClient.refresh() HookStatusClient.refresh()
} }
val hasPendingDowngrade = HookModuleUpdateNotifier.isDowngrade(systemHookStatus) val hasPendingDowngrade = HookModuleUpdateNotifier.isDowngrade(systemHookStatus)
val hasPendingUpdate = HookModuleUpdateNotifier.isUpgrade(systemHookStatus) val hasPendingUpdate = HookModuleUpdateNotifier.isUpgrade(systemHookStatus)
val hasPendingChange = hasPendingDowngrade || hasPendingUpdate val hasPendingChange = hasPendingDowngrade || hasPendingUpdate
androidx.compose.runtime.LaunchedEffect(systemHookStatus) { androidx.compose.runtime.LaunchedEffect(systemHookStatus) {
HookModuleUpdateNotifier.maybeNotify(context, systemHookStatus) HookModuleUpdateNotifier.maybeNotify(context, systemHookStatus)
} }
@@ -231,8 +230,11 @@ fun PrivilegeSettingsScreen(navController: NavController, serviceStatus: Status
CircularProgressIndicator(modifier = Modifier.size(24.dp)) CircularProgressIndicator(modifier = Modifier.size(24.dp))
Spacer(modifier = Modifier.width(12.dp)) Spacer(modifier = Modifier.width(12.dp))
Text( Text(
if (exportError != null) exportError!! if (exportError != null) {
else stringResource(R.string.exporting) exportError!!
} else {
stringResource(R.string.exporting)
},
) )
} }
}, },
@@ -273,7 +275,7 @@ fun PrivilegeSettingsScreen(navController: NavController, serviceStatus: Status
val uri = FileProvider.getUriForFile( val uri = FileProvider.getUriForFile(
context, context,
"${context.packageName}.cache", "${context.packageName}.cache",
file file,
) )
val intent = Intent(Intent.ACTION_SEND).apply { val intent = Intent(Intent.ACTION_SEND).apply {
type = "application/zip" type = "application/zip"
@@ -283,7 +285,7 @@ fun PrivilegeSettingsScreen(navController: NavController, serviceStatus: Status
context.startActivity(Intent.createChooser(intent, null)) context.startActivity(Intent.createChooser(intent, null))
showExportSuccessDialog = false showExportSuccessDialog = false
exportedFile = null exportedFile = null
} },
) { ) {
Text(stringResource(R.string.menu_share)) Text(stringResource(R.string.menu_share))
} }
@@ -293,7 +295,7 @@ fun PrivilegeSettingsScreen(navController: NavController, serviceStatus: Status
onClick = { onClick = {
val file = exportedFile ?: return@TextButton val file = exportedFile ?: return@TextButton
saveFileLauncher.launch(file.name) saveFileLauncher.launch(file.name)
} },
) { ) {
Text(stringResource(R.string.save)) Text(stringResource(R.string.save))
} }
@@ -413,11 +415,11 @@ fun PrivilegeSettingsScreen(navController: NavController, serviceStatus: Status
) )
}, },
modifier = modifier =
Modifier Modifier
.clip(logItemShape) .clip(logItemShape)
.clickable { .clickable {
navController.navigate("settings/privilege/logs") navController.navigate("settings/privilege/logs")
}, },
colors = ListItemDefaults.colors( colors = ListItemDefaults.colors(
containerColor = Color.Transparent, containerColor = Color.Transparent,
), ),
@@ -439,42 +441,42 @@ fun PrivilegeSettingsScreen(navController: NavController, serviceStatus: Status
) )
}, },
modifier = modifier =
Modifier Modifier
.clip(RoundedCornerShape(bottomStart = 12.dp, bottomEnd = 12.dp)) .clip(RoundedCornerShape(bottomStart = 12.dp, bottomEnd = 12.dp))
.clickable { .clickable {
val exportBase = File(context.cacheDir, "debug") val exportBase = File(context.cacheDir, "debug")
if (!exportBase.exists()) { if (!exportBase.exists()) {
exportBase.mkdirs() 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 = if (exportCancelled) {
SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format(Date()) outZip.delete()
val outZip = File(exportBase, "sing-box-lsposed-debug-${timestamp}.zip") return@launch
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
}
} }
}, 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( colors = ListItemDefaults.colors(
containerColor = Color.Transparent, containerColor = Color.Transparent,
), ),
@@ -496,44 +498,44 @@ fun PrivilegeSettingsScreen(navController: NavController, serviceStatus: Status
) )
}, },
modifier = modifier =
Modifier Modifier
.clip(RoundedCornerShape(bottomStart = 12.dp, bottomEnd = 12.dp)) .clip(RoundedCornerShape(bottomStart = 12.dp, bottomEnd = 12.dp))
.clickable { .clickable {
scope.launch { scope.launch {
val failure = withContext(Dispatchers.IO) { val failure = withContext(Dispatchers.IO) {
runCatching { runCatching {
val process = Runtime.getRuntime().exec( val process = Runtime.getRuntime().exec(
arrayOf( arrayOf(
"su", "su",
"-c", "-c",
"/system/bin/svc power reboot || /system/bin/reboot", "/system/bin/svc power reboot || /system/bin/reboot",
), ),
) )
val error = process.errorStream.bufferedReader().use { it.readText().trim() } val error = process.errorStream.bufferedReader().use { it.readText().trim() }
process.inputStream.close() process.inputStream.close()
process.outputStream.close() process.outputStream.close()
process.errorStream.close() process.errorStream.close()
val code = process.waitFor() val code = process.waitFor()
if (code == 0) { if (code == 0) {
null null
} else { } else {
error.ifBlank { "exit=$code" } error.ifBlank { "exit=$code" }
} }
}.getOrElse { it.message ?: "unknown" } }.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
}
} }
}, 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( colors = ListItemDefaults.colors(
containerColor = Color.Transparent, containerColor = Color.Transparent,
), ),
@@ -621,7 +623,7 @@ fun PrivilegeSettingsScreen(navController: NavController, serviceStatus: Status
RoundedCornerShape(topStart = 12.dp, topEnd = 12.dp) RoundedCornerShape(topStart = 12.dp, topEnd = 12.dp)
} else { } else {
RoundedCornerShape(12.dp) RoundedCornerShape(12.dp)
} },
), ),
colors = ListItemDefaults.colors( colors = ListItemDefaults.colors(
containerColor = Color.Transparent, 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) RoundedCornerShape(topStart = 12.dp, topEnd = 12.dp)
} else { } else {
RoundedCornerShape(12.dp) RoundedCornerShape(12.dp)
} },
), ),
colors = ListItemDefaults.colors( colors = ListItemDefaults.colors(
containerColor = Color.Transparent, containerColor = Color.Transparent,
@@ -847,11 +848,7 @@ fun PrivilegeSettingsScreen(navController: NavController, serviceStatus: Status
} }
@Composable @Composable
private fun SelfTestDialog( private fun SelfTestDialog(isRunning: Boolean, result: DetectionResult?, onDismiss: () -> Unit) {
isRunning: Boolean,
result: DetectionResult?,
onDismiss: () -> Unit,
) {
val notDetectedText = stringResource(R.string.privilege_settings_hide_test_not_detected) val notDetectedText = stringResource(R.string.privilege_settings_hide_test_not_detected)
AlertDialog( AlertDialog(

View File

@@ -54,14 +54,14 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalLifecycleOwner import androidx.compose.ui.platform.LocalLifecycleOwner
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.navigation.NavController
import androidx.lifecycle.Lifecycle import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.LifecycleEventObserver
import androidx.navigation.NavController
import io.nekohasekai.sfa.R import io.nekohasekai.sfa.R
import io.nekohasekai.sfa.bg.RootClient 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.compose.topbar.OverrideTopBar
import io.nekohasekai.sfa.database.Settings import io.nekohasekai.sfa.database.Settings
import io.nekohasekai.sfa.compose.screen.profileoverride.PerAppProxyScanner
import io.nekohasekai.sfa.vendor.PackageQueryManager import io.nekohasekai.sfa.vendor.PackageQueryManager
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async import kotlinx.coroutines.async
@@ -162,22 +162,22 @@ fun ProfileOverrideScreen(navController: NavController) {
Column( Column(
modifier = modifier =
Modifier Modifier
.fillMaxSize() .fillMaxSize()
.background(MaterialTheme.colorScheme.surface) .background(MaterialTheme.colorScheme.surface)
.verticalScroll(rememberScrollState()) .verticalScroll(rememberScrollState())
.padding(vertical = 8.dp), .padding(vertical = 8.dp),
) { ) {
// Card 1: Auto Redirect // Card 1: Auto Redirect
Card( Card(
modifier = modifier =
Modifier Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp), .padding(horizontal = 16.dp, vertical = 8.dp),
colors = colors =
CardDefaults.cardColors( CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceContainer, containerColor = MaterialTheme.colorScheme.surfaceContainer,
), ),
) { ) {
ListItem( ListItem(
headlineContent = { headlineContent = {
@@ -232,9 +232,9 @@ fun ProfileOverrideScreen(navController: NavController) {
}, },
modifier = Modifier.clip(RoundedCornerShape(12.dp)), modifier = Modifier.clip(RoundedCornerShape(12.dp)),
colors = colors =
ListItemDefaults.colors( ListItemDefaults.colors(
containerColor = Color.Transparent, containerColor = Color.Transparent,
), ),
) )
} }
@@ -254,13 +254,13 @@ fun ProfileOverrideScreen(navController: NavController) {
Card( Card(
modifier = modifier =
Modifier Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(horizontal = 16.dp), .padding(horizontal = 16.dp),
colors = colors =
CardDefaults.cardColors( CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceContainer, containerColor = MaterialTheme.colorScheme.surfaceContainer,
), ),
) { ) {
Column { Column {
// Mode selector (only when privileged query is needed) // Mode selector (only when privileged query is needed)
@@ -272,32 +272,44 @@ fun ProfileOverrideScreen(navController: NavController) {
Text( Text(
stringResource(R.string.per_app_proxy_package_query_mode), stringResource(R.string.per_app_proxy_package_query_mode),
style = MaterialTheme.typography.bodyLarge, style = MaterialTheme.typography.bodyLarge,
color = if (modeEnabled) Color.Unspecified color = if (modeEnabled) {
else MaterialTheme.colorScheme.onSurface.copy(alpha = disabledAlpha), Color.Unspecified
} else {
MaterialTheme.colorScheme.onSurface.copy(alpha = disabledAlpha)
},
) )
}, },
supportingContent = { supportingContent = {
Text( Text(
if (useRootMode) "ROOT" else "Shizuku", if (useRootMode) "ROOT" else "Shizuku",
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
color = if (modeEnabled) MaterialTheme.colorScheme.onSurfaceVariant color = if (modeEnabled) {
else MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = disabledAlpha), MaterialTheme.colorScheme.onSurfaceVariant
} else {
MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = disabledAlpha)
},
) )
}, },
leadingContent = { leadingContent = {
Icon( Icon(
imageVector = Icons.Outlined.Tune, imageVector = Icons.Outlined.Tune,
contentDescription = null, contentDescription = null,
tint = if (modeEnabled) MaterialTheme.colorScheme.primary tint = if (modeEnabled) {
else MaterialTheme.colorScheme.onSurface.copy(alpha = disabledAlpha), MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme.onSurface.copy(alpha = disabledAlpha)
},
) )
}, },
trailingContent = { trailingContent = {
Icon( Icon(
imageVector = Icons.AutoMirrored.Outlined.KeyboardArrowRight, imageVector = Icons.AutoMirrored.Outlined.KeyboardArrowRight,
contentDescription = null, contentDescription = null,
tint = if (modeEnabled) MaterialTheme.colorScheme.onSurfaceVariant tint = if (modeEnabled) {
else MaterialTheme.colorScheme.onSurface.copy(alpha = disabledAlpha), MaterialTheme.colorScheme.onSurfaceVariant
} else {
MaterialTheme.colorScheme.onSurface.copy(alpha = disabledAlpha)
},
) )
}, },
modifier = Modifier modifier = Modifier
@@ -355,19 +367,19 @@ fun ProfileOverrideScreen(navController: NavController) {
) )
}, },
modifier = modifier =
Modifier.clip( Modifier.clip(
if (showModeSelector) { if (showModeSelector) {
RoundedCornerShape(0.dp) RoundedCornerShape(0.dp)
} else if (perAppProxyEnabled && canUsePerAppProxy) { } else if (perAppProxyEnabled && canUsePerAppProxy) {
RoundedCornerShape(topStart = 12.dp, topEnd = 12.dp) RoundedCornerShape(topStart = 12.dp, topEnd = 12.dp)
} else { } else {
RoundedCornerShape(12.dp) RoundedCornerShape(12.dp)
}, },
), ),
colors = colors =
ListItemDefaults.colors( ListItemDefaults.colors(
containerColor = Color.Transparent, containerColor = Color.Transparent,
), ),
) )
if (perAppProxyEnabled && canUsePerAppProxy) { if (perAppProxyEnabled && canUsePerAppProxy) {
@@ -409,13 +421,13 @@ fun ProfileOverrideScreen(navController: NavController) {
) )
}, },
modifier = modifier =
Modifier.clickable(enabled = manageEnabled) { Modifier.clickable(enabled = manageEnabled) {
navController.navigate("settings/profile_override/manage") navController.navigate("settings/profile_override/manage")
}, },
colors = colors =
ListItemDefaults.colors( ListItemDefaults.colors(
containerColor = Color.Transparent, containerColor = Color.Transparent,
), ),
) )
// Managed Mode toggle // Managed Mode toggle
@@ -477,9 +489,9 @@ fun ProfileOverrideScreen(navController: NavController) {
}, },
modifier = Modifier.clip(RoundedCornerShape(bottomStart = 12.dp, bottomEnd = 12.dp)), modifier = Modifier.clip(RoundedCornerShape(bottomStart = 12.dp, bottomEnd = 12.dp)),
colors = colors =
ListItemDefaults.colors( ListItemDefaults.colors(
containerColor = Color.Transparent, containerColor = Color.Transparent,
), ),
) )
} }
} }
@@ -601,7 +613,7 @@ fun ProfileOverrideScreen(navController: NavController) {
Toast.makeText( Toast.makeText(
context, context,
R.string.root_access_denied, R.string.root_access_denied,
Toast.LENGTH_LONG Toast.LENGTH_LONG,
).show() ).show()
} }
} }
@@ -706,7 +718,7 @@ private suspend fun scanAllChinaApps(): Set<String> = withContext(Dispatchers.De
val chinaApps = mutableSetOf<String>() val chinaApps = mutableSetOf<String>()
installedPackages.map { packageInfo -> installedPackages.map { packageInfo ->
async { async {
if (PerAppProxyScanner.scanChinaPackage(packageInfo)) { if (PerAppProxyScanner.scanChinaPackage(packageInfo)) {
synchronized(chinaApps) { synchronized(chinaApps) {
chinaApps.add(packageInfo.packageName) chinaApps.add(packageInfo.packageName)
} }

View File

@@ -63,10 +63,7 @@ import kotlinx.coroutines.launch
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun ServiceSettingsScreen( fun ServiceSettingsScreen(navController: NavController, serviceConnection: ServiceConnection? = null) {
navController: NavController,
serviceConnection: ServiceConnection? = null,
) {
OverrideTopBar { OverrideTopBar {
TopAppBar( TopAppBar(
title = { Text(stringResource(R.string.service)) }, title = { Text(stringResource(R.string.service)) },
@@ -113,23 +110,23 @@ fun ServiceSettingsScreen(
Column( Column(
modifier = modifier =
Modifier Modifier
.fillMaxSize() .fillMaxSize()
.background(MaterialTheme.colorScheme.surface) .background(MaterialTheme.colorScheme.surface)
.verticalScroll(rememberScrollState()) .verticalScroll(rememberScrollState())
.padding(vertical = 8.dp), .padding(vertical = 8.dp),
) { ) {
// Background Permission Card (only show if battery optimization is not ignored) // Background Permission Card (only show if battery optimization is not ignored)
if (!isBatteryOptimizationIgnored && Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { if (!isBatteryOptimizationIgnored && Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
Card( Card(
modifier = modifier =
Modifier Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp), .padding(horizontal = 16.dp, vertical = 8.dp),
colors = colors =
CardDefaults.cardColors( CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.tertiaryContainer.copy(alpha = 0.5f), containerColor = MaterialTheme.colorScheme.tertiaryContainer.copy(alpha = 0.5f),
), ),
) { ) {
Column( Column(
modifier = Modifier.padding(16.dp), modifier = Modifier.padding(16.dp),
@@ -193,13 +190,13 @@ fun ServiceSettingsScreen(
// Options Section // Options Section
Card( Card(
modifier = modifier =
Modifier Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(horizontal = 16.dp), .padding(horizontal = 16.dp),
colors = colors =
CardDefaults.cardColors( CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceContainer, containerColor = MaterialTheme.colorScheme.surfaceContainer,
), ),
) { ) {
ListItem( ListItem(
headlineContent = { headlineContent = {
@@ -234,9 +231,9 @@ fun ServiceSettingsScreen(
}, },
modifier = Modifier.clip(RoundedCornerShape(12.dp)), modifier = Modifier.clip(RoundedCornerShape(12.dp)),
colors = colors =
ListItemDefaults.colors( ListItemDefaults.colors(
containerColor = Color.Transparent, containerColor = Color.Transparent,
), ),
) )
} }

View File

@@ -15,15 +15,14 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.outlined.OpenInNew 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.Code
import androidx.compose.material.icons.outlined.Description import androidx.compose.material.icons.outlined.Description
import androidx.compose.material.icons.outlined.Favorite import androidx.compose.material.icons.outlined.Favorite
import androidx.compose.material.icons.outlined.FilterAlt import androidx.compose.material.icons.outlined.FilterAlt
import androidx.compose.material.icons.outlined.Info import androidx.compose.material.icons.outlined.Info
import androidx.compose.material.icons.outlined.Settings import androidx.compose.material.icons.outlined.Settings
import androidx.compose.material.icons.outlined.SwapHoriz
import androidx.compose.material.icons.outlined.Tune import androidx.compose.material.icons.outlined.Tune
import androidx.compose.material.icons.outlined.AdminPanelSettings
import androidx.compose.material3.Badge import androidx.compose.material3.Badge
import androidx.compose.material3.Card import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults import androidx.compose.material3.CardDefaults
@@ -49,15 +48,12 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.navigation.NavController import androidx.navigation.NavController
import io.nekohasekai.sfa.BuildConfig
import io.nekohasekai.sfa.R import io.nekohasekai.sfa.R
import io.nekohasekai.sfa.compose.topbar.OverrideTopBar import io.nekohasekai.sfa.compose.topbar.OverrideTopBar
import io.nekohasekai.sfa.database.Settings import io.nekohasekai.sfa.database.Settings
import io.nekohasekai.sfa.update.UpdateState import io.nekohasekai.sfa.update.UpdateState
import io.nekohasekai.sfa.utils.HookModuleUpdateNotifier import io.nekohasekai.sfa.utils.HookModuleUpdateNotifier
import io.nekohasekai.sfa.utils.HookStatusClient import io.nekohasekai.sfa.utils.HookStatusClient
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
@@ -87,22 +83,22 @@ fun SettingsScreen(navController: NavController) {
Column( Column(
modifier = modifier =
Modifier Modifier
.fillMaxSize() .fillMaxSize()
.background(MaterialTheme.colorScheme.surface) .background(MaterialTheme.colorScheme.surface)
.verticalScroll(rememberScrollState()) .verticalScroll(rememberScrollState())
.padding(vertical = 8.dp), .padding(vertical = 8.dp),
) { ) {
// General Settings Group // General Settings Group
Card( Card(
modifier = modifier =
Modifier Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp), .padding(horizontal = 16.dp, vertical = 8.dp),
colors = colors =
CardDefaults.cardColors( CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceContainer, containerColor = MaterialTheme.colorScheme.surfaceContainer,
), ),
) { ) {
Column { Column {
ListItem( ListItem(
@@ -125,13 +121,13 @@ fun SettingsScreen(navController: NavController) {
} }
}, },
modifier = modifier =
Modifier Modifier
.clip(RoundedCornerShape(topStart = 12.dp, topEnd = 12.dp)) .clip(RoundedCornerShape(topStart = 12.dp, topEnd = 12.dp))
.clickable { navController.navigate("settings/app") }, .clickable { navController.navigate("settings/app") },
colors = colors =
ListItemDefaults.colors( ListItemDefaults.colors(
containerColor = Color.Transparent, containerColor = Color.Transparent,
), ),
) )
ListItem( ListItem(
@@ -149,12 +145,12 @@ fun SettingsScreen(navController: NavController) {
) )
}, },
modifier = modifier =
Modifier Modifier
.clickable { navController.navigate("settings/core") }, .clickable { navController.navigate("settings/core") },
colors = colors =
ListItemDefaults.colors( ListItemDefaults.colors(
containerColor = Color.Transparent, containerColor = Color.Transparent,
), ),
) )
ListItem( ListItem(
@@ -178,9 +174,9 @@ fun SettingsScreen(navController: NavController) {
}, },
modifier = Modifier.clickable { navController.navigate("settings/service") }, modifier = Modifier.clickable { navController.navigate("settings/service") },
colors = colors =
ListItemDefaults.colors( ListItemDefaults.colors(
containerColor = Color.Transparent, containerColor = Color.Transparent,
), ),
) )
ListItem( ListItem(
@@ -198,12 +194,12 @@ fun SettingsScreen(navController: NavController) {
) )
}, },
modifier = modifier =
Modifier Modifier
.clickable { navController.navigate("settings/profile_override") }, .clickable { navController.navigate("settings/profile_override") },
colors = colors =
ListItemDefaults.colors( ListItemDefaults.colors(
containerColor = Color.Transparent, containerColor = Color.Transparent,
), ),
) )
ListItem( ListItem(
@@ -228,13 +224,13 @@ fun SettingsScreen(navController: NavController) {
} }
}, },
modifier = modifier =
Modifier Modifier
.clip(RoundedCornerShape(bottomStart = 12.dp, bottomEnd = 12.dp)) .clip(RoundedCornerShape(bottomStart = 12.dp, bottomEnd = 12.dp))
.clickable { navController.navigate("settings/privilege") }, .clickable { navController.navigate("settings/privilege") },
colors = colors =
ListItemDefaults.colors( ListItemDefaults.colors(
containerColor = Color.Transparent, containerColor = Color.Transparent,
), ),
) )
} }
} }
@@ -249,13 +245,13 @@ fun SettingsScreen(navController: NavController) {
Card( Card(
modifier = modifier =
Modifier Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(horizontal = 16.dp), .padding(horizontal = 16.dp),
colors = colors =
CardDefaults.cardColors( CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceContainer, containerColor = MaterialTheme.colorScheme.surfaceContainer,
), ),
) { ) {
Column { Column {
ListItem( ListItem(
@@ -280,17 +276,17 @@ fun SettingsScreen(navController: NavController) {
) )
}, },
modifier = modifier =
Modifier Modifier
.clip(RoundedCornerShape(topStart = 12.dp, topEnd = 12.dp)) .clip(RoundedCornerShape(topStart = 12.dp, topEnd = 12.dp))
.clickable { .clickable {
val intent = android.content.Intent(android.content.Intent.ACTION_VIEW) val intent = android.content.Intent(android.content.Intent.ACTION_VIEW)
intent.data = android.net.Uri.parse("https://sing-box.sagernet.org/") intent.data = android.net.Uri.parse("https://sing-box.sagernet.org/")
context.startActivity(intent) context.startActivity(intent)
}, },
colors = colors =
ListItemDefaults.colors( ListItemDefaults.colors(
containerColor = Color.Transparent, containerColor = Color.Transparent,
), ),
) )
ListItem( ListItem(
@@ -315,17 +311,17 @@ fun SettingsScreen(navController: NavController) {
) )
}, },
modifier = modifier =
Modifier Modifier
.clickable { .clickable {
val intent = android.content.Intent(android.content.Intent.ACTION_VIEW) val intent = android.content.Intent(android.content.Intent.ACTION_VIEW)
intent.data = intent.data =
android.net.Uri.parse("https://github.com/SagerNet/sing-box-for-android") android.net.Uri.parse("https://github.com/SagerNet/sing-box-for-android")
context.startActivity(intent) context.startActivity(intent)
}, },
colors = colors =
ListItemDefaults.colors( ListItemDefaults.colors(
containerColor = Color.Transparent, containerColor = Color.Transparent,
), ),
) )
ListItem( ListItem(
@@ -350,17 +346,17 @@ fun SettingsScreen(navController: NavController) {
) )
}, },
modifier = modifier =
Modifier Modifier
.clip(RoundedCornerShape(bottomStart = 12.dp, bottomEnd = 12.dp)) .clip(RoundedCornerShape(bottomStart = 12.dp, bottomEnd = 12.dp))
.clickable { .clickable {
val intent = android.content.Intent(android.content.Intent.ACTION_VIEW) val intent = android.content.Intent(android.content.Intent.ACTION_VIEW)
intent.data = android.net.Uri.parse("https://sekai.icu/sponsors/") intent.data = android.net.Uri.parse("https://sekai.icu/sponsors/")
context.startActivity(intent) context.startActivity(intent)
}, },
colors = colors =
ListItemDefaults.colors( ListItemDefaults.colors(
containerColor = Color.Transparent, containerColor = Color.Transparent,
), ),
) )
} }
} }

View File

@@ -178,9 +178,9 @@ fun AppSelectionCard(
modifier = cardModifier, modifier = cardModifier,
shape = cardShape, shape = cardShape,
colors = colors =
CardDefaults.cardColors( CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceContainerLow, containerColor = MaterialTheme.colorScheme.surfaceContainerLow,
), ),
) { ) {
Row( Row(
modifier = Modifier.padding(12.dp), modifier = Modifier.padding(12.dp),
@@ -236,11 +236,11 @@ fun AppSelectionCard(
trailingIcon = { trailingIcon = {
Icon( Icon(
imageVector = imageVector =
if (showCopyMenu) { if (showCopyMenu) {
Icons.Default.ExpandLess Icons.Default.ExpandLess
} else { } else {
Icons.Default.ExpandMore Icons.Default.ExpandMore
}, },
contentDescription = null, contentDescription = null,
) )
}, },

View File

@@ -11,127 +11,127 @@ val Typography =
Typography( Typography(
// Display styles // Display styles
displayLarge = displayLarge =
TextStyle( TextStyle(
fontFamily = FontFamily.Default, fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal, fontWeight = FontWeight.Normal,
fontSize = 57.sp, fontSize = 57.sp,
lineHeight = 64.sp, lineHeight = 64.sp,
letterSpacing = (-0.25).sp, letterSpacing = (-0.25).sp,
), ),
displayMedium = displayMedium =
TextStyle( TextStyle(
fontFamily = FontFamily.Default, fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal, fontWeight = FontWeight.Normal,
fontSize = 45.sp, fontSize = 45.sp,
lineHeight = 52.sp, lineHeight = 52.sp,
letterSpacing = 0.sp, letterSpacing = 0.sp,
), ),
displaySmall = displaySmall =
TextStyle( TextStyle(
fontFamily = FontFamily.Default, fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal, fontWeight = FontWeight.Normal,
fontSize = 36.sp, fontSize = 36.sp,
lineHeight = 44.sp, lineHeight = 44.sp,
letterSpacing = 0.sp, letterSpacing = 0.sp,
), ),
// Headline styles // Headline styles
headlineLarge = headlineLarge =
TextStyle( TextStyle(
fontFamily = FontFamily.Default, fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal, fontWeight = FontWeight.Normal,
fontSize = 32.sp, fontSize = 32.sp,
lineHeight = 40.sp, lineHeight = 40.sp,
letterSpacing = 0.sp, letterSpacing = 0.sp,
), ),
headlineMedium = headlineMedium =
TextStyle( TextStyle(
fontFamily = FontFamily.Default, fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal, fontWeight = FontWeight.Normal,
fontSize = 28.sp, fontSize = 28.sp,
lineHeight = 36.sp, lineHeight = 36.sp,
letterSpacing = 0.sp, letterSpacing = 0.sp,
), ),
headlineSmall = headlineSmall =
TextStyle( TextStyle(
fontFamily = FontFamily.Default, fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal, fontWeight = FontWeight.Normal,
fontSize = 24.sp, fontSize = 24.sp,
lineHeight = 32.sp, lineHeight = 32.sp,
letterSpacing = 0.sp, letterSpacing = 0.sp,
), ),
// Title styles // Title styles
titleLarge = titleLarge =
TextStyle( TextStyle(
fontFamily = FontFamily.Default, fontFamily = FontFamily.Default,
fontWeight = FontWeight.Medium, fontWeight = FontWeight.Medium,
fontSize = 22.sp, fontSize = 22.sp,
lineHeight = 28.sp, lineHeight = 28.sp,
letterSpacing = 0.sp, letterSpacing = 0.sp,
), ),
titleMedium = titleMedium =
TextStyle( TextStyle(
fontFamily = FontFamily.Default, fontFamily = FontFamily.Default,
fontWeight = FontWeight.Medium, fontWeight = FontWeight.Medium,
fontSize = 16.sp, fontSize = 16.sp,
lineHeight = 24.sp, lineHeight = 24.sp,
letterSpacing = 0.15.sp, letterSpacing = 0.15.sp,
), ),
titleSmall = titleSmall =
TextStyle( TextStyle(
fontFamily = FontFamily.Default, fontFamily = FontFamily.Default,
fontWeight = FontWeight.Medium, fontWeight = FontWeight.Medium,
fontSize = 14.sp, fontSize = 14.sp,
lineHeight = 20.sp, lineHeight = 20.sp,
letterSpacing = 0.1.sp, letterSpacing = 0.1.sp,
), ),
// Body styles // Body styles
bodyLarge = bodyLarge =
TextStyle( TextStyle(
fontFamily = FontFamily.Default, fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal, fontWeight = FontWeight.Normal,
fontSize = 16.sp, fontSize = 16.sp,
lineHeight = 24.sp, lineHeight = 24.sp,
letterSpacing = 0.5.sp, letterSpacing = 0.5.sp,
), ),
bodyMedium = bodyMedium =
TextStyle( TextStyle(
fontFamily = FontFamily.Default, fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal, fontWeight = FontWeight.Normal,
fontSize = 14.sp, fontSize = 14.sp,
lineHeight = 20.sp, lineHeight = 20.sp,
letterSpacing = 0.25.sp, letterSpacing = 0.25.sp,
), ),
bodySmall = bodySmall =
TextStyle( TextStyle(
fontFamily = FontFamily.Default, fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal, fontWeight = FontWeight.Normal,
fontSize = 12.sp, fontSize = 12.sp,
lineHeight = 16.sp, lineHeight = 16.sp,
letterSpacing = 0.4.sp, letterSpacing = 0.4.sp,
), ),
// Label styles // Label styles
labelLarge = labelLarge =
TextStyle( TextStyle(
fontFamily = FontFamily.Default, fontFamily = FontFamily.Default,
fontWeight = FontWeight.Medium, fontWeight = FontWeight.Medium,
fontSize = 14.sp, fontSize = 14.sp,
lineHeight = 20.sp, lineHeight = 20.sp,
letterSpacing = 0.1.sp, letterSpacing = 0.1.sp,
), ),
labelMedium = labelMedium =
TextStyle( TextStyle(
fontFamily = FontFamily.Default, fontFamily = FontFamily.Default,
fontWeight = FontWeight.Medium, fontWeight = FontWeight.Medium,
fontSize = 12.sp, fontSize = 12.sp,
lineHeight = 16.sp, lineHeight = 16.sp,
letterSpacing = 0.5.sp, letterSpacing = 0.5.sp,
), ),
labelSmall = labelSmall =
TextStyle( TextStyle(
fontFamily = FontFamily.Default, fontFamily = FontFamily.Default,
fontWeight = FontWeight.Medium, fontWeight = FontWeight.Medium,
fontSize = 11.sp, fontSize = 11.sp,
lineHeight = 16.sp, lineHeight = 16.sp,
letterSpacing = 0.5.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.remember
import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.rememberUpdatedState
internal data class TopBarEntry( internal data class TopBarEntry(val key: Any, val content: @Composable () -> Unit)
val key: Any,
val content: @Composable () -> Unit,
)
class TopBarController internal constructor( class TopBarController internal constructor(private val state: MutableState<List<TopBarEntry>>) {
private val state: MutableState<List<TopBarEntry>>,
) {
val current: (@Composable () -> Unit)? get() = state.value.lastOrNull()?.content val current: (@Composable () -> Unit)? get() = state.value.lastOrNull()?.content
fun set( fun set(key: Any, content: @Composable () -> Unit) {
key: Any,
content: @Composable () -> Unit,
) {
state.value = state.value.filterNot { it.key == key } + TopBarEntry(key, content) 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.material.icons.twotone.*
import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector
data class IconCategory( data class IconCategory(val name: String, val icons: List<ProfileIcon>)
val name: String,
val icons: List<ProfileIcon>,
)
object MaterialIconsLibrary { object MaterialIconsLibrary {
val categories = val categories =
@@ -416,20 +413,16 @@ object MaterialIconsLibrary {
), ),
) )
fun getAllIcons(): List<ProfileIcon> { fun getAllIcons(): List<ProfileIcon> = categories.flatMap { it.icons }
return categories.flatMap { it.icons }
}
fun getIconById(id: String?): ImageVector? { fun getIconById(id: String?): ImageVector? {
if (id == null) return null if (id == null) return null
return getAllIcons().find { it.id == id }?.icon return getAllIcons().find { it.id == id }?.icon
} }
fun getCategoryForIcon(iconId: String): String? { fun getCategoryForIcon(iconId: String): String? = categories.find { category ->
return categories.find { category -> category.icons.any { it.id == iconId }
category.icons.any { it.id == iconId } }?.name
}?.name
}
fun searchIcons(query: String): List<ProfileIcon> { fun searchIcons(query: String): List<ProfileIcon> {
val lowercaseQuery = query.lowercase() 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 androidx.compose.ui.graphics.vector.ImageVector
import io.nekohasekai.sfa.compose.util.icons.MaterialIconsLibrary import io.nekohasekai.sfa.compose.util.icons.MaterialIconsLibrary
data class ProfileIcon( data class ProfileIcon(val id: String, val icon: ImageVector, val label: String)
val id: String,
val icon: ImageVector,
val label: String,
)
object ProfileIcons { object ProfileIcons {
// Use the complete Material Icons library with all available icons // Use the complete Material Icons library with all available icons
@@ -26,13 +22,9 @@ object ProfileIcons {
return Icons.AutoMirrored.Default.InsertDriveFile return Icons.AutoMirrored.Default.InsertDriveFile
} }
fun getCategoryForIcon(iconId: String): String? { fun getCategoryForIcon(iconId: String): String? = MaterialIconsLibrary.getCategoryForIcon(iconId)
return MaterialIconsLibrary.getCategoryForIcon(iconId)
}
fun searchIcons(query: String): List<ProfileIcon> { fun searchIcons(query: String): List<ProfileIcon> = MaterialIconsLibrary.searchIcons(query)
return MaterialIconsLibrary.searchIcons(query)
}
fun getCategories() = MaterialIconsLibrary.categories fun getCategories() = MaterialIconsLibrary.categories
} }

Some files were not shown because too many files have changed in this diff Show More