I've been doing some research on setting up and enabling iCloud Drive or iCloud backup of user files in my Flutter app. The idea is that the user, with iCloud enabled, can switch to another device and their settings and save files will be loaded from iCloud.
I used AI to implement a solution and it recommended I create a platform channel in swift and service layer in dart to communicate with my view model. So that's what I did. The platform channel passes the data through the service layer to the view model and is accessible in the view and then it goes back. I have three platform channels: one for saving the actual users journal data, one for dice formulas created by the user, and one for the users settings. The service layer is setup as singletons.
I'm trying to understand if I am on the right track with my implementation or if there are other recommendations for how to setup and implement this kind of service.
My implementation was working but couldn't be disabled by the user. Now that I have a switch to disable it, enabling it doesn't work. Because it was written extensively by AI I'm considering rewriting myself from scratch but want to see what the professionals are doing when they need to implement iCloud for their app. The data that is stored is currently stored locally and is user generated JSON files that save data created in the app. They are "journals" documenting solo adventures in TTRPGs. The data is really important to the user so I want to get this right.
Both of these offer somewhat low quality writing and explanation. Here's the view model code to give an idea:
Future<void> setCloudSyncEnabled(bool enabled) async {
debugPrint(
'SYNC: Attempting to ${enabled ? 'enable' : 'disable'} cloud sync');
if (enabled == isCloudSyncEnabled()) {
debugPrint(
'SYNC: Cloud sync already in desired state (${enabled ? 'enabled' : 'disabled'})');
return;
}
try {
debugPrint('SYNC: Saving cloud sync preference: $enabled');
await _preferencesService.setCloudSyncEnabled(enabled);
if (enabled) {
debugPrint('SYNC: Reinitializing cloud services');
// Reinitialize cloud services
final i = ServiceLocator.instance;
try {
// Initialize iCloud services
debugPrint('SYNC: Initializing CloudSettingsService');
i.cloudSettingsService = await ICloudSettingsService.getInstance();
// Only proceed with other cloud services if base service is available
if (await i.cloudSettingsService!.isAvailable()) {
debugPrint(
'SYNC: CloudSettingsService available, initializing other cloud services');
i.iCloudFormulasService = await ICloudFormulasService.initialize();
i.iCloudJournalService = await ICloudJournalService.initialize(
i.preferencesService,
i.storageLocationService,
);
debugPrint('SYNC: Initializing SettingsSyncManager');
i.settingsSyncManager = await SettingsSyncManager.initialize(
preferences: i.preferencesService,
cloud: i.cloudSettingsService!,
);
// Update our local reference to the sync manager
_syncManager = i.settingsSyncManager;
if (_syncManager != null) {
debugPrint('SYNC: Setting up sync manager status listener');
_syncManager?.syncStatusStream.distinct().listen((status) {
debugPrint('SYNC: Status update from sync manager: $status');
_syncStatus = status;
notifyListeners();
});
debugPrint('SYNC: Enabling cloud sync with provider');
await _syncManager?.enableCloudSync(
_syncManager?.provider ?? CloudStorageProvider.none);
debugPrint('SYNC: Cloud sync enabled successfully');
}
} else {
debugPrint(
'SYNC: CloudSettingsService not available, disabling cloud sync');
await _preferencesService.setCloudSyncEnabled(false);
throw Exception('Cloud services not available');
}
} catch (e) {
debugPrint('SYNC: Error initializing cloud services: $e');
// Reset all cloud services on error
i.cloudSettingsService = null;
i.iCloudFormulasService = null;
i.iCloudJournalService = null;
i.settingsSyncManager = null;
_syncManager = null;
await _preferencesService.setCloudSyncEnabled(false);
rethrow;
}
} else {
debugPrint('SYNC: Disabling cloud sync');
await _syncManager?.disableCloudSync();
debugPrint('SYNC: Cloud sync disabled successfully');
// Clear cloud services
final i = ServiceLocator.instance;
i.cloudSettingsService = null;
i.iCloudFormulasService = null;
i.iCloudJournalService = null;
i.settingsSyncManager = null;
_syncManager = null;
}
notifyListeners();
} catch (e, stack) {
debugPrint('SYNC: Error during sync configuration: $e');
ErrorReportingService.recordError(
'Failed to ${enabled ? 'enable' : 'disable'} cloud sync',
stack,
reason: e.toString(),
fatal: false,
);
// Revert the preference
debugPrint('SYNC: Reverting cloud sync preference due to error');
await _preferencesService.setCloudSyncEnabled(!enabled);
rethrow;
}
}
I discovered there was this icloud_storage package on pub.dev but its two years out of date with open issues and two pull requests ignored.
Other things I've explored and didn't find that helpful:
Well I ended up sticking with my own AI generated platform channels. My tests are passing. Not sure what will happen but it seems to be working. If anyone is interested but I'll post the code below so anyone else can comment on it, improve it, or roast it. I have three platform channels: one for the JSON files the user generates, one fo the dice formulas the user can save, and one for the app settings.
Journal
import UIKit
import Flutter
/// Handles iCloud synchronization for journal files in the iOS app.
/// This class manages the storage, retrieval, and synchronization of journal files using iCloud.
/// It provides methods for saving, loading, and monitoring changes to journal files.
class ICloudJournalHandler: NSObject {
/// The method channel used for communicating with Flutter
private let methodChannel: FlutterMethodChannel
/// The event channel used for sending file change notifications to Flutter
private let eventChannel: FlutterEventChannel
/// The event sink used to send events through the event channel
private var eventSink: FlutterEventSink?
/// The URL of the app's iCloud container, specifically the Documents directory
private let ubiquityContainer: URL?
/// Query used to monitor changes to journal files in iCloud
private var documentQuery: NSMetadataQuery?
/// Initializes the handler with the necessary Flutter plugin registrar
/// - Parameter registrar: The Flutter plugin registrar used to set up communication channels
init(registrar: FlutterPluginRegistrar) {
self.methodChannel = FlutterMethodChannel(
name: "icloud_journal_sync",
binaryMessenger: registrar.messenger()
)
self.eventChannel = FlutterEventChannel(
name: "icloud_journal_changes",
binaryMessenger: registrar.messenger()
)
self.ubiquityContainer = FileManager.default.url(
forUbiquityContainerIdentifier: nil
)?.appendingPathComponent("Documents")
super.init()
self.methodChannel.setMethodCallHandler(handleMethodCall)
self.eventChannel.setStreamHandler(self)
setupDocumentQuery()
}
/// Sets up the metadata query to monitor journal file changes in iCloud
/// This query watches for JSON files in the Documents directory
private func setupDocumentQuery() {
guard ubiquityContainer != nil else { return }
documentQuery = NSMetadataQuery()
documentQuery?.searchScopes = [NSMetadataQueryUbiquitousDocumentsScope]
documentQuery?.predicate = NSPredicate(format: "%K LIKE '*.json'", NSMetadataItemFSNameKey)
NotificationCenter.default.addObserver(
self,
selector: #selector(handleQueryUpdate),
name: NSNotification.Name.NSMetadataQueryDidUpdate,
object: documentQuery
)
NotificationCenter.default.addObserver(
self,
selector: #selector(handleInitialQueryResults),
name: NSNotification.Name.NSMetadataQueryDidFinishGathering,
object: documentQuery
)
documentQuery?.start()
}
/// Handles updates to the document query when changes are detected
/// - Parameter notification: The notification containing query update information
@objc private func handleQueryUpdate(_ notification: Notification) {
guard let query = notification.object as? NSMetadataQuery else { return }
query.disableUpdates()
defer { query.enableUpdates() }
handleQueryResults(query)
}
/// Handles the initial results when the document query first gathers files
/// - Parameter notification: The notification containing initial query results
@objc private func handleInitialQueryResults(_ notification: Notification) {
guard let query = notification.object as? NSMetadataQuery else { return }
handleQueryResults(query)
}
/// Processes query results and notifies listeners of file changes
/// - Parameter query: The metadata query containing file information
private func handleQueryResults(_ query: NSMetadataQuery) {
for item in query.results as! [NSMetadataItem] {
guard let filename = item.value(forAttribute: NSMetadataItemFSNameKey) as? String,
filename.hasSuffix(".json") else { continue }
notifyChange(type: "update", file: filename)
}
}
/// Notifies Flutter of file changes through the event channel
/// - Parameters:
/// - type: The type of change (e.g., "update")
/// - file: The name of the file that changed
private func notifyChange(type: String, file: String) {
let event: [String: Any] = [
"type": type,
"file": file
]
eventSink?(event)
}
/// Handles method calls from Flutter
/// - Parameters:
/// - call: The method call containing the method name and arguments
/// - result: The callback to send the result back to Flutter
private func handleMethodCall(_ call: FlutterMethodCall, _ result: @escaping FlutterResult) {
switch call.method {
case "initialize":
handleInitialize(result)
case "saveJournal":
handleSaveJournal(call, result)
case "loadJournal":
handleLoadJournal(call, result)
case "deleteJournal":
handleDeleteJournal(call, result)
case "uploadFile":
handleUploadFile(call, result)
case "downloadFile":
handleDownloadFile(call, result)
case "listFiles":
handleListFiles(call, result)
case "getFileMetadata":
handleGetFileMetadata(call, result)
default:
result(FlutterMethodNotImplemented)
}
}
/// Initializes the iCloud container
/// - Parameter result: The callback to send the initialization result back to Flutter
private func handleInitialize(_ result: @escaping FlutterResult) {
guard let container = ubiquityContainer else {
result(false)
return
}
do {
try FileManager.default.createDirectory(
at: container,
withIntermediateDirectories: true,
attributes: nil
)
result(true)
} catch {
print("Failed to initialize iCloud container: \(error)")
result(false)
}
}
/// Saves a journal to iCloud
/// - Parameters:
/// - call: The method call containing the filename and JSON content
/// - result: The callback to send the save result back to Flutter
private func handleSaveJournal(_ call: FlutterMethodCall, _ result: @escaping FlutterResult) {
guard let args = call.arguments as? [String: Any],
let filename = args["filename"] as? String,
let jsonString = args["jsonString"] as? String,
let container = ubiquityContainer else {
result(FlutterError(code: "INVALID_ARGS",
message: "Invalid arguments for saveJournal",
details: nil))
return
}
let fileURL = container.appendingPathComponent(filename)
do {
try jsonString.write(to: fileURL, atomically: true, encoding: .utf8)
result(nil)
} catch {
result(FlutterError(code: "SAVE_ERROR",
message: "Failed to save journal: \(error.localizedDescription)",
details: nil))
}
}
/// Loads a journal from iCloud
/// - Parameters:
/// - call: The method call containing the filename to load
/// - result: The callback to send the loaded content back to Flutter
private func handleLoadJournal(_ call: FlutterMethodCall, _ result: @escaping FlutterResult) {
guard let args = call.arguments as? [String: Any],
let filename = args["filename"] as? String,
let container = ubiquityContainer else {
result(FlutterError(code: "INVALID_ARGS",
message: "Invalid arguments for loadJournal",
details: nil))
return
}
let fileURL = container.appendingPathComponent(filename)
do {
let jsonString = try String(contentsOf: fileURL, encoding: .utf8)
result(jsonString)
} catch {
result(FlutterError(code: "LOAD_ERROR",
message: "Failed to load journal: \(error.localizedDescription)",
details: nil))
}
}
/// Deletes a journal from iCloud
/// - Parameters:
/// - call: The method call containing the filename to delete
/// - result: The callback to send the deletion result back to Flutter
private func handleDeleteJournal(_ call: FlutterMethodCall, _ result: @escaping FlutterResult) {
guard let args = call.arguments as? [String: Any],
let filename = args["filename"] as? String,
let container = ubiquityContainer else {
result(FlutterError(code: "INVALID_ARGS",
message: "Invalid arguments for deleteJournal",
details: nil))
return
}
let fileURL = container.appendingPathComponent(filename)
do {
try FileManager.default.removeItem(at: fileURL)
result(nil)
} catch {
result(FlutterError(code: "DELETE_ERROR",
message: "Failed to delete journal: \(error.localizedDescription)",
details: nil))
}
}
/// Uploads a file to iCloud
/// - Parameters:
/// - call: The method call containing the path and content
/// - result: The callback to send the upload result back to Flutter
private func handleUploadFile(_ call: FlutterMethodCall, _ result: @escaping FlutterResult) {
guard let args = call.arguments as? [String: Any],
let filename = args["path"] as? String,
let jsonString = args["content"] as? String,
let container = ubiquityContainer else {
result(FlutterError(code: "INVALID_ARGS",
message: "Invalid arguments for uploadFile",
details: nil))
return
}
let fileURL = container.appendingPathComponent(filename)
do {
try jsonString.write(to: fileURL, atomically: true, encoding: .utf8)
result(nil)
} catch {
result(FlutterError(code: "UPLOAD_ERROR",
message: "Failed to upload file: \(error.localizedDescription)",
details: nil))
}
}
/// Downloads a file from iCloud
/// - Parameters:
/// - call: The method call containing the path to download
/// - result: The callback to send the downloaded content back to Flutter
private func handleDownloadFile(_ call: FlutterMethodCall, _ result: @escaping FlutterResult) {
guard let args = call.arguments as? [String: Any],
let filename = args["path"] as? String,
let container = ubiquityContainer else {
result(FlutterError(code: "INVALID_ARGS",
message: "Invalid arguments for downloadFile",
details: nil))
return
}
let fileURL = container.appendingPathComponent(filename)
do {
let jsonString = try String(contentsOf: fileURL, encoding: .utf8)
result(jsonString)
} catch {
result(FlutterError(code: "DOWNLOAD_ERROR",
message: "Failed to download file: \(error.localizedDescription)",
details: nil))
}
}
/// Lists all files in the iCloud container
/// - Parameters:
/// - call: The method call (unused)
/// - result: The callback to send the file list back to Flutter
private func handleListFiles(_ call: FlutterMethodCall, _ result: @escaping FlutterResult) {
guard let container = ubiquityContainer else {
result(FlutterError(code: "NO_CONTAINER",
message: "iCloud container not available",
details: nil))
return
}
do {
let files = try FileManager.default.contentsOfDirectory(at: container, includingPropertiesForKeys: nil)
let filenames = files.map { $0.lastPathComponent }
result(filenames)
} catch {
result(FlutterError(code: "LIST_ERROR",
message: "Failed to list files: \(error.localizedDescription)",
details: nil))
}
}
/// Gets metadata for a file in iCloud
/// - Parameters:
/// - call: The method call containing the path to get metadata for
/// - result: The callback to send the metadata back to Flutter
private func handleGetFileMetadata(_ call: FlutterMethodCall, _ result: @escaping FlutterResult) {
guard let args = call.arguments as? [String: Any],
let filename = args["path"] as? String,
let container = ubiquityContainer else {
result(FlutterError(code: "INVALID_ARGS",
message: "Invalid arguments for getFileMetadata",
details: nil))
return
}
let fileURL = container.appendingPathComponent(filename)
do {
let attributes = try FileManager.default.attributesOfItem(atPath: fileURL.path)
let modificationDate = attributes[.modificationDate] as? Date ?? Date()
let version = String(modificationDate.timeIntervalSince1970)
result([
"version": version,
"modificationDate": modificationDate.timeIntervalSince1970
])
} catch {
result(FlutterError(code: "METADATA_ERROR",
message: "Failed to get file metadata: \(error.localizedDescription)",
details: nil))
}
}
}
extension ICloudJournalHandler: FlutterStreamHandler {
func onListen(withArguments arguments: Any?, eventSink events: @escaping FlutterEventSink) -> FlutterError? {
self.eventSink = events
return nil
}
func onCancel(withArguments arguments: Any?) -> FlutterError? {
self.eventSink = nil
return nil
}
}
Formulas and settings are similar. Send me a message if you want to see those.