Compare commits

..

1 Commits

Author SHA1 Message Date
chen08209
5756fba7e8 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 16:05:25 +08:00
45 changed files with 473 additions and 819 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" />
@@ -109,6 +118,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

@@ -25,16 +25,16 @@ suspend fun PackageManager.getPackageIconPath(packageName: String): String =
withContext(Dispatchers.IO) {
val cacheDir = GlobalState.application.cacheDir
val iconDir = File(cacheDir, "icons").apply { mkdirs() }
val pkgInfo = getPackageInfo(packageName, 0)
val lastUpdateTime = pkgInfo.lastUpdateTime
val iconFile = File(iconDir, "${packageName}_${lastUpdateTime}.webp")
if (iconFile.exists() && !isExpired(iconFile)) {
return@withContext iconFile.absolutePath
}
iconDir.listFiles()?.forEach { file ->
if (file.name.startsWith(packageName + "_")) file.delete()
}
return@withContext try {
val pkgInfo = getPackageInfo(packageName, 0)
val lastUpdateTime = pkgInfo.lastUpdateTime
val iconFile = File(iconDir, "${packageName}_${lastUpdateTime}.webp")
if (iconFile.exists() && !isExpired(iconFile)) {
return@withContext iconFile.absolutePath
}
iconDir.listFiles()?.forEach { file ->
if (file.name.startsWith(packageName + "_")) file.delete()
}
val icon = getApplicationIcon(packageName)
saveDrawableToFile(icon, iconFile)
iconFile.absolutePath
@@ -99,12 +99,15 @@ inline fun <reified T : FlutterPlugin> FlutterEngine.plugin(): T? {
fun <T> MethodChannel.invokeMethodOnMainThread(
method: String, arguments: Any? = null, callback: ((Result<T>) -> Unit)? = null
method: String,
arguments: Any? = null,
callback: ((Result<T>) -> Unit)? = null
) {
Handler(Looper.getMainLooper()).post {
invokeMethod(method, arguments, object : MethodChannel.Result {
override fun success(result: Any?) {
@Suppress("UNCHECKED_CAST") callback?.invoke(Result.success(result as T))
@Suppress("UNCHECKED_CAST")
callback?.invoke(Result.success(result as T))
}
override fun error(errorCode: String, errorMessage: String?, errorDetails: Any?) {

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

@@ -11,7 +11,10 @@ JNIEXPORT void JNICALL
Java_com_follow_clash_core_Core_startTun(JNIEnv *env, jobject thiz, jint fd, jobject cb,
jstring stack, jstring address, jstring dns) {
const auto interface = new_global(cb);
startTUN(interface, fd, get_string(stack), get_string(address), get_string(dns));
scoped_string stackChar = get_string(stack);
scoped_string addressChar = get_string(address);
scoped_string dnsChar = get_string(dns);
startTUN(interface, fd, stackChar, addressChar, dnsChar);
}
extern "C"
@@ -29,14 +32,16 @@ Java_com_follow_clash_core_Core_forceGC(JNIEnv *env, jobject thiz) {
extern "C"
JNIEXPORT void JNICALL
Java_com_follow_clash_core_Core_updateDNS(JNIEnv *env, jobject thiz, jstring dns) {
updateDns(get_string(dns));
scoped_string dnsChar = get_string(dns);
updateDns(dnsChar);
}
extern "C"
JNIEXPORT void JNICALL
Java_com_follow_clash_core_Core_invokeAction(JNIEnv *env, jobject thiz, jstring data, jobject cb) {
const auto interface = new_global(cb);
invokeAction(interface, get_string(data));
scoped_string dataChar = get_string(data);
invokeAction(interface, dataChar);
}
extern "C"
@@ -50,14 +55,16 @@ extern "C"
JNIEXPORT jstring JNICALL
Java_com_follow_clash_core_Core_getTraffic(JNIEnv *env, jobject thiz,
const jboolean only_statistics_proxy) {
return new_string(getTraffic(only_statistics_proxy));
scoped_string res = getTraffic(only_statistics_proxy);
return new_string(res);
}
extern "C"
JNIEXPORT jstring JNICALL
Java_com_follow_clash_core_Core_getTotalTraffic(JNIEnv *env, jobject thiz,
const jboolean only_statistics_proxy) {
return new_string(getTotalTraffic(only_statistics_proxy));
scoped_string res = getTotalTraffic(only_statistics_proxy);
return new_string(res);
}
extern "C"
@@ -77,10 +84,6 @@ static void release_jni_object_impl(void *obj) {
del_global(static_cast<jobject>(obj));
}
static void free_string_impl(char *str) {
free(str);
}
static void call_tun_interface_protect_impl(void *tun_interface, const int fd) {
ATTACH_JNI();
env->CallVoidMethod(static_cast<jobject>(tun_interface),
@@ -95,13 +98,14 @@ call_tun_interface_resolve_process_impl(void *tun_interface, const int protocol,
const int uid) {
ATTACH_JNI();
const auto packageName = reinterpret_cast<jstring>(env->CallObjectMethod(
static_cast<jobject>(tun_interface),
m_tun_interface_resolve_process,
protocol,
new_string(source),
new_string(target),
uid));
return get_string(packageName);
static_cast<jobject>(tun_interface),
m_tun_interface_resolve_process,
protocol,
new_string(source),
new_string(target),
uid));
scoped_string packageNameChar = get_string(packageName);
return packageNameChar;
}
static void call_invoke_interface_result_impl(void *invoke_interface, const char *data) {
@@ -136,7 +140,6 @@ JNI_OnLoad(JavaVM *vm, void *) {
resolve_process_func = &call_tun_interface_resolve_process_impl;
result_func = &call_invoke_interface_result_impl;
release_object_func = &release_jni_object_impl;
free_string_func = &free_string_impl;
return JNI_VERSION_1_6;
}

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

@@ -2,8 +2,6 @@
void (*release_object_func)(void *obj);
void (*free_string_func)(char *data);
void (*protect_func)(void *tun_interface, int fd);
char* (*resolve_process_func)(void *tun_interface,int protocol, const char *source, const char *target, int uid);
@@ -22,10 +20,6 @@ void release_object(void *obj) {
release_object_func(obj);
}
void free_string(char *data) {
free_string_func(data);
}
void result(void *invoke_Interface, const char *data) {
return result_func(invoke_Interface, data);
}

View File

@@ -16,7 +16,7 @@ func resolveProcess(callback unsafe.Pointer, protocol int, source, target string
t := C.CString(target)
defer C.free(unsafe.Pointer(t))
res := C.resolve_process(callback, C.int(protocol), s, t, C.int(uid))
return takeCString(res)
return parseCString(res)
}
func invokeResult(callback unsafe.Pointer, data string) {
@@ -29,7 +29,7 @@ func releaseObject(callback unsafe.Pointer) {
C.release_object(callback)
}
func takeCString(s *C.char) string {
defer C.free_string(s)
func parseCString(s *C.char) string {
//defer C.free(unsafe.Pointer(s))
return C.GoString(s)
}

View File

@@ -4,8 +4,6 @@
extern void (*release_object_func)(void *obj);
extern void (*free_string_func)(char *data);
extern void (*protect_func)(void *tun_interface, int fd);
extern char* (*resolve_process_func)(void *tun_interface, int protocol, const char *source, const char *target, int uid);
@@ -18,6 +16,4 @@ extern char* resolve_process(void *tun_interface, int protocol, const char *sour
extern void release_object(void *obj);
extern void free_string(char *data);
extern void result(void *invoke_Interface, const char *data);

View File

@@ -17,6 +17,7 @@ import (
"github.com/metacubex/mihomo/constant/features"
cp "github.com/metacubex/mihomo/constant/provider"
"github.com/metacubex/mihomo/hub"
"github.com/metacubex/mihomo/hub/executor"
"github.com/metacubex/mihomo/hub/route"
"github.com/metacubex/mihomo/listener"
"github.com/metacubex/mihomo/log"
@@ -235,30 +236,12 @@ func updateConfig(params *UpdateParams) {
updateListeners()
}
func parseWithPath(path string) (*config.Config, error) {
buf, err := readFile(path)
if err != nil {
return nil, err
}
rawConfig := config.DefaultRawConfig()
err = UnmarshalJson(buf, rawConfig)
if err != nil {
return nil, err
}
parseRawConfig, err := config.ParseRawConfig(rawConfig)
if err != nil {
return nil, err
}
return parseRawConfig, nil
}
func setupConfig(params *SetupParams) error {
runLock.Lock()
defer runLock.Unlock()
var err error
constant.DefaultTestURL = params.TestURL
currentConfig, err = parseWithPath(filepath.Join(constant.Path.HomeDir(), "config.json"))
currentConfig, err = executor.ParseWithPath(filepath.Join(constant.Path.HomeDir(), "config.yaml"))
if err != nil {
currentConfig, _ = config.ParseRawConfig(config.DefaultRawConfig())
}

View File

@@ -181,7 +181,7 @@ func nextHandle(action *Action, result ActionResult) bool {
//export invokeAction
func invokeAction(callback unsafe.Pointer, paramsChar *C.char) {
params := takeCString(paramsChar)
params := parseCString(paramsChar)
var action = &Action{}
err := json.Unmarshal([]byte(params), action)
if err != nil {
@@ -198,7 +198,7 @@ func invokeAction(callback unsafe.Pointer, paramsChar *C.char) {
//export startTUN
func startTUN(callback unsafe.Pointer, fd C.int, stackChar, addressChar, dnsChar *C.char) bool {
handleStartTun(callback, int(fd), takeCString(stackChar), takeCString(addressChar), takeCString(dnsChar))
handleStartTun(callback, int(fd), parseCString(stackChar), parseCString(addressChar), parseCString(dnsChar))
return true
}
@@ -212,16 +212,12 @@ func setMessageCallback(callback unsafe.Pointer) {
//export getTotalTraffic
func getTotalTraffic(onlyStatisticsProxy bool) *C.char {
data := C.CString(handleGetTotalTraffic(onlyStatisticsProxy))
defer C.free(unsafe.Pointer(data))
return data
return C.CString(handleGetTotalTraffic(onlyStatisticsProxy))
}
//export getTraffic
func getTraffic(onlyStatisticsProxy bool) *C.char {
data := C.CString(handleGetTraffic(onlyStatisticsProxy))
defer C.free(unsafe.Pointer(data))
return data
return C.CString(handleGetTraffic(onlyStatisticsProxy))
}
func sendMessage(message Message) {
@@ -253,5 +249,5 @@ func forceGC() {
//export updateDns
func updateDns(s *C.char) {
handleUpdateDns(takeCString(s))
handleUpdateDns(parseCString(s))
}

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

@@ -68,7 +68,7 @@ class AppPath {
Future<String> get configFilePath async {
final homeDirPath = await appPath.homeDirPath;
return join(homeDirPath, 'config.json');
return join(homeDirPath, 'config.yaml');
}
Future<String> get sharedPreferencesPath async {

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),
@@ -107,6 +107,7 @@ class Request {
handleFailRes();
})
.catchError((e) {
print(e);
failureCount++;
if (e is DioException && e.type == DioExceptionType.cancel) {
completer.complete(Result.error('cancelled'));
@@ -122,7 +123,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 +140,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 +159,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

@@ -20,6 +20,7 @@ import 'package:material_color_utilities/palettes/core_palette.dart';
import 'package:package_info_plus/package_info_plus.dart';
import 'package:path/path.dart';
import 'package:url_launcher/url_launcher.dart';
import 'package:yaml_writer/yaml_writer.dart';
import 'common/common.dart';
import 'controller.dart';
@@ -79,6 +80,7 @@ class GlobalState {
);
await _initDynamicColor();
await init();
appState = appState.copyWith(coreStatus: CoreStatus.connected);
await window?.init(version);
_shakingStore();
}
@@ -132,6 +134,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;
@@ -299,12 +303,12 @@ class GlobalState {
final config = await patchRawConfig(patchConfig: pathConfig);
final res = await Isolate.run<String>(() async {
try {
final res = json.encode(config);
final data = YamlWriter().write(config);
final file = File(configFilePath);
if (!await file.exists()) {
await file.create(recursive: true);
}
await file.writeAsString(res);
await file.writeAsString(data);
return '';
} catch (e) {
return e.toString();
@@ -438,7 +442,7 @@ class GlobalState {
entry.value.splitByMultipleSeparators;
}
}
List rules = [];
var rules = [];
if (rawConfig['rules'] != null) {
rules = rawConfig['rules'];
}
@@ -452,7 +456,7 @@ class GlobalState {
rules = [...overrideData.runningRule, ...rules];
}
}
rawConfig['rule'] = rules;
rawConfig['rules'] = rules;
return rawConfig;
}

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

@@ -108,56 +108,56 @@ class OutboundModeV2 extends StatelessWidget {
Mode.global => globalState.theme.darken3PrimaryContainer,
Mode.direct => context.colorScheme.tertiaryContainer,
};
return Container(
constraints: BoxConstraints.expand(),
padding: EdgeInsets.all(4.ap),
color: thumbColor.opacity38,
child: Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(10),
color: context.colorScheme.surfaceContainer,
),
padding: EdgeInsets.all(12.ap),
child: LayoutBuilder(
builder: (_, constraints) {
return CommonTabBar<Mode>(
children: Map.fromEntries(
Mode.values.map(
(item) => MapEntry(
item,
Container(
clipBehavior: Clip.antiAlias,
alignment: Alignment.center,
decoration: BoxDecoration(),
height: constraints.maxHeight,
padding: EdgeInsets.all(4),
child: Text(
Intl.message(item.name),
style: Theme.of(context).textTheme.titleSmall
?.adjustSize(1)
.copyWith(
color: item == mode
? _getTextColor(context, item)
: null,
),
return Column(
children: [
Expanded(
child: Container(
constraints: BoxConstraints.expand(),
padding: EdgeInsets.all(12),
child: LayoutBuilder(
builder: (_, constraints) {
return CommonTabBar<Mode>(
children: Map.fromEntries(
Mode.values.map(
(item) => MapEntry(
item,
Container(
clipBehavior: Clip.antiAlias,
alignment: Alignment.center,
decoration: BoxDecoration(),
height: constraints.maxHeight,
child: Text(
Intl.message(item.name),
style: Theme.of(context)
.textTheme
.titleSmall
?.adjustSize(1)
.copyWith(
color: item == mode
? _getTextColor(context, item)
: null,
),
),
),
),
),
),
),
),
padding: EdgeInsets.symmetric(horizontal: 0),
groupValue: mode,
onValueChanged: (value) {
if (value == null) {
return;
}
globalState.appController.changeMode(value);
padding: EdgeInsets.symmetric(horizontal: 0),
groupValue: mode,
onValueChanged: (value) {
if (value == null) {
return;
}
globalState.appController.changeMode(value);
},
thumbColor: thumbColor,
);
},
thumbColor: thumbColor,
);
},
),
),
),
),
Container(color: thumbColor, height: 10.ap),
],
);
},
),

View File

@@ -39,9 +39,6 @@ class ProxyCard extends StatelessWidget {
getDelayProvider(proxyName: proxy.name, testUrl: testUrl),
);
return FadeThroughBox(
alignment: type == ProxyCardType.expand
? Alignment.centerLeft
: Alignment.centerRight,
child: delay == 0 || delay == null
? SizedBox(
height: measure.labelSmallHeight,

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

View File

@@ -1690,6 +1690,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "3.1.3"
yaml_writer:
dependency: "direct main"
description:
name: yaml_writer
sha256: "69651cd7238411179ac32079937d4aa9a2970150d6b2ae2c6fe6de09402a5dc5"
url: "https://pub.dev"
source: hosted
version: "2.1.0"
sdks:
dart: ">=3.9.0 <4.0.0"
flutter: ">=3.29.0"

View File

@@ -1,7 +1,7 @@
name: fl_clash
description: A multi-platform proxy client based on ClashMeta, simple and easy to use, open-source and ad-free.
publish_to: 'none'
version: 0.8.88+2025083102
version: 0.8.88+2025083101
environment:
sdk: '>=3.8.0 <4.0.0'
@@ -63,6 +63,7 @@ dependencies:
flutter_cache_manager: ^3.4.1
crypto: ^3.0.3
flutter_acrylic: ^1.1.4
yaml_writer: ^2.1.0
dev_dependencies:
flutter_test:
sdk: flutter