Compare commits

..

1 Commits

Author SHA1 Message Date
chen08209
f06abecb3e 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-09-03 10:08:26 +08:00
28 changed files with 540 additions and 285 deletions

View File

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

View File

@@ -2,10 +2,6 @@
<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" />
@@ -24,28 +20,23 @@
<uses-permission
android:name="android.permission.QUERY_ALL_PACKAGES"
tools:ignore="QueryAllPackagesPermission" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
<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="com.follow.clash.MainActivity"
android:name=".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" />
@@ -73,10 +64,6 @@
</intent-filter>
</activity>
<meta-data
android:name="io.flutter.embedding.android.EnableImpeller"
android:value="false" />
<activity
android:name=".TempActivity"
android:excludeFromRecents="true"
@@ -122,29 +109,6 @@
</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

@@ -8,38 +8,32 @@ 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, ::handleOnServiceCrash
RemoteService::class.intent, ::handleServiceDisconnected
) {
IRemoteInterface.Stub.asInterface(it)
}
}
var onServiceCrash: (() -> Unit)? = null
var onServiceDisconnected: ((String) -> Unit)? = null
private fun handleOnServiceCrash() {
bindingState.set(false)
onServiceCrash?.let {
it()
private fun handleServiceDisconnected(message: String) {
onServiceDisconnected?.let {
it(message)
}
}
private val bindingState = AtomicBoolean(false)
fun bind() {
if (bindingState.compareAndSet(false, true)) {
delegate.bind()
}
delegate.bind()
}
suspend fun invokeAction(
data: String, cb: (result: ByteArray?, isSuccess: Boolean) -> Unit
) {
delegate.useService {
): Result<Unit> {
return delegate.useService {
it.invokeAction(data, object : ICallbackInterface.Stub() {
override fun onResult(result: ByteArray?, isSuccess: Boolean) {
cb(result, isSuccess)
@@ -50,16 +44,16 @@ object Service {
suspend fun updateNotificationParams(
params: NotificationParams
) {
delegate.useService {
): Result<Unit> {
return delegate.useService {
it.updateNotificationParams(params)
}
}
suspend fun setMessageCallback(
cb: (result: String?) -> Unit
) {
delegate.useService {
): Result<Unit> {
return 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 onServiceCrash() {
private fun onServiceDisconnected(message: String) {
State.runStateFlow.tryEmit(RunState.STOP)
flutterMethodChannel.invokeMethodOnMainThread<Any>("crash", null)
flutterMethodChannel.invokeMethodOnMainThread<Any>("crash", message)
}
private fun handleSyncState(call: MethodCall, result: MethodChannel.Result) {
@@ -119,8 +119,11 @@ class ServicePlugin : FlutterPlugin, MethodChannel.MethodCallHandler,
stopText = params.stopText,
onlyStatisticsProxy = params.onlyStatisticsProxy
)
)
result.success(true)
).onSuccess {
result.success("")
}.onFailure {
result.success(it.message)
}
}
}
@@ -129,10 +132,14 @@ class ServicePlugin : FlutterPlugin, MethodChannel.MethodCallHandler,
launch {
Service.setMessageCallback {
handleSendEvent(it)
}.onSuccess {
result.success("")
}.onFailure {
result.success(it.message)
}
result.success(true)
}
Service.onServiceCrash = ::onServiceCrash
Service.onServiceDisconnected = ::onServiceDisconnected
}
private fun handleGetRunTime(result: MethodChannel.Result) {

View File

@@ -1,5 +1,9 @@
<?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,14 +13,20 @@ 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_DATA_SYNC
import android.content.pm.ServiceInfo.FOREGROUND_SERVICE_TYPE_SPECIAL_USE
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
@@ -37,19 +43,23 @@ 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_DATA_SYNC)
startForeground(id, notification, FOREGROUND_SERVICE_TYPE_SPECIAL_USE)
} 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() = Intent().apply {
setComponent(Components.TEMP_ACTIVITY)
setPackage(GlobalState.packageName)
get() = Components.TEMP_ACTIVITY.intent.apply {
action = this@quickIntent.action
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_MULTIPLE_TASK)
}
@@ -58,9 +68,7 @@ val BroadcastAction.action: String
get() = "${GlobalState.application.packageName}.intent.action.${this.name}"
val BroadcastAction.quickIntent: Intent
get() = Intent().apply {
setComponent(Components.BROADCAST_RECEIVER)
setPackage(GlobalState.packageName)
get() = Components.BROADCAST_RECEIVER.intent.apply {
action = this@quickIntent.action
}
@@ -126,62 +134,54 @@ 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,
): Flow<BindServiceEvent<T>> = callbackFlow {
var currentBinder: IBinder? = null
val deathRecipient = IBinder.DeathRecipient {
trySend(BindServiceEvent.Crashed)
}
maxRetries: Int = 5,
retryDelayMillis: Long = 200L
): Flow<Pair<IBinder?, String>> = callbackFlow {
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(BindServiceEvent.Connected(casted))
trySend(Pair(casted, ""))
} else {
GlobalState.log("Binder is not of type ${T::class.java}")
trySend(BindServiceEvent.Disconnected)
trySend(Pair(null, "Binder is not of type ${T::class.java}"))
}
} catch (e: RemoteException) {
GlobalState.log("Failed to link to death: ${e.message}")
binder.unlinkToDeath(deathRecipient, 0)
trySend(BindServiceEvent.Disconnected)
trySend(Pair(null, "Failed to link to death: ${e.message}"))
}
} else {
trySend(BindServiceEvent.Disconnected)
trySend(Pair(null, "Binder empty"))
}
}
override fun onServiceDisconnected(name: ComponentName?) {
GlobalState.log("Service disconnected")
currentBinder?.unlinkToDeath(deathRecipient, 0)
currentBinder = null
trySend(BindServiceEvent.Disconnected)
trySend(Pair(null, "Service disconnected"))
}
}
if (!bindService(intent, connection, flags)) {
GlobalState.log("Failed to bind service")
trySend(BindServiceEvent.Disconnected)
close()
return@callbackFlow
val success = withContext(Dispatchers.Main) {
bindService(intent, connection, flags)
}
if (!success) {
throw IllegalStateException("bindService() failed, will retry")
}
awaitClose {
currentBinder?.unlinkToDeath(deathRecipient, 0)
unbindService(connection)
Handler(Looper.getMainLooper()).post {
unbindService(connection)
}
}
}.retryWhen { cause, attempt ->
if (attempt < maxRetries && cause is Exception) {
delay(retryDelayMillis)
true
} else {
false
}
}
@@ -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
this.forEach { byteArray ->
forEach { byteArray ->
byteArray.copyInto(combined, offset)
offset += byteArray.size
}

View File

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

View File

@@ -5,11 +5,11 @@
<application>
<service
android:name="com.follow.clash.service.VpnService"
android:name=".VpnService"
android:exported="false"
android:foregroundServiceType="dataSync"
android:foregroundServiceType="specialUse"
android:permission="android.permission.BIND_VPN_SERVICE"
android:process=":background">
android:process=":remote">
<intent-filter>
<action android:name="android.net.VpnService" />
</intent-filter>
@@ -19,18 +19,31 @@
</service>
<service
android:name="com.follow.clash.service.CommonService"
android:name=".CommonService"
android:exported="false"
android:foregroundServiceType="dataSync"
android:process=":background">
android:foregroundServiceType="specialUse"
android:process=":remote">
<property
android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
android:value="service" />
</service>
<service
android:name="com.follow.clash.service.RemoteService"
android:name=".RemoteService"
android:enabled="true"
android:exported="false"
android:process=":background" />
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>
</application>
</manifest>

View File

@@ -1,35 +1,33 @@
package com.follow.clash
package com.follow.clash.service
import android.database.Cursor
import android.database.MatrixCursor
import android.os.CancellationSignal
import android.os.ParcelFileDescriptor
import android.provider.DocumentsContract.Document
import android.provider.DocumentsContract.Root
import android.provider.DocumentsContract
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(
Document.COLUMN_DOCUMENT_ID,
Document.COLUMN_DISPLAY_NAME,
Document.COLUMN_MIME_TYPE,
Document.COLUMN_FLAGS,
Document.COLUMN_SIZE,
DocumentsContract.Document.COLUMN_DOCUMENT_ID,
DocumentsContract.Document.COLUMN_DISPLAY_NAME,
DocumentsContract.Document.COLUMN_MIME_TYPE,
DocumentsContract.Document.COLUMN_FLAGS,
DocumentsContract.Document.COLUMN_SIZE,
)
private val DEFAULT_ROOT_COLUMNS = arrayOf(
Root.COLUMN_ROOT_ID,
Root.COLUMN_FLAGS,
Root.COLUMN_ICON,
Root.COLUMN_TITLE,
Root.COLUMN_SUMMARY,
Root.COLUMN_DOCUMENT_ID
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
)
}
@@ -40,12 +38,12 @@ class FilesProvider : DocumentsProvider() {
override fun queryRoots(projection: Array<String>?): Cursor {
return MatrixCursor(projection ?: DEFAULT_ROOT_COLUMNS).apply {
newRow().apply {
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, "/")
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, "/")
}
}
}
@@ -87,20 +85,20 @@ class FilesProvider : DocumentsProvider() {
private fun includeFile(result: MatrixCursor, file: File) {
result.newRow().apply {
add(Document.COLUMN_DOCUMENT_ID, file.absolutePath)
add(Document.COLUMN_DISPLAY_NAME, file.name)
add(Document.COLUMN_SIZE, file.length())
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_FLAGS,
Document.FLAG_SUPPORTS_WRITE or Document.FLAG_SUPPORTS_DELETE
DocumentsContract.Document.COLUMN_FLAGS,
DocumentsContract.Document.FLAG_SUPPORTS_WRITE or DocumentsContract.Document.FLAG_SUPPORTS_DELETE
)
add(Document.COLUMN_MIME_TYPE, getDocumentType(file))
add(DocumentsContract.Document.COLUMN_MIME_TYPE, getDocumentType(file))
}
}
private fun getDocumentType(file: File): String {
return if (file.isDirectory) {
Document.MIME_TYPE_DIR
DocumentsContract.Document.MIME_TYPE_DIR
} else {
"application/octet-stream"
}

View File

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

View File

@@ -0,0 +1,17 @@
<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 _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(
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(
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!,
);
},
child: const HomePage(),
),
),
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(),
);
}

190
lib/common/cache.dart Normal file
View File

@@ -0,0 +1,190 @@
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,5 +1,6 @@
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,6 +8,7 @@ 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';
@@ -77,6 +78,7 @@ class AppController {
await coreController.preload();
await _initCore();
_ref.read(coreStatusProvider.notifier).value = CoreStatus.connected;
_ref.read(initProvider.notifier).value = true;
if (_ref.read(isStartProvider)) {
await globalState.handleStart();
}
@@ -534,11 +536,9 @@ 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 {
@@ -546,6 +546,18 @@ class AppController {
}
await _handlePreference();
await _handlerDisclaimer();
final message = await coreController.preload();
if (message.isNotEmpty) {
_ref.read(coreStatusProvider.notifier).value = CoreStatus.disconnected;
if (context.mounted) {
context.showNotifier(message);
}
return;
}
_ref.read(coreStatusProvider.notifier).value = CoreStatus.connected;
await service?.syncAndroidState(globalState.getAndroidState());
await _initCore();
await _initStatus();
_ref.read(initProvider.notifier).value = true;
}

View File

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

View File

@@ -9,7 +9,7 @@ import 'package:fl_clash/models/models.dart';
mixin CoreInterface {
Future<bool> init(InitParams params);
Future<bool> preload();
Future<String> preload();
Future<bool> shutdown();
@@ -306,6 +306,7 @@ 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,10 +15,12 @@ class CoreLib extends CoreHandlerInterface {
CoreLib._internal();
@override
Future<bool> preload() async {
await service?.init();
_connectedCompleter.complete(true);
return true;
Future<String> preload() async {
final res = await service?.init();
if (res?.isEmpty == true) {
_connectedCompleter.complete(true);
}
return res ?? '';
}
factory CoreLib() {

View File

@@ -149,10 +149,10 @@ class CoreService extends CoreHandlerInterface {
}
@override
Future<bool> preload() async {
Future<String> preload() async {
await _serverCompleter.future;
await start();
return true;
return '';
}
@override

View File

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

View File

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

View File

@@ -96,7 +96,6 @@ class _CoreContainerState extends ConsumerState<CoreManager>
if (ref.read(coreStatusProvider) != CoreStatus.connected) {
return;
}
context.showNotifier('Core crash');
ref.read(coreStatusProvider.notifier).value = CoreStatus.disconnected;
await coreController.shutdown();
super.onCrash();

View File

@@ -76,16 +76,16 @@ class Service {
return await methodChannel.invokeMethod<bool>('stop') ?? false;
}
Future<bool> syncAndroidState(AndroidState state) async {
return await methodChannel.invokeMethod<bool>(
Future<String> syncAndroidState(AndroidState state) async {
return await methodChannel.invokeMethod<String>(
'syncState',
json.encode(state),
) ??
false;
'';
}
Future<bool> init() async {
return await methodChannel.invokeMethod<bool>('init') ?? false;
Future<String> init() async {
return await methodChannel.invokeMethod<String>('init') ?? '';
}
Future<DateTime?> getRunTime() async {

View File

@@ -79,7 +79,6 @@ class GlobalState {
);
await _initDynamicColor();
await init();
appState = appState.copyWith(coreStatus: CoreStatus.connected);
await window?.init(version);
_shakingStore();
}
@@ -133,8 +132,6 @@ 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

@@ -83,6 +83,7 @@ class _ProxiesListViewState extends State<ProxiesListView> {
final tempUnfoldSet = Set<String>.from(currentUnfoldSet);
if (tempUnfoldSet.contains(groupName)) {
tempUnfoldSet.remove(groupName);
_autoScrollToGroup(groupName);
} else {
tempUnfoldSet.add(groupName);
}
@@ -191,33 +192,46 @@ class _ProxiesListViewState extends State<ProxiesListView> {
);
}
void _scrollToGroupSelected(String groupName) {
double _getGroupOffset(String groupName) {
if (_controller.position.maxScrollExtent == 0) {
return;
return 0;
}
final appController = globalState.appController;
final currentGroups = appController.getCurrentGroups();
final currentGroups = globalState.appController.getCurrentGroups();
final findIndex = currentGroups.indexWhere(
(item) => item.name == groupName,
);
final index = findIndex != -1 ? findIndex : 0;
final currentInitOffset = _headerOffset[index];
return _headerOffset[index];
}
void _autoScrollToGroup(String groupName) {
_controller.jumpTo(_getGroupOffset(groupName));
}
void _scrollToGroupSelected(String groupName) {
final currentInitOffset = _getGroupOffset(groupName);
final currentGroups = globalState.appController.getCurrentGroups();
final proxies = currentGroups.getGroup(groupName)?.all;
_controller.animateTo(
min(
currentInitOffset +
8 +
getScrollToSelectedOffset(
groupName: groupName,
proxies: proxies ?? [],
),
_controller.position.maxScrollExtent,
),
duration: const Duration(milliseconds: 300),
curve: Curves.easeIn,
_jumpTo(
currentInitOffset +
8 +
getScrollToSelectedOffset(
groupName: groupName,
proxies: proxies ?? [],
),
);
}
void _jumpTo(double offset) {
if (mounted && _controller.hasClients) {
_controller.animateTo(
min(offset, _controller.position.maxScrollExtent),
duration: const Duration(milliseconds: 300),
curve: Curves.easeIn,
);
}
}
@override
Widget build(BuildContext context) {
return Consumer(

View File

@@ -1,6 +1,7 @@
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 {
@@ -17,6 +18,7 @@ class CommonTargetIcon extends StatelessWidget {
if (src.isEmpty) {
return _defaultIcon();
}
final base64 = src.getBase64;
if (base64 != null) {
return Image.memory(
@@ -27,18 +29,8 @@ class CommonTargetIcon extends StatelessWidget {
},
);
}
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());
},
);
return ImageCacheWidget(src: src, defaultWidget: _defaultIcon());
}
@override
@@ -46,3 +38,46 @@ 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);
},
);
}
}