Compare commits
1 Commits
v0.8.88-pr
...
v0.8.88-pr
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b987c2376a |
@@ -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
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
17
android/service/src/main/res/drawable/ic_service.xml
Normal file
17
android/service/src/main/res/drawable/ic_service.xml
Normal 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>
|
||||
@@ -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
190
lib/common/cache.dart
Normal 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',
|
||||
};
|
||||
@@ -1,5 +1,6 @@
|
||||
export 'android.dart';
|
||||
export 'app_localizations.dart';
|
||||
export 'cache.dart';
|
||||
export 'color.dart';
|
||||
export 'compute.dart';
|
||||
export 'constant.dart';
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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';
|
||||
@@ -73,10 +74,9 @@ class AppController {
|
||||
|
||||
Future<void> restartCore() async {
|
||||
await coreController.shutdown();
|
||||
_ref.read(coreStatusProvider.notifier).value = CoreStatus.connecting;
|
||||
await coreController.preload();
|
||||
await _connectCore();
|
||||
await _initCore();
|
||||
_ref.read(coreStatusProvider.notifier).value = CoreStatus.connected;
|
||||
_ref.read(initProvider.notifier).value = true;
|
||||
if (_ref.read(isStartProvider)) {
|
||||
await globalState.handleStart();
|
||||
}
|
||||
@@ -534,11 +534,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,9 +544,28 @@ 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();
|
||||
|
||||
@@ -29,7 +29,7 @@ class CoreController {
|
||||
return _instance!;
|
||||
}
|
||||
|
||||
Future<bool> preload() {
|
||||
Future<String> preload() {
|
||||
return _interface.preload();
|
||||
}
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ abstract mixin class CoreEventListener {
|
||||
|
||||
void onLoaded(String providerName) {}
|
||||
|
||||
void onCrash() {}
|
||||
void onCrash(String message) {}
|
||||
}
|
||||
|
||||
class CoreEventManager {
|
||||
@@ -36,7 +36,7 @@ class CoreEventManager {
|
||||
listener.onLoaded(event.data);
|
||||
break;
|
||||
case CoreEventType.crash:
|
||||
listener.onCrash();
|
||||
listener.onCrash(event.data);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -73,7 +73,9 @@ class CoreService extends CoreHandlerInterface {
|
||||
}
|
||||
|
||||
void _handleInvokeCrashEvent() {
|
||||
coreEventManager.sendEvent(CoreEvent(type: CoreEventType.crash));
|
||||
coreEventManager.sendEvent(
|
||||
CoreEvent(type: CoreEventType.crash, data: 'socket done'),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> start() async {
|
||||
@@ -149,10 +151,10 @@ class CoreService extends CoreHandlerInterface {
|
||||
}
|
||||
|
||||
@override
|
||||
Future<bool> preload() async {
|
||||
Future<String> preload() async {
|
||||
await _serverCompleter.future;
|
||||
await start();
|
||||
return true;
|
||||
return '';
|
||||
}
|
||||
|
||||
@override
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -48,9 +48,11 @@ class _AndroidContainerState extends ConsumerState<AndroidManager>
|
||||
}
|
||||
|
||||
@override
|
||||
void onServiceCrash() {
|
||||
coreEventManager.sendEvent(CoreEvent(type: CoreEventType.crash));
|
||||
super.onServiceCrash();
|
||||
void onServiceCrash(String message) {
|
||||
coreEventManager.sendEvent(
|
||||
CoreEvent(type: CoreEventType.crash, data: message),
|
||||
);
|
||||
super.onServiceCrash(message);
|
||||
}
|
||||
|
||||
@override
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -92,13 +92,13 @@ class _CoreContainerState extends ConsumerState<CoreManager>
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> onCrash() async {
|
||||
Future<void> onCrash(String message) async {
|
||||
context.showNotifier(message);
|
||||
if (ref.read(coreStatusProvider) != CoreStatus.connected) {
|
||||
return;
|
||||
}
|
||||
context.showNotifier('Core crash');
|
||||
ref.read(coreStatusProvider.notifier).value = CoreStatus.disconnected;
|
||||
await coreController.shutdown();
|
||||
super.onCrash();
|
||||
super.onCrash(message);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ import 'package:flutter/services.dart';
|
||||
abstract mixin class ServiceListener {
|
||||
void onServiceEvent(CoreEvent event) {}
|
||||
|
||||
void onServiceCrash() {}
|
||||
void onServiceCrash(String message) {}
|
||||
}
|
||||
|
||||
class Service {
|
||||
@@ -43,8 +43,9 @@ class Service {
|
||||
}
|
||||
break;
|
||||
case 'crash':
|
||||
final message = call.arguments as String? ?? '';
|
||||
for (final listener in _listeners) {
|
||||
listener.onServiceCrash();
|
||||
listener.onServiceCrash(message);
|
||||
}
|
||||
break;
|
||||
default:
|
||||
@@ -76,16 +77,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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -63,64 +63,64 @@ class _DashboardViewState extends ConsumerState<DashboardView> {
|
||||
Consumer(
|
||||
builder: (_, ref, _) {
|
||||
final coreStatus = ref.watch(coreStatusProvider);
|
||||
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,
|
||||
),
|
||||
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,
|
||||
},
|
||||
),
|
||||
CoreStatus.connected => Icon(
|
||||
Icons.check_sharp,
|
||||
fontWeight: FontWeight.w900,
|
||||
key: ValueKey(CoreStatus.connected),
|
||||
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.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,
|
||||
}),
|
||||
),
|
||||
label: Text(switch (coreStatus) {
|
||||
CoreStatus.connecting => appLocalizations.connecting,
|
||||
CoreStatus.connected => appLocalizations.connected,
|
||||
CoreStatus.disconnected =>
|
||||
appLocalizations.disconnected,
|
||||
}),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
|
||||
@@ -28,6 +28,7 @@ class _ProxiesListViewState extends State<ProxiesListView> {
|
||||
null,
|
||||
);
|
||||
List<double> _headerOffset = [];
|
||||
double containerHeight = 0;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -80,6 +81,7 @@ 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);
|
||||
@@ -191,31 +193,93 @@ 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];
|
||||
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,
|
||||
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 proxies = currentGroups.getGroup(groupName)?.all;
|
||||
_jumpTo(
|
||||
currentInitOffset +
|
||||
8 +
|
||||
getScrollToSelectedOffset(
|
||||
groupName: groupName,
|
||||
proxies: proxies ?? [],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
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
|
||||
@@ -262,6 +326,7 @@ class _ProxiesListViewState extends State<ProxiesListView> {
|
||||
),
|
||||
LayoutBuilder(
|
||||
builder: (_, container) {
|
||||
containerHeight = container.maxHeight;
|
||||
return ValueListenableBuilder(
|
||||
valueListenable: _headerStateNotifier,
|
||||
builder: (_, headerState, _) {
|
||||
@@ -502,21 +567,29 @@ class _ListHeaderState extends State<ListHeader> {
|
||||
children: [
|
||||
if (isExpand) ...[
|
||||
IconButton(
|
||||
visualDensity: VisualDensity.standard,
|
||||
visualDensity: VisualDensity.compact,
|
||||
padding: EdgeInsets.all(2),
|
||||
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: 6),
|
||||
const SizedBox(width: 8),
|
||||
] else
|
||||
SizedBox(width: 4),
|
||||
SizedBox(width: 6),
|
||||
IconButton.filledTonal(
|
||||
visualDensity: VisualDensity.compact,
|
||||
padding: EdgeInsets.all(4),
|
||||
iconSize: 22,
|
||||
onPressed: () {
|
||||
_handleChange(groupName);
|
||||
},
|
||||
|
||||
@@ -76,13 +76,13 @@ class FadeRotationScaleBox extends StatelessWidget {
|
||||
Widget build(BuildContext context) {
|
||||
return AnimatedSwitcher(
|
||||
duration: commonDuration,
|
||||
switchInCurve: Curves.easeOut,
|
||||
switchOutCurve: Curves.easeIn,
|
||||
switchInCurve: Curves.easeOutBack,
|
||||
switchOutCurve: Curves.easeInBack,
|
||||
transitionBuilder: (child, animation) {
|
||||
return RotationTransition(
|
||||
turns: animation.drive(Tween(begin: 0.8, end: 1.0)),
|
||||
child: FadeTransition(
|
||||
opacity: animation.drive(Tween(begin: 0.4, end: 1.0)),
|
||||
opacity: animation.drive(Tween(begin: 0.6, end: 1.0)),
|
||||
child: ScaleTransition(scale: animation, child: child),
|
||||
),
|
||||
);
|
||||
@@ -100,13 +100,18 @@ class FadeScaleBox extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AnimatedSwitcher(
|
||||
duration: commonDuration,
|
||||
switchOutCurve: Curves.easeOutBack,
|
||||
switchInCurve: Curves.easeInBack,
|
||||
transitionBuilder: (child, animation) {
|
||||
return Container(
|
||||
alignment: Alignment.bottomRight,
|
||||
child: FadeScaleTransition(animation: animation, child: child),
|
||||
return FadeTransition(
|
||||
opacity: animation,
|
||||
child: ScaleTransition(
|
||||
scale: animation.drive(Tween(begin: 0.4, end: 1.0)),
|
||||
child: child,
|
||||
),
|
||||
);
|
||||
},
|
||||
duration: Duration(milliseconds: 300),
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@ 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';
|
||||
@@ -337,11 +336,7 @@ class CommonScaffoldState extends State<CommonScaffold> {
|
||||
ValueListenableBuilder<Widget?>(
|
||||
valueListenable: _floatingActionButton,
|
||||
builder: (_, value, _) {
|
||||
return IntrinsicWidth(
|
||||
child: IntrinsicHeight(
|
||||
child: FadeScaleBox(child: value ?? SizedBox()),
|
||||
),
|
||||
);
|
||||
return value ?? SizedBox();
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user