mirror of
https://github.com/immich-app/immich
synced 2025-10-17 18:19:27 +00:00
fix: local sync task never runs on iOS (#21491)
* fix: local sync task never runs on iOS * chore: rename ios register method * refactor from using dart callback to dart entrypoint + more logs * check if file exists before hashing * reschedule local sync task * chore: rename background worker logger * refactor: move file exists check inside repo --------- Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
This commit is contained in:
parent
4f7702c6bf
commit
674faf2e57
12 changed files with 218 additions and 152 deletions
|
|
@ -62,7 +62,7 @@ private open class BackgroundWorkerPigeonCodec : StandardMessageCodec() {
|
||||||
/** Generated interface from Pigeon that represents a handler of messages from Flutter. */
|
/** Generated interface from Pigeon that represents a handler of messages from Flutter. */
|
||||||
interface BackgroundWorkerFgHostApi {
|
interface BackgroundWorkerFgHostApi {
|
||||||
fun enableSyncWorker()
|
fun enableSyncWorker()
|
||||||
fun enableUploadWorker(callbackHandle: Long)
|
fun enableUploadWorker()
|
||||||
fun disableUploadWorker()
|
fun disableUploadWorker()
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
|
|
@ -93,11 +93,9 @@ interface BackgroundWorkerFgHostApi {
|
||||||
run {
|
run {
|
||||||
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.BackgroundWorkerFgHostApi.enableUploadWorker$separatedMessageChannelSuffix", codec)
|
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.BackgroundWorkerFgHostApi.enableUploadWorker$separatedMessageChannelSuffix", codec)
|
||||||
if (api != null) {
|
if (api != null) {
|
||||||
channel.setMessageHandler { message, reply ->
|
channel.setMessageHandler { _, reply ->
|
||||||
val args = message as List<Any?>
|
|
||||||
val callbackHandleArg = args[0] as Long
|
|
||||||
val wrapped: List<Any?> = try {
|
val wrapped: List<Any?> = try {
|
||||||
api.enableUploadWorker(callbackHandleArg)
|
api.enableUploadWorker()
|
||||||
listOf(null)
|
listOf(null)
|
||||||
} catch (exception: Throwable) {
|
} catch (exception: Throwable) {
|
||||||
BackgroundWorkerPigeonUtils.wrapError(exception)
|
BackgroundWorkerPigeonUtils.wrapError(exception)
|
||||||
|
|
@ -130,6 +128,7 @@ interface BackgroundWorkerFgHostApi {
|
||||||
/** Generated interface from Pigeon that represents a handler of messages from Flutter. */
|
/** Generated interface from Pigeon that represents a handler of messages from Flutter. */
|
||||||
interface BackgroundWorkerBgHostApi {
|
interface BackgroundWorkerBgHostApi {
|
||||||
fun onInitialized()
|
fun onInitialized()
|
||||||
|
fun close()
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
/** The codec used by BackgroundWorkerBgHostApi. */
|
/** The codec used by BackgroundWorkerBgHostApi. */
|
||||||
|
|
@ -156,6 +155,22 @@ interface BackgroundWorkerBgHostApi {
|
||||||
channel.setMessageHandler(null)
|
channel.setMessageHandler(null)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
run {
|
||||||
|
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.BackgroundWorkerBgHostApi.close$separatedMessageChannelSuffix", codec)
|
||||||
|
if (api != null) {
|
||||||
|
channel.setMessageHandler { _, reply ->
|
||||||
|
val wrapped: List<Any?> = try {
|
||||||
|
api.close()
|
||||||
|
listOf(null)
|
||||||
|
} catch (exception: Throwable) {
|
||||||
|
BackgroundWorkerPigeonUtils.wrapError(exception)
|
||||||
|
}
|
||||||
|
reply.reply(wrapped)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
channel.setMessageHandler(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -11,9 +11,8 @@ import com.google.common.util.concurrent.ListenableFuture
|
||||||
import com.google.common.util.concurrent.SettableFuture
|
import com.google.common.util.concurrent.SettableFuture
|
||||||
import io.flutter.FlutterInjector
|
import io.flutter.FlutterInjector
|
||||||
import io.flutter.embedding.engine.FlutterEngine
|
import io.flutter.embedding.engine.FlutterEngine
|
||||||
import io.flutter.embedding.engine.dart.DartExecutor.DartCallback
|
import io.flutter.embedding.engine.dart.DartExecutor
|
||||||
import io.flutter.embedding.engine.loader.FlutterLoader
|
import io.flutter.embedding.engine.loader.FlutterLoader
|
||||||
import io.flutter.view.FlutterCallbackInformation
|
|
||||||
|
|
||||||
private const val TAG = "BackgroundWorker"
|
private const val TAG = "BackgroundWorker"
|
||||||
|
|
||||||
|
|
@ -58,25 +57,6 @@ class BackgroundWorker(context: Context, params: WorkerParameters) :
|
||||||
loader.ensureInitializationCompleteAsync(ctx, null, Handler(Looper.getMainLooper())) {
|
loader.ensureInitializationCompleteAsync(ctx, null, Handler(Looper.getMainLooper())) {
|
||||||
engine = FlutterEngine(ctx)
|
engine = FlutterEngine(ctx)
|
||||||
|
|
||||||
// Retrieve the callback handle stored by the main Flutter app
|
|
||||||
// This handle points to the Flutter function that should be executed in the background
|
|
||||||
val callbackHandle =
|
|
||||||
ctx.getSharedPreferences(BackgroundWorkerApiImpl.SHARED_PREF_NAME, Context.MODE_PRIVATE)
|
|
||||||
.getLong(BackgroundWorkerApiImpl.SHARED_PREF_CALLBACK_HANDLE, 0L)
|
|
||||||
|
|
||||||
if (callbackHandle == 0L) {
|
|
||||||
// Without a valid callback handle, we cannot start the Flutter background execution
|
|
||||||
complete(Result.failure())
|
|
||||||
return@ensureInitializationCompleteAsync
|
|
||||||
}
|
|
||||||
|
|
||||||
// Start the Flutter engine with the specified callback as the entry point
|
|
||||||
val callback = FlutterCallbackInformation.lookupCallbackInformation(callbackHandle)
|
|
||||||
if (callback == null) {
|
|
||||||
complete(Result.failure())
|
|
||||||
return@ensureInitializationCompleteAsync
|
|
||||||
}
|
|
||||||
|
|
||||||
// Register custom plugins
|
// Register custom plugins
|
||||||
MainActivity.registerPlugins(ctx, engine!!)
|
MainActivity.registerPlugins(ctx, engine!!)
|
||||||
flutterApi =
|
flutterApi =
|
||||||
|
|
@ -86,8 +66,12 @@ class BackgroundWorker(context: Context, params: WorkerParameters) :
|
||||||
api = this
|
api = this
|
||||||
)
|
)
|
||||||
|
|
||||||
engine!!.dartExecutor.executeDartCallback(
|
engine!!.dartExecutor.executeDartEntrypoint(
|
||||||
DartCallback(ctx.assets, loader.findAppBundlePath(), callback)
|
DartExecutor.DartEntrypoint(
|
||||||
|
loader.findAppBundlePath(),
|
||||||
|
"package:immich_mobile/domain/services/background_worker.service.dart",
|
||||||
|
"backgroundSyncNativeEntrypoint"
|
||||||
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -109,14 +93,7 @@ class BackgroundWorker(context: Context, params: WorkerParameters) :
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
override fun close() {
|
||||||
* Called when the system has to stop this worker because constraints are
|
|
||||||
* no longer met or the system needs resources for more important tasks
|
|
||||||
* This is also called when the worker has been explicitly cancelled or replaced
|
|
||||||
*/
|
|
||||||
override fun onStopped() {
|
|
||||||
Log.d(TAG, "About to stop BackupWorker")
|
|
||||||
|
|
||||||
if (isComplete) {
|
if (isComplete) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -134,6 +111,16 @@ class BackgroundWorker(context: Context, params: WorkerParameters) :
|
||||||
}, 5000)
|
}, 5000)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Called when the system has to stop this worker because constraints are
|
||||||
|
* no longer met or the system needs resources for more important tasks
|
||||||
|
* This is also called when the worker has been explicitly cancelled or replaced
|
||||||
|
*/
|
||||||
|
override fun onStopped() {
|
||||||
|
Log.d(TAG, "About to stop BackupWorker")
|
||||||
|
close()
|
||||||
|
}
|
||||||
|
|
||||||
private fun handleHostResult(result: kotlin.Result<Unit>) {
|
private fun handleHostResult(result: kotlin.Result<Unit>) {
|
||||||
if (isComplete) {
|
if (isComplete) {
|
||||||
return
|
return
|
||||||
|
|
|
||||||
|
|
@ -21,9 +21,8 @@ class BackgroundWorkerApiImpl(context: Context) : BackgroundWorkerFgHostApi {
|
||||||
Log.i(TAG, "Scheduled media observer")
|
Log.i(TAG, "Scheduled media observer")
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun enableUploadWorker(callbackHandle: Long) {
|
override fun enableUploadWorker() {
|
||||||
updateUploadEnabled(ctx, true)
|
updateUploadEnabled(ctx, true)
|
||||||
updateCallbackHandle(ctx, callbackHandle)
|
|
||||||
Log.i(TAG, "Scheduled background upload tasks")
|
Log.i(TAG, "Scheduled background upload tasks")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -41,7 +40,6 @@ class BackgroundWorkerApiImpl(context: Context) : BackgroundWorkerFgHostApi {
|
||||||
|
|
||||||
const val SHARED_PREF_NAME = "Immich::Background"
|
const val SHARED_PREF_NAME = "Immich::Background"
|
||||||
const val SHARED_PREF_BACKUP_ENABLED = "Background::backup::enabled"
|
const val SHARED_PREF_BACKUP_ENABLED = "Background::backup::enabled"
|
||||||
const val SHARED_PREF_CALLBACK_HANDLE = "Background::backup::callbackHandle"
|
|
||||||
|
|
||||||
private fun updateUploadEnabled(context: Context, enabled: Boolean) {
|
private fun updateUploadEnabled(context: Context, enabled: Boolean) {
|
||||||
context.getSharedPreferences(SHARED_PREF_NAME, Context.MODE_PRIVATE).edit {
|
context.getSharedPreferences(SHARED_PREF_NAME, Context.MODE_PRIVATE).edit {
|
||||||
|
|
@ -49,12 +47,6 @@ class BackgroundWorkerApiImpl(context: Context) : BackgroundWorkerFgHostApi {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun updateCallbackHandle(context: Context, callbackHandle: Long) {
|
|
||||||
context.getSharedPreferences(SHARED_PREF_NAME, Context.MODE_PRIVATE).edit {
|
|
||||||
putLong(SHARED_PREF_CALLBACK_HANDLE, callbackHandle)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun enqueueMediaObserver(ctx: Context) {
|
fun enqueueMediaObserver(ctx: Context) {
|
||||||
val constraints = Constraints.Builder()
|
val constraints = Constraints.Builder()
|
||||||
.addContentUriTrigger(MediaStore.Images.Media.INTERNAL_CONTENT_URI, true)
|
.addContentUriTrigger(MediaStore.Images.Media.INTERNAL_CONTENT_URI, true)
|
||||||
|
|
|
||||||
|
|
@ -24,7 +24,7 @@ import UIKit
|
||||||
BackgroundServicePlugin.register(with: self.registrar(forPlugin: "BackgroundServicePlugin")!)
|
BackgroundServicePlugin.register(with: self.registrar(forPlugin: "BackgroundServicePlugin")!)
|
||||||
|
|
||||||
BackgroundServicePlugin.registerBackgroundProcessing()
|
BackgroundServicePlugin.registerBackgroundProcessing()
|
||||||
BackgroundWorkerApiImpl.registerBackgroundProcessing()
|
BackgroundWorkerApiImpl.registerBackgroundWorkers()
|
||||||
|
|
||||||
BackgroundServicePlugin.setPluginRegistrantCallback { registry in
|
BackgroundServicePlugin.setPluginRegistrantCallback { registry in
|
||||||
if !registry.hasPlugin("org.cocoapods.path-provider-foundation") {
|
if !registry.hasPlugin("org.cocoapods.path-provider-foundation") {
|
||||||
|
|
|
||||||
|
|
@ -74,7 +74,7 @@ class BackgroundWorkerPigeonCodec: FlutterStandardMessageCodec, @unchecked Senda
|
||||||
/// Generated protocol from Pigeon that represents a handler of messages from Flutter.
|
/// Generated protocol from Pigeon that represents a handler of messages from Flutter.
|
||||||
protocol BackgroundWorkerFgHostApi {
|
protocol BackgroundWorkerFgHostApi {
|
||||||
func enableSyncWorker() throws
|
func enableSyncWorker() throws
|
||||||
func enableUploadWorker(callbackHandle: Int64) throws
|
func enableUploadWorker() throws
|
||||||
func disableUploadWorker() throws
|
func disableUploadWorker() throws
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -99,11 +99,9 @@ class BackgroundWorkerFgHostApiSetup {
|
||||||
}
|
}
|
||||||
let enableUploadWorkerChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.BackgroundWorkerFgHostApi.enableUploadWorker\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
|
let enableUploadWorkerChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.BackgroundWorkerFgHostApi.enableUploadWorker\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
|
||||||
if let api = api {
|
if let api = api {
|
||||||
enableUploadWorkerChannel.setMessageHandler { message, reply in
|
enableUploadWorkerChannel.setMessageHandler { _, reply in
|
||||||
let args = message as! [Any?]
|
|
||||||
let callbackHandleArg = args[0] as! Int64
|
|
||||||
do {
|
do {
|
||||||
try api.enableUploadWorker(callbackHandle: callbackHandleArg)
|
try api.enableUploadWorker()
|
||||||
reply(wrapResult(nil))
|
reply(wrapResult(nil))
|
||||||
} catch {
|
} catch {
|
||||||
reply(wrapError(error))
|
reply(wrapError(error))
|
||||||
|
|
@ -130,6 +128,7 @@ class BackgroundWorkerFgHostApiSetup {
|
||||||
/// Generated protocol from Pigeon that represents a handler of messages from Flutter.
|
/// Generated protocol from Pigeon that represents a handler of messages from Flutter.
|
||||||
protocol BackgroundWorkerBgHostApi {
|
protocol BackgroundWorkerBgHostApi {
|
||||||
func onInitialized() throws
|
func onInitialized() throws
|
||||||
|
func close() throws
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Generated setup class from Pigeon to handle messages through the `binaryMessenger`.
|
/// Generated setup class from Pigeon to handle messages through the `binaryMessenger`.
|
||||||
|
|
@ -151,6 +150,19 @@ class BackgroundWorkerBgHostApiSetup {
|
||||||
} else {
|
} else {
|
||||||
onInitializedChannel.setMessageHandler(nil)
|
onInitializedChannel.setMessageHandler(nil)
|
||||||
}
|
}
|
||||||
|
let closeChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.BackgroundWorkerBgHostApi.close\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
|
||||||
|
if let api = api {
|
||||||
|
closeChannel.setMessageHandler { _, reply in
|
||||||
|
do {
|
||||||
|
try api.close()
|
||||||
|
reply(wrapResult(nil))
|
||||||
|
} catch {
|
||||||
|
reply(wrapError(error))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
closeChannel.setMessageHandler(nil)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
/// Generated protocol from Pigeon that represents Flutter messages that can be called from Swift.
|
/// Generated protocol from Pigeon that represents Flutter messages that can be called from Swift.
|
||||||
|
|
|
||||||
|
|
@ -86,28 +86,10 @@ class BackgroundWorker: BackgroundWorkerBgHostApi {
|
||||||
* starts the engine, and sets up a timeout timer if specified.
|
* starts the engine, and sets up a timeout timer if specified.
|
||||||
*/
|
*/
|
||||||
func run() {
|
func run() {
|
||||||
// Retrieve the callback handle stored by the main Flutter app
|
|
||||||
// This handle points to the Flutter function that should be executed in the background
|
|
||||||
let callbackHandle = Int64(UserDefaults.standard.string(
|
|
||||||
forKey: BackgroundWorkerApiImpl.backgroundUploadCallbackHandleKey) ?? "0") ?? 0
|
|
||||||
|
|
||||||
if callbackHandle == 0 {
|
|
||||||
// Without a valid callback handle, we cannot start the Flutter background execution
|
|
||||||
complete(success: false)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Use the callback handle to retrieve the actual Flutter callback information
|
|
||||||
guard let callback = FlutterCallbackCache.lookupCallbackInformation(callbackHandle) else {
|
|
||||||
// The callback handle is invalid or the callback was not found
|
|
||||||
complete(success: false)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Start the Flutter engine with the specified callback as the entry point
|
// Start the Flutter engine with the specified callback as the entry point
|
||||||
let isRunning = engine.run(
|
let isRunning = engine.run(
|
||||||
withEntrypoint: callback.callbackName,
|
withEntrypoint: "backgroundSyncNativeEntrypoint",
|
||||||
libraryURI: callback.callbackLibraryPath
|
libraryURI: "package:immich_mobile/domain/services/background_worker.service.dart"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Verify that the Flutter engine started successfully
|
// Verify that the Flutter engine started successfully
|
||||||
|
|
@ -127,7 +109,7 @@ class BackgroundWorker: BackgroundWorkerBgHostApi {
|
||||||
if maxSeconds != nil {
|
if maxSeconds != nil {
|
||||||
// Schedule a timer to cancel the task after the specified timeout period
|
// Schedule a timer to cancel the task after the specified timeout period
|
||||||
Timer.scheduledTimer(withTimeInterval: TimeInterval(maxSeconds!), repeats: false) { _ in
|
Timer.scheduledTimer(withTimeInterval: TimeInterval(maxSeconds!), repeats: false) { _ in
|
||||||
self.cancel()
|
self.close()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -156,7 +138,7 @@ class BackgroundWorker: BackgroundWorkerBgHostApi {
|
||||||
* Sends a cancel signal to the Flutter side and sets up a fallback timer to ensure
|
* Sends a cancel signal to the Flutter side and sets up a fallback timer to ensure
|
||||||
* the completion handler is eventually called even if Flutter doesn't respond.
|
* the completion handler is eventually called even if Flutter doesn't respond.
|
||||||
*/
|
*/
|
||||||
func cancel() {
|
func close() {
|
||||||
if isComplete {
|
if isComplete {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -182,7 +164,7 @@ class BackgroundWorker: BackgroundWorkerBgHostApi {
|
||||||
private func handleHostResult(result: Result<Void, PigeonError>) {
|
private func handleHostResult(result: Result<Void, PigeonError>) {
|
||||||
switch result {
|
switch result {
|
||||||
case .success(): self.complete(success: true)
|
case .success(): self.complete(success: true)
|
||||||
case .failure(_): self.cancel()
|
case .failure(_): self.close()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -6,10 +6,8 @@ class BackgroundWorkerApiImpl: BackgroundWorkerFgHostApi {
|
||||||
print("BackgroundUploadImpl:enableSyncWorker Local Sync worker scheduled")
|
print("BackgroundUploadImpl:enableSyncWorker Local Sync worker scheduled")
|
||||||
}
|
}
|
||||||
|
|
||||||
func enableUploadWorker(callbackHandle: Int64) throws {
|
func enableUploadWorker() throws {
|
||||||
BackgroundWorkerApiImpl.updateUploadEnabled(true)
|
BackgroundWorkerApiImpl.updateUploadEnabled(true)
|
||||||
// Store the callback handle for later use when starting background Flutter isolates
|
|
||||||
BackgroundWorkerApiImpl.updateUploadCallbackHandle(callbackHandle)
|
|
||||||
|
|
||||||
BackgroundWorkerApiImpl.scheduleRefreshUpload()
|
BackgroundWorkerApiImpl.scheduleRefreshUpload()
|
||||||
BackgroundWorkerApiImpl.scheduleProcessingUpload()
|
BackgroundWorkerApiImpl.scheduleProcessingUpload()
|
||||||
|
|
@ -23,7 +21,6 @@ class BackgroundWorkerApiImpl: BackgroundWorkerFgHostApi {
|
||||||
}
|
}
|
||||||
|
|
||||||
public static let backgroundUploadEnabledKey = "immich:background:backup:enabled"
|
public static let backgroundUploadEnabledKey = "immich:background:backup:enabled"
|
||||||
public static let backgroundUploadCallbackHandleKey = "immich:background:backup:callbackHandle"
|
|
||||||
|
|
||||||
private static let localSyncTaskID = "app.alextran.immich.background.localSync"
|
private static let localSyncTaskID = "app.alextran.immich.background.localSync"
|
||||||
private static let refreshUploadTaskID = "app.alextran.immich.background.refreshUpload"
|
private static let refreshUploadTaskID = "app.alextran.immich.background.refreshUpload"
|
||||||
|
|
@ -33,17 +30,13 @@ class BackgroundWorkerApiImpl: BackgroundWorkerFgHostApi {
|
||||||
return UserDefaults.standard.set(isEnabled, forKey: BackgroundWorkerApiImpl.backgroundUploadEnabledKey)
|
return UserDefaults.standard.set(isEnabled, forKey: BackgroundWorkerApiImpl.backgroundUploadEnabledKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func updateUploadCallbackHandle(_ callbackHandle: Int64) {
|
|
||||||
return UserDefaults.standard.set(String(callbackHandle), forKey: BackgroundWorkerApiImpl.backgroundUploadCallbackHandleKey)
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func cancelUploadTasks() {
|
private static func cancelUploadTasks() {
|
||||||
BackgroundWorkerApiImpl.updateUploadEnabled(false)
|
BackgroundWorkerApiImpl.updateUploadEnabled(false)
|
||||||
BGTaskScheduler.shared.cancel(taskRequestWithIdentifier: refreshUploadTaskID);
|
BGTaskScheduler.shared.cancel(taskRequestWithIdentifier: refreshUploadTaskID);
|
||||||
BGTaskScheduler.shared.cancel(taskRequestWithIdentifier: processingUploadTaskID);
|
BGTaskScheduler.shared.cancel(taskRequestWithIdentifier: processingUploadTaskID);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static func registerBackgroundProcessing() {
|
public static func registerBackgroundWorkers() {
|
||||||
BGTaskScheduler.shared.register(
|
BGTaskScheduler.shared.register(
|
||||||
forTaskWithIdentifier: processingUploadTaskID, using: nil) { task in
|
forTaskWithIdentifier: processingUploadTaskID, using: nil) { task in
|
||||||
if task is BGProcessingTask {
|
if task is BGProcessingTask {
|
||||||
|
|
@ -102,9 +95,22 @@ class BackgroundWorkerApiImpl: BackgroundWorkerFgHostApi {
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func handleBackgroundRefresh(task: BGAppRefreshTask, taskType: BackgroundTaskType) {
|
private static func handleBackgroundRefresh(task: BGAppRefreshTask, taskType: BackgroundTaskType) {
|
||||||
scheduleRefreshUpload()
|
let maxSeconds: Int?
|
||||||
// Restrict the refresh task to run only for a maximum of 20 seconds
|
|
||||||
runBackgroundWorker(task: task, taskType: taskType, maxSeconds: 20)
|
switch taskType {
|
||||||
|
case .localSync:
|
||||||
|
maxSeconds = 15
|
||||||
|
scheduleLocalSync()
|
||||||
|
case .refreshUpload:
|
||||||
|
maxSeconds = 20
|
||||||
|
scheduleRefreshUpload()
|
||||||
|
case .processingUpload:
|
||||||
|
print("Unexpected background refresh task encountered")
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Restrict the refresh task to run only for a maximum of (maxSeconds) seconds
|
||||||
|
runBackgroundWorker(task: task, taskType: taskType, maxSeconds: maxSeconds)
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func handleBackgroundProcessing(task: BGProcessingTask) {
|
private static func handleBackgroundProcessing(task: BGProcessingTask) {
|
||||||
|
|
@ -134,7 +140,7 @@ class BackgroundWorkerApiImpl: BackgroundWorkerFgHostApi {
|
||||||
|
|
||||||
task.expirationHandler = {
|
task.expirationHandler = {
|
||||||
DispatchQueue.main.async {
|
DispatchQueue.main.async {
|
||||||
backgroundWorker.cancel()
|
backgroundWorker.close()
|
||||||
}
|
}
|
||||||
isSuccess = false
|
isSuccess = false
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ import 'package:immich_mobile/providers/backup/drift_backup.provider.dart';
|
||||||
import 'package:immich_mobile/providers/db.provider.dart';
|
import 'package:immich_mobile/providers/db.provider.dart';
|
||||||
import 'package:immich_mobile/providers/infrastructure/db.provider.dart';
|
import 'package:immich_mobile/providers/infrastructure/db.provider.dart';
|
||||||
import 'package:immich_mobile/providers/user.provider.dart';
|
import 'package:immich_mobile/providers/user.provider.dart';
|
||||||
|
import 'package:immich_mobile/repositories/file_media.repository.dart';
|
||||||
import 'package:immich_mobile/services/app_settings.service.dart';
|
import 'package:immich_mobile/services/app_settings.service.dart';
|
||||||
import 'package:immich_mobile/services/auth.service.dart';
|
import 'package:immich_mobile/services/auth.service.dart';
|
||||||
import 'package:immich_mobile/services/localization.service.dart';
|
import 'package:immich_mobile/services/localization.service.dart';
|
||||||
|
|
@ -31,9 +32,7 @@ class BackgroundWorkerFgService {
|
||||||
// TODO: Move this call to native side once old timeline is removed
|
// TODO: Move this call to native side once old timeline is removed
|
||||||
Future<void> enableSyncService() => _foregroundHostApi.enableSyncWorker();
|
Future<void> enableSyncService() => _foregroundHostApi.enableSyncWorker();
|
||||||
|
|
||||||
Future<void> enableUploadService() => _foregroundHostApi.enableUploadWorker(
|
Future<void> enableUploadService() => _foregroundHostApi.enableUploadWorker();
|
||||||
PluginUtilities.getCallbackHandle(_backgroundSyncNativeEntrypoint)!.toRawHandle(),
|
|
||||||
);
|
|
||||||
|
|
||||||
Future<void> disableUploadService() => _foregroundHostApi.disableUploadWorker();
|
Future<void> disableUploadService() => _foregroundHostApi.disableUploadWorker();
|
||||||
}
|
}
|
||||||
|
|
@ -44,7 +43,7 @@ class BackgroundWorkerBgService extends BackgroundWorkerFlutterApi {
|
||||||
final Drift _drift;
|
final Drift _drift;
|
||||||
final DriftLogger _driftLogger;
|
final DriftLogger _driftLogger;
|
||||||
final BackgroundWorkerBgHostApi _backgroundHostApi;
|
final BackgroundWorkerBgHostApi _backgroundHostApi;
|
||||||
final Logger _logger = Logger('BackgroundUploadBgService');
|
final Logger _logger = Logger('BackgroundWorkerBgService');
|
||||||
|
|
||||||
bool _isCleanedUp = false;
|
bool _isCleanedUp = false;
|
||||||
|
|
||||||
|
|
@ -66,37 +65,50 @@ class BackgroundWorkerBgService extends BackgroundWorkerFlutterApi {
|
||||||
bool get _isBackupEnabled => _ref.read(appSettingsServiceProvider).getSetting(AppSettingsEnum.enableBackup);
|
bool get _isBackupEnabled => _ref.read(appSettingsServiceProvider).getSetting(AppSettingsEnum.enableBackup);
|
||||||
|
|
||||||
Future<void> init() async {
|
Future<void> init() async {
|
||||||
await loadTranslations();
|
try {
|
||||||
HttpSSLOptions.apply(applyNative: false);
|
await loadTranslations();
|
||||||
await _ref.read(authServiceProvider).setOpenApiServiceEndpoint();
|
HttpSSLOptions.apply(applyNative: false);
|
||||||
|
await _ref.read(authServiceProvider).setOpenApiServiceEndpoint();
|
||||||
|
|
||||||
// Initialize the file downloader
|
// Initialize the file downloader
|
||||||
await FileDownloader().configure(
|
await FileDownloader().configure(
|
||||||
globalConfig: [
|
globalConfig: [
|
||||||
// maxConcurrent: 6, maxConcurrentByHost(server):6, maxConcurrentByGroup: 3
|
// maxConcurrent: 6, maxConcurrentByHost(server):6, maxConcurrentByGroup: 3
|
||||||
(Config.holdingQueue, (6, 6, 3)),
|
(Config.holdingQueue, (6, 6, 3)),
|
||||||
// On Android, if files are larger than 256MB, run in foreground service
|
// On Android, if files are larger than 256MB, run in foreground service
|
||||||
(Config.runInForegroundIfFileLargerThan, 256),
|
(Config.runInForegroundIfFileLargerThan, 256),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
await FileDownloader().trackTasksInGroup(kDownloadGroupLivePhoto, markDownloadedComplete: false);
|
await FileDownloader().trackTasksInGroup(kDownloadGroupLivePhoto, markDownloadedComplete: false);
|
||||||
await FileDownloader().trackTasks();
|
await FileDownloader().trackTasks();
|
||||||
configureFileDownloaderNotifications();
|
configureFileDownloaderNotifications();
|
||||||
|
|
||||||
// Notify the host that the background upload service has been initialized and is ready to use
|
await _ref.read(fileMediaRepositoryProvider).enableBackgroundAccess();
|
||||||
await _backgroundHostApi.onInitialized();
|
|
||||||
|
// Notify the host that the background worker service has been initialized and is ready to use
|
||||||
|
_backgroundHostApi.onInitialized();
|
||||||
|
} catch (error, stack) {
|
||||||
|
_logger.severe("Failed to initialize background worker", error, stack);
|
||||||
|
_backgroundHostApi.close();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> onLocalSync(int? maxSeconds) async {
|
Future<void> onLocalSync(int? maxSeconds) async {
|
||||||
_logger.info('Local background syncing started');
|
try {
|
||||||
final sw = Stopwatch()..start();
|
_logger.info('Local background syncing started');
|
||||||
|
final sw = Stopwatch()..start();
|
||||||
|
|
||||||
final timeout = maxSeconds != null ? Duration(seconds: maxSeconds) : null;
|
final timeout = maxSeconds != null ? Duration(seconds: maxSeconds) : null;
|
||||||
await _syncAssets(hashTimeout: timeout, syncRemote: false);
|
await _syncAssets(hashTimeout: timeout, syncRemote: false);
|
||||||
|
|
||||||
sw.stop();
|
sw.stop();
|
||||||
_logger.info("Local sync completed in ${sw.elapsed.inSeconds}s");
|
_logger.info("Local sync completed in ${sw.elapsed.inSeconds}s");
|
||||||
|
} catch (error, stack) {
|
||||||
|
_logger.severe("Failed to complete local sync", error, stack);
|
||||||
|
} finally {
|
||||||
|
await _cleanup();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* We do the following on Android upload
|
/* We do the following on Android upload
|
||||||
|
|
@ -107,16 +119,20 @@ class BackgroundWorkerBgService extends BackgroundWorkerFlutterApi {
|
||||||
*/
|
*/
|
||||||
@override
|
@override
|
||||||
Future<void> onAndroidUpload() async {
|
Future<void> onAndroidUpload() async {
|
||||||
_logger.info('Android background processing started');
|
try {
|
||||||
final sw = Stopwatch()..start();
|
_logger.info('Android background processing started');
|
||||||
|
final sw = Stopwatch()..start();
|
||||||
|
|
||||||
await _syncAssets(hashTimeout: Duration(minutes: _isBackupEnabled ? 3 : 6));
|
await _syncAssets(hashTimeout: Duration(minutes: _isBackupEnabled ? 3 : 6));
|
||||||
await _handleBackup(processBulk: false);
|
await _handleBackup(processBulk: false);
|
||||||
|
|
||||||
await _cleanup();
|
sw.stop();
|
||||||
|
_logger.info("Android background processing completed in ${sw.elapsed.inSeconds}s");
|
||||||
sw.stop();
|
} catch (error, stack) {
|
||||||
_logger.info("Android background processing completed in ${sw.elapsed.inSeconds}s");
|
_logger.severe("Failed to complete Android background processing", error, stack);
|
||||||
|
} finally {
|
||||||
|
await _cleanup();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* We do the following on background upload
|
/* We do the following on background upload
|
||||||
|
|
@ -129,29 +145,37 @@ class BackgroundWorkerBgService extends BackgroundWorkerFlutterApi {
|
||||||
*/
|
*/
|
||||||
@override
|
@override
|
||||||
Future<void> onIosUpload(bool isRefresh, int? maxSeconds) async {
|
Future<void> onIosUpload(bool isRefresh, int? maxSeconds) async {
|
||||||
_logger.info('iOS background upload started with maxSeconds: ${maxSeconds}s');
|
try {
|
||||||
final sw = Stopwatch()..start();
|
_logger.info('iOS background upload started with maxSeconds: ${maxSeconds}s');
|
||||||
|
final sw = Stopwatch()..start();
|
||||||
|
|
||||||
final timeout = isRefresh ? const Duration(seconds: 5) : Duration(minutes: _isBackupEnabled ? 3 : 6);
|
final timeout = isRefresh ? const Duration(seconds: 5) : Duration(minutes: _isBackupEnabled ? 3 : 6);
|
||||||
await _syncAssets(hashTimeout: timeout);
|
await _syncAssets(hashTimeout: timeout);
|
||||||
|
|
||||||
final backupFuture = _handleBackup();
|
final backupFuture = _handleBackup();
|
||||||
if (maxSeconds != null) {
|
if (maxSeconds != null) {
|
||||||
await backupFuture.timeout(Duration(seconds: maxSeconds - 1), onTimeout: () {});
|
await backupFuture.timeout(Duration(seconds: maxSeconds - 1), onTimeout: () {});
|
||||||
} else {
|
} else {
|
||||||
await backupFuture;
|
await backupFuture;
|
||||||
|
}
|
||||||
|
|
||||||
|
sw.stop();
|
||||||
|
_logger.info("iOS background upload completed in ${sw.elapsed.inSeconds}s");
|
||||||
|
} catch (error, stack) {
|
||||||
|
_logger.severe("Failed to complete iOS background upload", error, stack);
|
||||||
|
} finally {
|
||||||
|
await _cleanup();
|
||||||
}
|
}
|
||||||
|
|
||||||
await _cleanup();
|
|
||||||
|
|
||||||
sw.stop();
|
|
||||||
_logger.info("iOS background upload completed in ${sw.elapsed.inSeconds}s");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> cancel() async {
|
Future<void> cancel() async {
|
||||||
_logger.warning("Background upload cancelled");
|
_logger.warning("Background worker cancelled");
|
||||||
await _cleanup();
|
try {
|
||||||
|
await _cleanup();
|
||||||
|
} catch (error, stack) {
|
||||||
|
debugPrint('Failed to cleanup background worker: $error with stack: $stack');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _cleanup() async {
|
Future<void> _cleanup() async {
|
||||||
|
|
@ -159,13 +183,21 @@ class BackgroundWorkerBgService extends BackgroundWorkerFlutterApi {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
_isCleanedUp = true;
|
try {
|
||||||
await _ref.read(backgroundSyncProvider).cancel();
|
_isCleanedUp = true;
|
||||||
await _ref.read(backgroundSyncProvider).cancelLocal();
|
_logger.info("Cleaning up background worker");
|
||||||
await _isar.close();
|
await _ref.read(backgroundSyncProvider).cancel();
|
||||||
await _drift.close();
|
await _ref.read(backgroundSyncProvider).cancelLocal();
|
||||||
await _driftLogger.close();
|
if (_isar.isOpen) {
|
||||||
_ref.dispose();
|
await _isar.close();
|
||||||
|
}
|
||||||
|
await _drift.close();
|
||||||
|
await _driftLogger.close();
|
||||||
|
_ref.dispose();
|
||||||
|
debugPrint("Background worker cleaned up");
|
||||||
|
} catch (error, stack) {
|
||||||
|
debugPrint('Failed to cleanup background worker: $error with stack: $stack');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _handleBackup({bool processBulk = true}) async {
|
Future<void> _handleBackup({bool processBulk = true}) async {
|
||||||
|
|
@ -221,8 +253,10 @@ class BackgroundWorkerBgService extends BackgroundWorkerFlutterApi {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Native entry invoked from the background worker. If renaming or moving this to a different
|
||||||
|
/// library, make sure to update the entry points and URI in native workers as well
|
||||||
@pragma('vm:entry-point')
|
@pragma('vm:entry-point')
|
||||||
Future<void> _backgroundSyncNativeEntrypoint() async {
|
Future<void> backgroundSyncNativeEntrypoint() async {
|
||||||
WidgetsFlutterBinding.ensureInitialized();
|
WidgetsFlutterBinding.ensureInitialized();
|
||||||
DartPluginRegistrant.ensureInitialized();
|
DartPluginRegistrant.ensureInitialized();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,13 @@ class StorageRepository {
|
||||||
file = await entity?.originFile;
|
file = await entity?.originFile;
|
||||||
if (file == null) {
|
if (file == null) {
|
||||||
log.warning("Cannot get file for asset $assetId");
|
log.warning("Cannot get file for asset $assetId");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
final exists = await file.exists();
|
||||||
|
if (!exists) {
|
||||||
|
log.warning("File for asset $assetId does not exist");
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
} catch (error, stackTrace) {
|
} catch (error, stackTrace) {
|
||||||
log.warning("Error getting file for asset $assetId", error, stackTrace);
|
log.warning("Error getting file for asset $assetId", error, stackTrace);
|
||||||
|
|
@ -34,6 +41,13 @@ class StorageRepository {
|
||||||
log.warning(
|
log.warning(
|
||||||
"Cannot get motion file for asset ${asset.id}, name: ${asset.name}, created on: ${asset.createdAt}",
|
"Cannot get motion file for asset ${asset.id}, name: ${asset.name}, created on: ${asset.createdAt}",
|
||||||
);
|
);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
final exists = await file.exists();
|
||||||
|
if (!exists) {
|
||||||
|
log.warning("Motion file for asset ${asset.id} does not exist");
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
} catch (error, stackTrace) {
|
} catch (error, stackTrace) {
|
||||||
log.warning(
|
log.warning(
|
||||||
|
|
|
||||||
|
|
@ -206,14 +206,14 @@ class ImmichAppState extends ConsumerState<ImmichApp> with WidgetsBindingObserve
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
// needs to be delayed so that EasyLocalization is working
|
// needs to be delayed so that EasyLocalization is working
|
||||||
if (Store.isBetaTimelineEnabled) {
|
if (Store.isBetaTimelineEnabled) {
|
||||||
|
ref.read(backgroundServiceProvider).disableService();
|
||||||
ref.read(driftBackgroundUploadFgService).enableSyncService();
|
ref.read(driftBackgroundUploadFgService).enableSyncService();
|
||||||
if (ref.read(appSettingsServiceProvider).getSetting(AppSettingsEnum.enableBackup)) {
|
if (ref.read(appSettingsServiceProvider).getSetting(AppSettingsEnum.enableBackup)) {
|
||||||
ref.read(backgroundServiceProvider).disableService();
|
|
||||||
ref.read(driftBackgroundUploadFgService).enableUploadService();
|
ref.read(driftBackgroundUploadFgService).enableUploadService();
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
ref.read(backgroundServiceProvider).resumeServiceIfEnabled();
|
|
||||||
ref.read(driftBackgroundUploadFgService).disableUploadService();
|
ref.read(driftBackgroundUploadFgService).disableUploadService();
|
||||||
|
ref.read(backgroundServiceProvider).resumeServiceIfEnabled();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
27
mobile/lib/platform/background_worker_api.g.dart
generated
27
mobile/lib/platform/background_worker_api.g.dart
generated
|
|
@ -82,7 +82,7 @@ class BackgroundWorkerFgHostApi {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> enableUploadWorker(int callbackHandle) async {
|
Future<void> enableUploadWorker() async {
|
||||||
final String pigeonVar_channelName =
|
final String pigeonVar_channelName =
|
||||||
'dev.flutter.pigeon.immich_mobile.BackgroundWorkerFgHostApi.enableUploadWorker$pigeonVar_messageChannelSuffix';
|
'dev.flutter.pigeon.immich_mobile.BackgroundWorkerFgHostApi.enableUploadWorker$pigeonVar_messageChannelSuffix';
|
||||||
final BasicMessageChannel<Object?> pigeonVar_channel = BasicMessageChannel<Object?>(
|
final BasicMessageChannel<Object?> pigeonVar_channel = BasicMessageChannel<Object?>(
|
||||||
|
|
@ -90,7 +90,7 @@ class BackgroundWorkerFgHostApi {
|
||||||
pigeonChannelCodec,
|
pigeonChannelCodec,
|
||||||
binaryMessenger: pigeonVar_binaryMessenger,
|
binaryMessenger: pigeonVar_binaryMessenger,
|
||||||
);
|
);
|
||||||
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(<Object?>[callbackHandle]);
|
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(null);
|
||||||
final List<Object?>? pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
|
final List<Object?>? pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
|
||||||
if (pigeonVar_replyList == null) {
|
if (pigeonVar_replyList == null) {
|
||||||
throw _createConnectionError(pigeonVar_channelName);
|
throw _createConnectionError(pigeonVar_channelName);
|
||||||
|
|
@ -164,6 +164,29 @@ class BackgroundWorkerBgHostApi {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> close() async {
|
||||||
|
final String pigeonVar_channelName =
|
||||||
|
'dev.flutter.pigeon.immich_mobile.BackgroundWorkerBgHostApi.close$pigeonVar_messageChannelSuffix';
|
||||||
|
final BasicMessageChannel<Object?> pigeonVar_channel = BasicMessageChannel<Object?>(
|
||||||
|
pigeonVar_channelName,
|
||||||
|
pigeonChannelCodec,
|
||||||
|
binaryMessenger: pigeonVar_binaryMessenger,
|
||||||
|
);
|
||||||
|
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(null);
|
||||||
|
final List<Object?>? pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
|
||||||
|
if (pigeonVar_replyList == null) {
|
||||||
|
throw _createConnectionError(pigeonVar_channelName);
|
||||||
|
} else if (pigeonVar_replyList.length > 1) {
|
||||||
|
throw PlatformException(
|
||||||
|
code: pigeonVar_replyList[0]! as String,
|
||||||
|
message: pigeonVar_replyList[1] as String?,
|
||||||
|
details: pigeonVar_replyList[2],
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
abstract class BackgroundWorkerFlutterApi {
|
abstract class BackgroundWorkerFlutterApi {
|
||||||
|
|
|
||||||
|
|
@ -15,8 +15,7 @@ import 'package:pigeon/pigeon.dart';
|
||||||
abstract class BackgroundWorkerFgHostApi {
|
abstract class BackgroundWorkerFgHostApi {
|
||||||
void enableSyncWorker();
|
void enableSyncWorker();
|
||||||
|
|
||||||
// Enables the background upload service with the given callback handle
|
void enableUploadWorker();
|
||||||
void enableUploadWorker(int callbackHandle);
|
|
||||||
|
|
||||||
// Disables the background upload service
|
// Disables the background upload service
|
||||||
void disableUploadWorker();
|
void disableUploadWorker();
|
||||||
|
|
@ -27,6 +26,8 @@ abstract class BackgroundWorkerBgHostApi {
|
||||||
// Called from the background flutter engine when it has bootstrapped and established the
|
// Called from the background flutter engine when it has bootstrapped and established the
|
||||||
// required platform channels to notify the native side to start the background upload
|
// required platform channels to notify the native side to start the background upload
|
||||||
void onInitialized();
|
void onInitialized();
|
||||||
|
|
||||||
|
void close();
|
||||||
}
|
}
|
||||||
|
|
||||||
@FlutterApi()
|
@FlutterApi()
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue