Compare commits

..

1 Commits

Author SHA1 Message Date
chen08209
e684beb3f3 Add android separates the core process
Support core status check and force restart

Optimize proxies page and access page

Update flutter and pub dependencies
2025-08-31 20:47:20 +08:00
33 changed files with 371 additions and 696 deletions

View File

@@ -29,7 +29,6 @@ android {
ndkVersion = libs.versions.ndkVersion.get()
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
@@ -54,12 +53,6 @@ android {
}
}
packaging {
jniLibs {
useLegacyPackaging = true
}
}
buildTypes {
debug {
isMinifyEnabled = false

View File

@@ -2,6 +2,10 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<permission
android:name="${applicationId}.permission.RECEIVE_BROADCASTS"
android:protectionLevel="signature" />
<uses-feature
android:name="android.hardware.touchscreen"
android:required="false" />
@@ -20,23 +24,28 @@
<uses-permission
android:name="android.permission.QUERY_ALL_PACKAGES"
tools:ignore="QueryAllPackagesPermission" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<application
android:name=".Application"
android:banner="@mipmap/ic_banner"
android:extractNativeLibs="true"
android:hardwareAccelerated="true"
android:icon="@mipmap/ic_launcher"
android:label="FlClash">
<activity
android:name=".MainActivity"
android:name="com.follow.clash.MainActivity"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:exported="true"
android:hardwareAccelerated="true"
android:launchMode="singleTop"
android:theme="@style/LaunchTheme"
android:windowSoftInputMode="adjustResize">
<!-- Specifies an Android theme to apply to this Activity as soon as
the Android process has started. This theme is visible to the user
while the Flutter UI initializes. After that, this theme continues
to determine the Window background behind the Flutter UI. -->
<meta-data
android:name="io.flutter.embedding.android.NormalTheme"
android:resource="@style/NormalTheme" />
@@ -64,6 +73,10 @@
</intent-filter>
</activity>
<meta-data
android:name="io.flutter.embedding.android.EnableImpeller"
android:value="false" />
<activity
android:name=".TempActivity"
android:excludeFromRecents="true"
@@ -109,6 +122,29 @@
</intent-filter>
</receiver>
<provider
android:name=".FilesProvider"
android:authorities="${applicationId}.files"
android:exported="true"
android:grantUriPermissions="true"
android:permission="android.permission.MANAGE_DOCUMENTS"
android:process=":background">
<intent-filter>
<action android:name="android.content.action.DOCUMENTS_PROVIDER" />
</intent-filter>
</provider>
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileProvider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
</provider>
<meta-data
android:name="flutterEmbedding"
android:value="2" />

View File

@@ -1,33 +1,35 @@
package com.follow.clash.service
package com.follow.clash
import android.database.Cursor
import android.database.MatrixCursor
import android.os.CancellationSignal
import android.os.ParcelFileDescriptor
import android.provider.DocumentsContract
import android.provider.DocumentsContract.Document
import android.provider.DocumentsContract.Root
import android.provider.DocumentsProvider
import java.io.File
import java.io.FileNotFoundException
class FilesProvider : DocumentsProvider() {
companion object {
private const val DEFAULT_ROOT_ID = "0"
private val DEFAULT_DOCUMENT_COLUMNS = arrayOf(
DocumentsContract.Document.COLUMN_DOCUMENT_ID,
DocumentsContract.Document.COLUMN_DISPLAY_NAME,
DocumentsContract.Document.COLUMN_MIME_TYPE,
DocumentsContract.Document.COLUMN_FLAGS,
DocumentsContract.Document.COLUMN_SIZE,
Document.COLUMN_DOCUMENT_ID,
Document.COLUMN_DISPLAY_NAME,
Document.COLUMN_MIME_TYPE,
Document.COLUMN_FLAGS,
Document.COLUMN_SIZE,
)
private val DEFAULT_ROOT_COLUMNS = arrayOf(
DocumentsContract.Root.COLUMN_ROOT_ID,
DocumentsContract.Root.COLUMN_FLAGS,
DocumentsContract.Root.COLUMN_ICON,
DocumentsContract.Root.COLUMN_TITLE,
DocumentsContract.Root.COLUMN_SUMMARY,
DocumentsContract.Root.COLUMN_DOCUMENT_ID
Root.COLUMN_ROOT_ID,
Root.COLUMN_FLAGS,
Root.COLUMN_ICON,
Root.COLUMN_TITLE,
Root.COLUMN_SUMMARY,
Root.COLUMN_DOCUMENT_ID
)
}
@@ -38,12 +40,12 @@ class FilesProvider : DocumentsProvider() {
override fun queryRoots(projection: Array<String>?): Cursor {
return MatrixCursor(projection ?: DEFAULT_ROOT_COLUMNS).apply {
newRow().apply {
add(DocumentsContract.Root.COLUMN_ROOT_ID, DEFAULT_ROOT_ID)
add(DocumentsContract.Root.COLUMN_FLAGS, DocumentsContract.Root.FLAG_LOCAL_ONLY)
add(DocumentsContract.Root.COLUMN_ICON, R.drawable.ic_service)
add(DocumentsContract.Root.COLUMN_TITLE, "FlClash")
add(DocumentsContract.Root.COLUMN_SUMMARY, "Data")
add(DocumentsContract.Root.COLUMN_DOCUMENT_ID, "/")
add(Root.COLUMN_ROOT_ID, DEFAULT_ROOT_ID)
add(Root.COLUMN_FLAGS, Root.FLAG_LOCAL_ONLY)
add(Root.COLUMN_ICON, R.mipmap.ic_launcher)
add(Root.COLUMN_TITLE, "FlClash")
add(Root.COLUMN_SUMMARY, "Data")
add(Root.COLUMN_DOCUMENT_ID, "/")
}
}
}
@@ -85,20 +87,20 @@ class FilesProvider : DocumentsProvider() {
private fun includeFile(result: MatrixCursor, file: File) {
result.newRow().apply {
add(DocumentsContract.Document.COLUMN_DOCUMENT_ID, file.absolutePath)
add(DocumentsContract.Document.COLUMN_DISPLAY_NAME, file.name)
add(DocumentsContract.Document.COLUMN_SIZE, file.length())
add(Document.COLUMN_DOCUMENT_ID, file.absolutePath)
add(Document.COLUMN_DISPLAY_NAME, file.name)
add(Document.COLUMN_SIZE, file.length())
add(
DocumentsContract.Document.COLUMN_FLAGS,
DocumentsContract.Document.FLAG_SUPPORTS_WRITE or DocumentsContract.Document.FLAG_SUPPORTS_DELETE
Document.COLUMN_FLAGS,
Document.FLAG_SUPPORTS_WRITE or Document.FLAG_SUPPORTS_DELETE
)
add(DocumentsContract.Document.COLUMN_MIME_TYPE, getDocumentType(file))
add(Document.COLUMN_MIME_TYPE, getDocumentType(file))
}
}
private fun getDocumentType(file: File): String {
return if (file.isDirectory) {
DocumentsContract.Document.MIME_TYPE_DIR
Document.MIME_TYPE_DIR
} else {
"application/octet-stream"
}

View File

@@ -8,32 +8,38 @@ import com.follow.clash.service.IRemoteInterface
import com.follow.clash.service.RemoteService
import com.follow.clash.service.models.NotificationParams
import com.follow.clash.service.models.VpnOptions
import java.util.concurrent.atomic.AtomicBoolean
object Service {
private val delegate by lazy {
ServiceDelegate<IRemoteInterface>(
RemoteService::class.intent, ::handleServiceDisconnected
RemoteService::class.intent, ::handleOnServiceCrash
) {
IRemoteInterface.Stub.asInterface(it)
}
}
var onServiceDisconnected: ((String) -> Unit)? = null
var onServiceCrash: (() -> Unit)? = null
private fun handleServiceDisconnected(message: String) {
onServiceDisconnected?.let {
it(message)
private fun handleOnServiceCrash() {
bindingState.set(false)
onServiceCrash?.let {
it()
}
}
private val bindingState = AtomicBoolean(false)
fun bind() {
delegate.bind()
if (bindingState.compareAndSet(false, true)) {
delegate.bind()
}
}
suspend fun invokeAction(
data: String, cb: (result: ByteArray?, isSuccess: Boolean) -> Unit
): Result<Unit> {
return delegate.useService {
) {
delegate.useService {
it.invokeAction(data, object : ICallbackInterface.Stub() {
override fun onResult(result: ByteArray?, isSuccess: Boolean) {
cb(result, isSuccess)
@@ -44,16 +50,16 @@ object Service {
suspend fun updateNotificationParams(
params: NotificationParams
): Result<Unit> {
return delegate.useService {
) {
delegate.useService {
it.updateNotificationParams(params)
}
}
suspend fun setMessageCallback(
cb: (result: String?) -> Unit
): Result<Unit> {
return delegate.useService {
) {
delegate.useService {
it.setMessageCallback(object : IMessageInterface.Stub() {
override fun onResult(result: String?) {
cb(result)

View File

@@ -119,9 +119,9 @@ object State {
return@launch
}
appPlugin?.prepare(options.enable) {
runTime = System.currentTimeMillis()
Service.startService(options, true)
runStateFlow.tryEmit(RunState.START)
runTime = System.currentTimeMillis()
}
}
}

View File

@@ -104,9 +104,9 @@ class ServicePlugin : FlutterPlugin, MethodChannel.MethodCallHandler,
}
}
private fun onServiceDisconnected(message: String) {
private fun onServiceCrash() {
State.runStateFlow.tryEmit(RunState.STOP)
flutterMethodChannel.invokeMethodOnMainThread<Any>("crash", message)
flutterMethodChannel.invokeMethodOnMainThread<Any>("crash", null)
}
private fun handleSyncState(call: MethodCall, result: MethodChannel.Result) {
@@ -119,11 +119,8 @@ class ServicePlugin : FlutterPlugin, MethodChannel.MethodCallHandler,
stopText = params.stopText,
onlyStatisticsProxy = params.onlyStatisticsProxy
)
).onSuccess {
result.success("")
}.onFailure {
result.success(it.message)
}
)
result.success(true)
}
}
@@ -132,14 +129,10 @@ class ServicePlugin : FlutterPlugin, MethodChannel.MethodCallHandler,
launch {
Service.setMessageCallback {
handleSendEvent(it)
}.onSuccess {
result.success("")
}.onFailure {
result.success(it.message)
}
result.success(true)
}
Service.onServiceDisconnected = ::onServiceDisconnected
Service.onServiceCrash = ::onServiceCrash
}
private fun handleGetRunTime(result: MethodChannel.Result) {

View File

@@ -1,9 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<permission
android:name="${applicationId}.permission.RECEIVE_BROADCASTS"
android:protectionLevel="signature" />
<uses-permission android:name="${applicationId}.permission.RECEIVE_BROADCASTS" />
</manifest>

View File

@@ -13,20 +13,14 @@ import android.content.Context.RECEIVER_NOT_EXPORTED
import android.content.Intent
import android.content.IntentFilter
import android.content.ServiceConnection
import android.content.pm.ServiceInfo.FOREGROUND_SERVICE_TYPE_SPECIAL_USE
import android.content.pm.ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC
import android.os.Build
import android.os.Handler
import android.os.IBinder
import android.os.Looper
import android.os.RemoteException
import android.util.Log
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.retryWhen
import kotlinx.coroutines.withContext
import java.nio.charset.Charset
import kotlin.reflect.KClass
@@ -43,23 +37,19 @@ val KClass<*>.intent: Intent
fun Service.startForegroundCompat(id: Int, notification: Notification) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
startForeground(id, notification, FOREGROUND_SERVICE_TYPE_SPECIAL_USE)
startForeground(id, notification, FOREGROUND_SERVICE_TYPE_DATA_SYNC)
} else {
startForeground(id, notification)
}
}
val ComponentName.intent: Intent
get() = Intent().apply {
setComponent(this@intent)
setPackage(GlobalState.packageName)
}
val QuickAction.action: String
get() = "${GlobalState.application.packageName}.action.${this.name}"
val QuickAction.quickIntent: Intent
get() = Components.TEMP_ACTIVITY.intent.apply {
get() = Intent().apply {
setComponent(Components.TEMP_ACTIVITY)
setPackage(GlobalState.packageName)
action = this@quickIntent.action
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_MULTIPLE_TASK)
}
@@ -68,7 +58,9 @@ val BroadcastAction.action: String
get() = "${GlobalState.application.packageName}.intent.action.${this.name}"
val BroadcastAction.quickIntent: Intent
get() = Components.BROADCAST_RECEIVER.intent.apply {
get() = Intent().apply {
setComponent(Components.BROADCAST_RECEIVER)
setPackage(GlobalState.packageName)
action = this@quickIntent.action
}
@@ -134,54 +126,62 @@ fun Context.receiveBroadcastFlow(
}
sealed class BindServiceEvent<out T : IBinder> {
data class Connected<T : IBinder>(val binder: T) : BindServiceEvent<T>()
object Disconnected : BindServiceEvent<Nothing>()
object Crashed : BindServiceEvent<Nothing>()
}
inline fun <reified T : IBinder> Context.bindServiceFlow(
intent: Intent,
flags: Int = Context.BIND_AUTO_CREATE,
maxRetries: Int = 5,
retryDelayMillis: Long = 200L
): Flow<Pair<IBinder?, String>> = callbackFlow {
): Flow<BindServiceEvent<T>> = callbackFlow {
var currentBinder: IBinder? = null
val deathRecipient = IBinder.DeathRecipient {
trySend(BindServiceEvent.Crashed)
}
val connection = object : ServiceConnection {
override fun onServiceConnected(name: ComponentName?, binder: IBinder?) {
if (binder != null) {
try {
binder.linkToDeath(deathRecipient, 0)
currentBinder = binder
@Suppress("UNCHECKED_CAST") val casted = binder as? T
if (casted != null) {
trySend(Pair(casted, ""))
trySend(BindServiceEvent.Connected(casted))
} else {
trySend(Pair(null, "Binder is not of type ${T::class.java}"))
GlobalState.log("Binder is not of type ${T::class.java}")
trySend(BindServiceEvent.Disconnected)
}
} catch (e: RemoteException) {
trySend(Pair(null, "Failed to link to death: ${e.message}"))
GlobalState.log("Failed to link to death: ${e.message}")
binder.unlinkToDeath(deathRecipient, 0)
trySend(BindServiceEvent.Disconnected)
}
} else {
trySend(Pair(null, "Binder empty"))
trySend(BindServiceEvent.Disconnected)
}
}
override fun onServiceDisconnected(name: ComponentName?) {
trySend(Pair(null, "Service disconnected"))
GlobalState.log("Service disconnected")
currentBinder?.unlinkToDeath(deathRecipient, 0)
currentBinder = null
trySend(BindServiceEvent.Disconnected)
}
}
val success = withContext(Dispatchers.Main) {
bindService(intent, connection, flags)
}
if (!success) {
throw IllegalStateException("bindService() failed, will retry")
if (!bindService(intent, connection, flags)) {
GlobalState.log("Failed to bind service")
trySend(BindServiceEvent.Disconnected)
close()
return@callbackFlow
}
awaitClose {
Handler(Looper.getMainLooper()).post {
unbindService(connection)
}
}
}.retryWhen { cause, attempt ->
if (attempt < maxRetries && cause is Exception) {
delay(retryDelayMillis)
true
} else {
false
currentBinder?.unlinkToDeath(deathRecipient, 0)
unbindService(connection)
}
}
@@ -230,7 +230,7 @@ fun <T : List<ByteArray>> T.formatString(charset: Charset = Charsets.UTF_8): Str
val totalSize = this.sumOf { it.size }
val combined = ByteArray(totalSize)
var offset = 0
forEach { byteArray ->
this.forEach { byteArray ->
byteArray.copyInto(combined, offset)
offset += byteArray.size
}

View File

@@ -6,67 +6,73 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.retryWhen
import kotlinx.coroutines.launch
import kotlinx.coroutines.withTimeout
import java.util.concurrent.atomic.AtomicBoolean
class ServiceDelegate<T>(
private val intent: Intent,
private val onServiceDisconnected: ((String) -> Unit)? = null,
private val onServiceDisconnected: (() -> Unit)? = null,
private val onServiceCrash: (() -> Unit)? = null,
private val interfaceCreator: (IBinder) -> T,
) : CoroutineScope by CoroutineScope(SupervisorJob() + Dispatchers.Default) {
private val _bindingState = AtomicBoolean(false)
private val _service = MutableStateFlow<T?>(null)
private var _serviceState = MutableStateFlow<Pair<T?, String>?>(null)
val service: StateFlow<T?> = _service
val serviceState: StateFlow<Pair<T?, String>?> = _serviceState
private var job: Job? = null
private var bindJob: Job? = null
private fun handleBindEvent(event: BindServiceEvent<IBinder>) {
when (event) {
is BindServiceEvent.Connected -> {
_service.value = event.binder.let(interfaceCreator)
}
private fun handleBind(data: Pair<IBinder?, String>) {
data.first?.let {
_serviceState.value = Pair(interfaceCreator(it), data.second)
} ?: run {
_serviceState.value = Pair(null, data.second)
unbind()
onServiceDisconnected?.invoke(data.second)
_bindingState.set(false)
is BindServiceEvent.Disconnected -> {
_service.value = null
onServiceDisconnected?.invoke()
}
is BindServiceEvent.Crashed -> {
_service.value = null
onServiceCrash?.invoke()
}
}
}
fun bind() {
if (_bindingState.compareAndSet(false, true)) {
job?.cancel()
job = null
_serviceState.value = null
job = launch {
GlobalState.application.bindServiceFlow<IBinder>(intent).collect { handleBind(it) }
unbind()
bindJob = launch {
GlobalState.application.bindServiceFlow<IBinder>(intent).collect { it ->
handleBindEvent(it)
}
}
}
suspend inline fun <R> useService(
timeoutMillis: Long = 5000, crossinline block: (T) -> R
retries: Int = 10,
delayMillis: Long = 200,
crossinline block: (T) -> R
): Result<R> {
return runCatching {
withTimeout(timeoutMillis) {
val state = serviceState.filterNotNull().first()
state.first?.let {
service.filterNotNull()
.retryWhen { _, attempt ->
(attempt < retries).also {
if (it) delay(delayMillis)
}
}.first().let {
block(it)
} ?: throw Exception(state.second)
}
}
}
}
fun unbind() {
if (_bindingState.compareAndSet(true, false)) {
job?.cancel()
job = null
_serviceState.value = null
}
_service.value = null
bindJob?.cancel()
bindJob = null
}
}

View File

@@ -5,11 +5,11 @@
<application>
<service
android:name=".VpnService"
android:name="com.follow.clash.service.VpnService"
android:exported="false"
android:foregroundServiceType="specialUse"
android:foregroundServiceType="dataSync"
android:permission="android.permission.BIND_VPN_SERVICE"
android:process=":remote">
android:process=":background">
<intent-filter>
<action android:name="android.net.VpnService" />
</intent-filter>
@@ -19,31 +19,18 @@
</service>
<service
android:name=".CommonService"
android:name="com.follow.clash.service.CommonService"
android:exported="false"
android:foregroundServiceType="specialUse"
android:process=":remote">
android:foregroundServiceType="dataSync"
android:process=":background">
<property
android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
android:value="service" />
</service>
<service
android:name=".RemoteService"
android:enabled="true"
android:name="com.follow.clash.service.RemoteService"
android:exported="false"
android:process=":remote" />
<provider
android:name=".FilesProvider"
android:authorities="${applicationId}.files"
android:exported="true"
android:grantUriPermissions="true"
android:permission="android.permission.MANAGE_DOCUMENTS"
android:process=":remote">
<intent-filter>
<action android:name="android.content.action.DOCUMENTS_PROVIDER" />
</intent-filter>
</provider>
android:process=":background" />
</application>
</manifest>

View File

@@ -28,11 +28,6 @@ class RemoteService : Service(),
}
}
private fun handleServiceDisconnected(message: String) {
intent = null
delegate = null
}
private fun handleStartService() {
launch {
val nextIntent = when (State.options?.enable == true) {
@@ -41,7 +36,7 @@ class RemoteService : Service(),
}
if (intent != nextIntent) {
delegate?.unbind()
delegate = ServiceDelegate(nextIntent, ::handleServiceDisconnected) { binder ->
delegate = ServiceDelegate(nextIntent) { binder ->
when (binder) {
is VpnService.LocalBinder -> binder.getService()
is CommonService.LocalBinder -> binder.getService()
@@ -57,7 +52,7 @@ class RemoteService : Service(),
}
}
private val binder = object : IRemoteInterface.Stub() {
private val binder: IRemoteInterface.Stub = object : IRemoteInterface.Stub() {
override fun invokeAction(data: String, callback: ICallbackInterface) {
Core.invokeAction(data) {
val chunks = it?.chunkedForAidl() ?: listOf()

View File

@@ -1,17 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:width="240dp"
android:height="240dp"
android:viewportWidth="240"
android:viewportHeight="240"
tools:ignore="VectorRaster">
<path
android:pathData="M48.1,80.89L168.44,11.41c11.08,-6.4 25.24,-2.6 31.64,8.48 0,0 0,0 0,0h0c6.4,11.08 2.6,25.24 -8.48,31.64 0,0 0,0 0,0l-120.34,69.48c-11.08,6.4 -25.24,2.6 -31.64,-8.48 0,0 0,0 0,0h0c-6.4,-11.08 -2.6,-25.24 8.48,-31.64 0,0 0,0 0,0Z"
android:fillColor="#6666FB"/>
<path
android:pathData="M78.98,134.37l60.18,-34.74c11.07,-6.39 25.23,-2.59 31.63,8.48h0c6.4,11.07 2.61,25.24 -8.47,31.64l-60.18,34.74c-11.08,6.4 -25.24,2.6 -31.64,-8.48 0,0 0,0 0,0h0c-6.4,-11.08 -2.6,-25.24 8.48,-31.64h0Z"
android:fillColor="#336AB6"/>
<path
android:pathData="M109.86,187.86h0c11.08,-6.4 25.24,-2.6 31.64,8.48 0,0 0,0 0,0h0c6.4,11.08 2.6,25.24 -8.48,31.64 0,0 0,0 0,0h0c-11.08,6.4 -25.24,2.6 -31.64,-8.48 0,0 0,0 0,0h0c-6.4,-11.08 -2.6,-25.24 8.48,-31.64 0,0 0,0 0,0Z"
android:fillColor="#5CA8E9"/>
</vector>

View File

@@ -107,57 +107,57 @@ class ApplicationState extends ConsumerState<Application> {
@override
Widget build(context) {
return Consumer(
builder: (_, ref, child) {
final locale = ref.watch(
appSettingProvider.select((state) => state.locale),
);
final themeProps = ref.watch(themeSettingProvider);
return MaterialApp(
debugShowCheckedModeBanner: false,
navigatorKey: globalState.navigatorKey,
localizationsDelegates: const [
AppLocalizations.delegate,
GlobalMaterialLocalizations.delegate,
GlobalCupertinoLocalizations.delegate,
GlobalWidgetsLocalizations.delegate,
],
builder: (_, child) {
return AppEnvManager(
child: _buildApp(
_buildPlatformState(
_buildState(
return _buildPlatformState(
_buildState(
Consumer(
builder: (_, ref, child) {
final locale = ref.watch(
appSettingProvider.select((state) => state.locale),
);
final themeProps = ref.watch(themeSettingProvider);
return MaterialApp(
debugShowCheckedModeBanner: false,
navigatorKey: globalState.navigatorKey,
localizationsDelegates: const [
AppLocalizations.delegate,
GlobalMaterialLocalizations.delegate,
GlobalCupertinoLocalizations.delegate,
GlobalWidgetsLocalizations.delegate,
],
builder: (_, child) {
return AppEnvManager(
child: _buildApp(
AppSidebarContainer(child: _buildPlatformApp(child!)),
),
);
},
scrollBehavior: BaseScrollBehavior(),
title: appName,
locale: utils.getLocaleForString(locale),
supportedLocales: AppLocalizations.delegate.supportedLocales,
themeMode: themeProps.themeMode,
theme: ThemeData(
useMaterial3: true,
pageTransitionsTheme: _pageTransitionsTheme,
colorScheme: _getAppColorScheme(
brightness: Brightness.light,
primaryColor: themeProps.primaryColor,
),
),
darkTheme: ThemeData(
useMaterial3: true,
pageTransitionsTheme: _pageTransitionsTheme,
colorScheme: _getAppColorScheme(
brightness: Brightness.dark,
primaryColor: themeProps.primaryColor,
).toPureBlack(themeProps.pureBlack),
),
home: child!,
);
},
scrollBehavior: BaseScrollBehavior(),
title: appName,
locale: utils.getLocaleForString(locale),
supportedLocales: AppLocalizations.delegate.supportedLocales,
themeMode: themeProps.themeMode,
theme: ThemeData(
useMaterial3: true,
pageTransitionsTheme: _pageTransitionsTheme,
colorScheme: _getAppColorScheme(
brightness: Brightness.light,
primaryColor: themeProps.primaryColor,
),
),
darkTheme: ThemeData(
useMaterial3: true,
pageTransitionsTheme: _pageTransitionsTheme,
colorScheme: _getAppColorScheme(
brightness: Brightness.dark,
primaryColor: themeProps.primaryColor,
).toPureBlack(themeProps.pureBlack),
),
home: child!,
);
},
child: const HomePage(),
child: const HomePage(),
),
),
);
}

View File

@@ -1,190 +0,0 @@
import 'dart:io';
import 'package:dio/dio.dart';
import 'package:fl_clash/common/common.dart';
import 'package:flutter_cache_manager/flutter_cache_manager.dart';
class LocalImageCacheManager extends CacheManager {
static const key = 'LocalImageCacheData';
static final LocalImageCacheManager _instance = LocalImageCacheManager._();
factory LocalImageCacheManager() {
return _instance;
}
LocalImageCacheManager._()
: super(
Config(
key,
stalePeriod: Duration(days: 30),
maxNrOfCacheObjects: 1000,
fileService: _LocalImageCacheFileService(),
),
);
}
class _LocalImageCacheFileService extends FileService {
_LocalImageCacheFileService();
@override
Future<FileServiceResponse> get(
String url, {
Map<String, String>? headers,
}) async {
final response = await request.dio.get<ResponseBody>(
url,
options: Options(headers: headers, responseType: ResponseType.stream),
);
return _LocalImageResponse(response);
}
}
class _LocalImageResponse implements FileServiceResponse {
_LocalImageResponse(this._response);
final DateTime _receivedTime = DateTime.now();
final Response<ResponseBody> _response;
@override
int get statusCode => _response.statusCode ?? 0;
@override
Stream<List<int>> get content =>
_response.data?.stream.transform(uint8ListToListIntConverter) ??
Stream.empty();
@override
int? get contentLength => _response.data?.contentLength;
@override
DateTime get validTill {
// Without a cache-control header we keep the file for a week
var ageDuration = const Duration(days: 7);
final controlHeader = _response.headers.value(
HttpHeaders.cacheControlHeader,
);
if (controlHeader != null) {
final controlSettings = controlHeader.split(',');
for (final setting in controlSettings) {
final sanitizedSetting = setting.trim().toLowerCase();
if (sanitizedSetting.startsWith('max-age=')) {
final validSeconds =
int.tryParse(sanitizedSetting.split('=')[1]) ?? 0;
if (validSeconds > 0) {
ageDuration = Duration(seconds: validSeconds);
}
}
}
}
if (ageDuration > const Duration(days: 7)) {
return _receivedTime.add(ageDuration);
}
return _receivedTime.add(const Duration(days: 7));
}
@override
String? get eTag => _response.headers.value(HttpHeaders.etagHeader);
@override
String get fileExtension {
var fileExtension = '';
final contentTypeHeader = _response.headers.value(
HttpHeaders.contentTypeHeader,
);
if (contentTypeHeader != null) {
final contentType = ContentType.parse(contentTypeHeader);
fileExtension = contentType.fileExtension;
}
return fileExtension;
}
}
extension ContentTypeConverter on ContentType {
String get fileExtension => mimeTypes[mimeType] ?? '.$subType';
}
const mimeTypes = {
'application/vnd.android.package-archive': '.apk',
'application/epub+zip': '.epub',
'application/gzip': '.gz',
'application/java-archive': '.jar',
'application/json': '.json',
'application/ld+json': '.jsonld',
'application/msword': '.doc',
'application/octet-stream': '.bin',
'application/ogg': '.ogx',
'application/pdf': '.pdf',
'application/php': '.php',
'application/rtf': '.rtf',
'application/vnd.amazon.ebook': '.azw',
'application/vnd.apple.installer+xml': '.mpkg',
'application/vnd.mozilla.xul+xml': '.xul',
'application/vnd.ms-excel': '.xls',
'application/vnd.ms-fontobject': '.eot',
'application/vnd.ms-powerpoint': '.ppt',
'application/vnd.oasis.opendocument.presentation': '.odp',
'application/vnd.oasis.opendocument.spreadsheet': '.ods',
'application/vnd.oasis.opendocument.text': '.odt',
'application/vnd.openxmlformats-officedocument.presentationml.presentation':
'.pptx',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': '.xlsx',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document':
'.docx',
'application/vnd.rar': '.rar',
'application/vnd.visio': '.vsd',
'application/x-7z-compressed': '.7z',
'application/x-abiword': '.abw',
'application/x-bzip': '.bz',
'application/x-bzip2': '.bz2',
'application/x-csh': '.csh',
'application/x-freearc': '.arc',
'application/x-sh': '.sh',
'application/x-shockwave-flash': '.swf',
'application/x-tar': '.tar',
'application/xhtml+xml': '.xhtml',
'application/xml': '.xml',
'application/zip': '.zip',
'audio/3gpp': '.3gp',
'audio/3gpp2': '.3g2',
'audio/aac': '.aac',
'audio/x-aac': '.aac',
'audio/midi': '.midi',
'audio/x-midi': '.midi',
'audio/x-m4a': '.m4a',
'audio/m4a': '.m4a',
'audio/mpeg': '.mp3',
'audio/ogg': '.oga',
'audio/opus': '.opus',
'audio/wav': '.wav',
'audio/x-wav': '.wav',
'audio/webm': '.weba',
'font/otf': '.otf',
'font/ttf': '.ttf',
'font/woff': '.woff',
'font/woff2': '.woff2',
'image/bmp': '.bmp',
'image/gif': '.gif',
'image/jpeg': '.jpg',
'image/png': '.png',
'image/svg+xml': '.svg',
'image/tiff': '.tiff',
'image/vnd.microsoft.icon': '.ico',
'image/webp': '.webp',
'text/calendar': '.ics',
'text/css': '.css',
'text/csv': '.csv',
'text/html': '.html',
'text/javascript': '.js',
'text/plain': '.txt',
'text/xml': '.xml',
'video/3gpp': '.3gp',
'video/3gpp2': '.3g2',
'video/mp2t': '.ts',
'video/mpeg': '.mpeg',
'video/ogg': '.ogv',
'video/webm': '.webm',
'video/x-msvideo': '.avi',
'video/quicktime': '.mov',
};

View File

@@ -1,6 +1,5 @@
export 'android.dart';
export 'app_localizations.dart';
export 'cache.dart';
export 'color.dart';
export 'compute.dart';
export 'constant.dart';

View File

@@ -11,12 +11,12 @@ import 'package:fl_clash/state.dart';
import 'package:flutter/cupertino.dart';
class Request {
late final Dio dio;
late final Dio _dio;
late final Dio _clashDio;
String? userAgent;
Request() {
dio = Dio(BaseOptions(headers: {'User-Agent': browserUa}));
_dio = Dio(BaseOptions(headers: {'User-Agent': browserUa}));
_clashDio = Dio();
_clashDio.httpClientAdapter = IOHttpClientAdapter(
createHttpClient: () {
@@ -48,7 +48,7 @@ class Request {
Future<MemoryImage?> getImage(String url) async {
if (url.isEmpty) return null;
final response = await dio.get<Uint8List>(
final response = await _dio.get<Uint8List>(
url,
options: Options(responseType: ResponseType.bytes),
);
@@ -58,7 +58,7 @@ class Request {
}
Future<Map<String, dynamic>?> checkForUpdate() async {
final response = await dio.get(
final response = await _dio.get(
'https://api.github.com/repos/$repository/releases/latest',
options: Options(responseType: ResponseType.json),
);
@@ -92,7 +92,7 @@ class Request {
}
}
final future = dio.get<Map<String, dynamic>>(
final future = _dio.get<Map<String, dynamic>>(
source.key,
cancelToken: cancelToken,
options: Options(responseType: ResponseType.json),
@@ -122,7 +122,7 @@ class Request {
Future<bool> pingHelper() async {
try {
final response = await dio
final response = await _dio
.get(
'http://$localhost:$helperPort/ping',
options: Options(responseType: ResponseType.plain),
@@ -139,7 +139,7 @@ class Request {
Future<bool> startCoreByHelper(String arg) async {
try {
final response = await dio
final response = await _dio
.post(
'http://$localhost:$helperPort/start',
data: json.encode({'path': appPath.corePath, 'arg': arg}),
@@ -158,7 +158,7 @@ class Request {
Future<bool> stopCoreByHelper() async {
try {
final response = await dio
final response = await _dio
.post(
'http://$localhost:$helperPort/stop',
options: Options(responseType: ResponseType.plain),

View File

@@ -8,7 +8,6 @@ import 'package:fl_clash/common/archive.dart';
import 'package:fl_clash/core/core.dart';
import 'package:fl_clash/enum/enum.dart';
import 'package:fl_clash/plugins/app.dart';
import 'package:fl_clash/plugins/service.dart';
import 'package:fl_clash/providers/providers.dart';
import 'package:fl_clash/state.dart';
import 'package:fl_clash/widgets/dialog.dart';
@@ -74,9 +73,10 @@ class AppController {
Future<void> restartCore() async {
await coreController.shutdown();
await _connectCore();
_ref.read(coreStatusProvider.notifier).value = CoreStatus.connecting;
await coreController.preload();
await _initCore();
_ref.read(initProvider.notifier).value = true;
_ref.read(coreStatusProvider.notifier).value = CoreStatus.connected;
if (_ref.read(isStartProvider)) {
await globalState.handleStart();
}
@@ -534,9 +534,11 @@ class AppController {
}
};
updateTray(true);
await _initCore();
await _initStatus();
autoLaunch?.updateStatus(_ref.read(appSettingProvider).autoLaunch);
autoUpdateProfiles();
autoCheckUpdate();
autoLaunch?.updateStatus(_ref.read(appSettingProvider).autoLaunch);
if (!_ref.read(appSettingProvider).silentLaunch) {
window?.show();
} else {
@@ -544,28 +546,9 @@ class AppController {
}
await _handlePreference();
await _handlerDisclaimer();
await _connectCore();
await service?.syncAndroidState(globalState.getAndroidState());
await _initCore();
await _initStatus();
_ref.read(initProvider.notifier).value = true;
}
Future<void> _connectCore() async {
_ref.read(coreStatusProvider.notifier).value = CoreStatus.connecting;
final message = await coreController.preload();
if (message.isNotEmpty) {
_ref.read(coreStatusProvider.notifier).value = CoreStatus.disconnected;
if (context.mounted) {
context.showNotifier(message);
}
return;
}
Future.delayed(const Duration(milliseconds: 600), () {
_ref.read(coreStatusProvider.notifier).value = CoreStatus.connected;
});
}
Future<void> _initStatus() async {
if (system.isAndroid) {
await globalState.updateStartTime();

View File

@@ -29,7 +29,7 @@ class CoreController {
return _instance!;
}
Future<String> preload() {
Future<bool> preload() {
return _interface.preload();
}

View File

@@ -13,7 +13,7 @@ abstract mixin class CoreEventListener {
void onLoaded(String providerName) {}
void onCrash(String message) {}
void onCrash() {}
}
class CoreEventManager {
@@ -36,7 +36,7 @@ class CoreEventManager {
listener.onLoaded(event.data);
break;
case CoreEventType.crash:
listener.onCrash(event.data);
listener.onCrash();
break;
}
}

View File

@@ -9,7 +9,7 @@ import 'package:fl_clash/models/models.dart';
mixin CoreInterface {
Future<bool> init(InitParams params);
Future<String> preload();
Future<bool> preload();
Future<bool> shutdown();
@@ -306,7 +306,6 @@ abstract class CoreHandlerInterface with CoreInterface {
return await _invoke<String>(
method: ActionMethod.asyncTestDelay,
data: json.encode(delayParams),
timeout: Duration(seconds: 6),
) ??
json.encode(Delay(name: proxyName, value: -1, url: url));
}

View File

@@ -15,12 +15,10 @@ class CoreLib extends CoreHandlerInterface {
CoreLib._internal();
@override
Future<String> preload() async {
final res = await service?.init();
if (res?.isEmpty == true) {
_connectedCompleter.complete(true);
}
return res ?? '';
Future<bool> preload() async {
await service?.init();
_connectedCompleter.complete(true);
return true;
}
factory CoreLib() {

View File

@@ -73,9 +73,7 @@ class CoreService extends CoreHandlerInterface {
}
void _handleInvokeCrashEvent() {
coreEventManager.sendEvent(
CoreEvent(type: CoreEventType.crash, data: 'socket done'),
);
coreEventManager.sendEvent(CoreEvent(type: CoreEventType.crash));
}
Future<void> start() async {
@@ -151,10 +149,10 @@ class CoreService extends CoreHandlerInterface {
}
@override
Future<String> preload() async {
Future<bool> preload() async {
await _serverCompleter.future;
await start();
return '';
return true;
}
@override

View File

@@ -24,8 +24,7 @@ Future<void> main() async {
Future<void> _service(List<String> flags) async {
WidgetsFlutterBinding.ensureInitialized();
await globalState.init();
await coreController.preload();
await service?.syncAndroidState(globalState.getAndroidState());
await service?.init();
tile?.addListener(
_TileListenerWithService(
onStop: () async {

View File

@@ -48,11 +48,9 @@ class _AndroidContainerState extends ConsumerState<AndroidManager>
}
@override
void onServiceCrash(String message) {
coreEventManager.sendEvent(
CoreEvent(type: CoreEventType.crash, data: message),
);
super.onServiceCrash(message);
void onServiceCrash() {
coreEventManager.sendEvent(CoreEvent(type: CoreEventType.crash));
super.onServiceCrash();
}
@override

View File

@@ -45,7 +45,7 @@ class _AppStateManagerState extends ConsumerState<AppStateManager>
});
ref.listenManual(needUpdateGroupsProvider, (prev, next) {
if (prev != next) {
globalState.appController.updateGroupsDebounce();
globalState.appController.updateGroupsDebounce(commonDuration);
}
});
if (window == null) {

View File

@@ -92,13 +92,13 @@ class _CoreContainerState extends ConsumerState<CoreManager>
}
@override
Future<void> onCrash(String message) async {
context.showNotifier(message);
Future<void> onCrash() async {
if (ref.read(coreStatusProvider) != CoreStatus.connected) {
return;
}
context.showNotifier('Core crash');
ref.read(coreStatusProvider.notifier).value = CoreStatus.disconnected;
await coreController.shutdown();
super.onCrash(message);
super.onCrash();
}
}

View File

@@ -13,7 +13,7 @@ import 'package:flutter/services.dart';
abstract mixin class ServiceListener {
void onServiceEvent(CoreEvent event) {}
void onServiceCrash(String message) {}
void onServiceCrash() {}
}
class Service {
@@ -43,9 +43,8 @@ class Service {
}
break;
case 'crash':
final message = call.arguments as String? ?? '';
for (final listener in _listeners) {
listener.onServiceCrash(message);
listener.onServiceCrash();
}
break;
default:
@@ -77,16 +76,16 @@ class Service {
return await methodChannel.invokeMethod<bool>('stop') ?? false;
}
Future<String> syncAndroidState(AndroidState state) async {
return await methodChannel.invokeMethod<String>(
Future<bool> syncAndroidState(AndroidState state) async {
return await methodChannel.invokeMethod<bool>(
'syncState',
json.encode(state),
) ??
'';
false;
}
Future<String> init() async {
return await methodChannel.invokeMethod<String>('init') ?? '';
Future<bool> init() async {
return await methodChannel.invokeMethod<bool>('init') ?? false;
}
Future<DateTime?> getRunTime() async {

View File

@@ -79,6 +79,7 @@ class GlobalState {
);
await _initDynamicColor();
await init();
appState = appState.copyWith(coreStatus: CoreStatus.connected);
await window?.init(version);
_shakingStore();
}
@@ -132,6 +133,8 @@ class GlobalState {
utils.getLocaleForString(config.appSetting.locale) ??
WidgetsBinding.instance.platformDispatcher.locale,
);
await coreController.preload();
await service?.syncAndroidState(globalState.getAndroidState());
}
String get ua => config.patchClashConfig.globalUa ?? packageInfo.ua;

View File

@@ -63,64 +63,64 @@ class _DashboardViewState extends ConsumerState<DashboardView> {
Consumer(
builder: (_, ref, _) {
final coreStatus = ref.watch(coreStatusProvider);
return FadeScaleBox(
child: coreStatus == CoreStatus.connected
? SizedBox()
: FilledButton.icon(
key: ValueKey(coreStatus),
onPressed: coreStatus == CoreStatus.connecting
? () {}
: _handleConnection,
style: FilledButton.styleFrom(
visualDensity: VisualDensity.compact,
padding: EdgeInsets.symmetric(horizontal: 12),
backgroundColor: switch (coreStatus) {
CoreStatus.connecting => null,
CoreStatus.connected => Colors.greenAccent,
CoreStatus.disconnected => context.colorScheme.error,
},
foregroundColor: switch (coreStatus) {
CoreStatus.connecting => null,
CoreStatus.connected => switch (Theme.brightnessOf(
context,
)) {
Brightness.light =>
context.colorScheme.onSurfaceVariant,
Brightness.dark => null,
},
CoreStatus.disconnected =>
context.colorScheme.onError,
},
return Tooltip(
message: appLocalizations.coreStatus,
child: FilledButton.icon(
onPressed: coreStatus == CoreStatus.connecting
? () {}
: _handleConnection,
style: FilledButton.styleFrom(
visualDensity: VisualDensity.compact,
padding: EdgeInsets.symmetric(horizontal: 12),
backgroundColor: switch (coreStatus) {
CoreStatus.connecting => null,
CoreStatus.connected => Colors.greenAccent,
CoreStatus.disconnected => context.colorScheme.error,
},
foregroundColor: switch (coreStatus) {
CoreStatus.connecting => null,
CoreStatus.connected => switch (Theme.brightnessOf(
context,
)) {
Brightness.light => context.colorScheme.onSurfaceVariant,
Brightness.dark => null,
},
CoreStatus.disconnected => context.colorScheme.onError,
},
),
icon: SizedBox(
height: globalState.measure.bodyMediumHeight,
width: globalState.measure.bodyMediumHeight,
child: FadeRotationScaleBox(
child: switch (coreStatus) {
CoreStatus.connecting => Padding(
padding: EdgeInsets.all(2),
child: CircularProgressIndicator(
key: ValueKey(CoreStatus.connecting),
strokeWidth: 3,
color: context.colorScheme.onPrimary,
backgroundColor: Colors.transparent,
),
),
icon: SizedBox(
height: globalState.measure.bodyMediumHeight,
width: globalState.measure.bodyMediumHeight,
child: switch (coreStatus) {
CoreStatus.connecting => Padding(
padding: EdgeInsets.all(2),
child: CircularProgressIndicator(
strokeWidth: 3,
color: context.colorScheme.onPrimary,
backgroundColor: Colors.transparent,
),
),
CoreStatus.connected => Icon(
Icons.check_sharp,
fontWeight: FontWeight.w900,
),
CoreStatus.disconnected => Icon(
Icons.restart_alt_sharp,
fontWeight: FontWeight.w900,
),
},
CoreStatus.connected => Icon(
Icons.check_sharp,
fontWeight: FontWeight.w900,
key: ValueKey(CoreStatus.connected),
),
label: Text(switch (coreStatus) {
CoreStatus.connecting => appLocalizations.connecting,
CoreStatus.connected => appLocalizations.connected,
CoreStatus.disconnected =>
appLocalizations.disconnected,
}),
),
CoreStatus.disconnected => Icon(
Icons.restart_alt_sharp,
fontWeight: FontWeight.w900,
key: ValueKey(CoreStatus.disconnected),
),
},
),
),
label: Text(switch (coreStatus) {
CoreStatus.connecting => appLocalizations.connecting,
CoreStatus.connected => appLocalizations.connected,
CoreStatus.disconnected => appLocalizations.disconnected,
}),
),
);
},
),

View File

@@ -28,7 +28,6 @@ class _ProxiesListViewState extends State<ProxiesListView> {
null,
);
List<double> _headerOffset = [];
double containerHeight = 0;
@override
void initState() {
@@ -81,7 +80,6 @@ class _ProxiesListViewState extends State<ProxiesListView> {
}
void _handleChange(Set<String> currentUnfoldSet, String groupName) {
_autoScrollToGroup(groupName);
final tempUnfoldSet = Set<String>.from(currentUnfoldSet);
if (tempUnfoldSet.contains(groupName)) {
tempUnfoldSet.remove(groupName);
@@ -193,95 +191,33 @@ class _ProxiesListViewState extends State<ProxiesListView> {
);
}
double _getGroupOffset(String groupName) {
void _scrollToGroupSelected(String groupName) {
if (_controller.position.maxScrollExtent == 0) {
return 0;
return;
}
final currentGroups = globalState.appController.getCurrentGroups();
final appController = globalState.appController;
final currentGroups = appController.getCurrentGroups();
final findIndex = currentGroups.indexWhere(
(item) => item.name == groupName,
);
final index = findIndex != -1 ? findIndex : 0;
return _headerOffset[index];
}
void _scrollToMakeVisibleWithPadding({
required double containerHeight,
required double pixels,
required double start,
required double end,
double padding = 24,
}) {
final visibleStart = pixels;
final visibleEnd = pixels + containerHeight;
final isElementVisible = start >= visibleStart && end <= visibleEnd;
if (isElementVisible) {
return;
}
double targetScrollOffset;
if (end <= visibleStart) {
targetScrollOffset = start;
} else if (start >= visibleEnd) {
targetScrollOffset = end - containerHeight + padding;
} else {
final visibleTopPart = end - visibleStart;
final visibleBottomPart = visibleEnd - start;
if (visibleTopPart.abs() >= visibleBottomPart.abs()) {
targetScrollOffset = end - containerHeight + padding;
} else {
targetScrollOffset = start;
}
}
targetScrollOffset = targetScrollOffset.clamp(
_controller.position.minScrollExtent,
_controller.position.maxScrollExtent,
);
_controller.jumpTo(targetScrollOffset);
}
void _autoScrollToGroup(String groupName) {
final pixels = _controller.position.pixels;
final offset = _getGroupOffset(groupName);
_scrollToMakeVisibleWithPadding(
containerHeight: containerHeight,
pixels: pixels,
start: offset,
end: offset + listHeaderHeight,
);
}
void _scrollToGroupSelected(String groupName) {
final currentInitOffset = _getGroupOffset(groupName);
final currentGroups = globalState.appController.getCurrentGroups();
final currentInitOffset = _headerOffset[index];
final proxies = currentGroups.getGroup(groupName)?.all;
_jumpTo(
currentInitOffset +
8 +
getScrollToSelectedOffset(
groupName: groupName,
proxies: proxies ?? [],
),
_controller.animateTo(
min(
currentInitOffset +
8 +
getScrollToSelectedOffset(
groupName: groupName,
proxies: proxies ?? [],
),
_controller.position.maxScrollExtent,
),
duration: const Duration(milliseconds: 300),
curve: Curves.easeIn,
);
}
void _jumpTo(double offset) {
if (mounted && _controller.hasClients) {
_controller.animateTo(
offset.clamp(
_controller.position.minScrollExtent,
_controller.position.maxScrollExtent,
),
duration: const Duration(milliseconds: 300),
curve: Curves.easeIn,
);
}
}
@override
Widget build(BuildContext context) {
return Consumer(
@@ -326,7 +262,6 @@ class _ProxiesListViewState extends State<ProxiesListView> {
),
LayoutBuilder(
builder: (_, container) {
containerHeight = container.maxHeight;
return ValueListenableBuilder(
valueListenable: _headerStateNotifier,
builder: (_, headerState, _) {
@@ -567,29 +502,21 @@ class _ListHeaderState extends State<ListHeader> {
children: [
if (isExpand) ...[
IconButton(
visualDensity: VisualDensity.compact,
padding: EdgeInsets.all(2),
visualDensity: VisualDensity.standard,
onPressed: () {
widget.onScrollToSelected(groupName);
},
iconSize: 19,
icon: const Icon(Icons.adjust),
),
const SizedBox(width: 4),
IconButton(
iconSize: 20,
visualDensity: VisualDensity.compact,
padding: EdgeInsets.all(2),
onPressed: _delayTest,
visualDensity: VisualDensity.standard,
icon: const Icon(Icons.network_ping),
),
const SizedBox(width: 8),
const SizedBox(width: 6),
] else
SizedBox(width: 6),
SizedBox(width: 4),
IconButton.filledTonal(
visualDensity: VisualDensity.compact,
padding: EdgeInsets.all(4),
iconSize: 22,
onPressed: () {
_handleChange(groupName);
},

View File

@@ -76,13 +76,13 @@ class FadeRotationScaleBox extends StatelessWidget {
Widget build(BuildContext context) {
return AnimatedSwitcher(
duration: commonDuration,
switchInCurve: Curves.easeOutBack,
switchOutCurve: Curves.easeInBack,
switchInCurve: Curves.easeOut,
switchOutCurve: Curves.easeIn,
transitionBuilder: (child, animation) {
return RotationTransition(
turns: animation.drive(Tween(begin: 0.8, end: 1.0)),
child: FadeTransition(
opacity: animation.drive(Tween(begin: 0.6, end: 1.0)),
opacity: animation.drive(Tween(begin: 0.4, end: 1.0)),
child: ScaleTransition(scale: animation, child: child),
),
);
@@ -100,18 +100,13 @@ class FadeScaleBox extends StatelessWidget {
@override
Widget build(BuildContext context) {
return AnimatedSwitcher(
duration: commonDuration,
switchOutCurve: Curves.easeOutBack,
switchInCurve: Curves.easeInBack,
transitionBuilder: (child, animation) {
return FadeTransition(
opacity: animation,
child: ScaleTransition(
scale: animation.drive(Tween(begin: 0.4, end: 1.0)),
child: child,
),
return Container(
alignment: Alignment.bottomRight,
child: FadeScaleTransition(animation: animation, child: child),
);
},
duration: Duration(milliseconds: 300),
child: child,
);
}

View File

@@ -1,7 +1,6 @@
import 'dart:io';
import 'package:fl_clash/common/common.dart';
import 'package:flutter/material.dart';
import 'package:flutter_cache_manager/flutter_cache_manager.dart';
import 'package:flutter_svg/svg.dart';
class CommonTargetIcon extends StatelessWidget {
@@ -18,7 +17,6 @@ class CommonTargetIcon extends StatelessWidget {
if (src.isEmpty) {
return _defaultIcon();
}
final base64 = src.getBase64;
if (base64 != null) {
return Image.memory(
@@ -29,8 +27,18 @@ class CommonTargetIcon extends StatelessWidget {
},
);
}
return ImageCacheWidget(src: src, defaultWidget: _defaultIcon());
return FutureBuilder(
future: DefaultCacheManager().getSingleFile(src),
builder: (_, snapshot) {
final data = snapshot.data;
if (data == null) {
return SizedBox();
}
return src.isSvg
? SvgPicture.file(data, errorBuilder: (_, _, _) => _defaultIcon())
: Image.file(data, errorBuilder: (_, _, _) => _defaultIcon());
},
);
}
@override
@@ -38,46 +46,3 @@ class CommonTargetIcon extends StatelessWidget {
return SizedBox(width: size, height: size, child: _buildIcon());
}
}
class ImageCacheWidget extends StatefulWidget {
final String src;
final Widget defaultWidget;
const ImageCacheWidget({
super.key,
required this.src,
required this.defaultWidget,
});
@override
State<ImageCacheWidget> createState() => _ImageCacheWidgetState();
}
class _ImageCacheWidgetState extends State<ImageCacheWidget> {
late Future<File> _imageFuture;
@override
void initState() {
super.initState();
_imageFuture = LocalImageCacheManager().getSingleFile(widget.src);
}
@override
Widget build(BuildContext context) {
return FutureBuilder<File>(
future: _imageFuture,
builder: (context, snapshot) {
final data = snapshot.data;
if (data == null) {
return SizedBox();
}
return widget.src.isSvg
? SvgPicture.file(
data,
errorBuilder: (_, _, _) => widget.defaultWidget,
)
: Image.file(data, errorBuilder: (_, _, _) => widget.defaultWidget);
},
);
}
}

View File

@@ -2,6 +2,7 @@ import 'package:fl_clash/common/common.dart';
import 'package:fl_clash/enum/enum.dart';
import 'package:fl_clash/models/models.dart';
import 'package:fl_clash/providers/app.dart';
import 'package:fl_clash/widgets/fade_box.dart';
import 'package:fl_clash/widgets/pop_scope.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
@@ -336,7 +337,11 @@ class CommonScaffoldState extends State<CommonScaffold> {
ValueListenableBuilder<Widget?>(
valueListenable: _floatingActionButton,
builder: (_, value, _) {
return value ?? SizedBox();
return IntrinsicWidth(
child: IntrinsicHeight(
child: FadeScaleBox(child: value ?? SizedBox()),
),
);
},
),
);