diff --git a/iosApp/FeedFlow.entitlements b/iosApp/FeedFlow.entitlements new file mode 100644 index 00000000..a215bc00 --- /dev/null +++ b/iosApp/FeedFlow.entitlements @@ -0,0 +1,18 @@ + + + + + com.apple.developer.icloud-container-identifiers + + iCloud.com.prof18.feedflow + + com.apple.developer.icloud-services + + CloudDocuments + + com.apple.developer.ubiquity-container-identifiers + + iCloud.com.prof18.feedflow + + + diff --git a/iosApp/FeedFlow.xcodeproj/project.pbxproj b/iosApp/FeedFlow.xcodeproj/project.pbxproj index 0b9c78c5..88da2863 100644 --- a/iosApp/FeedFlow.xcodeproj/project.pbxproj +++ b/iosApp/FeedFlow.xcodeproj/project.pbxproj @@ -168,6 +168,7 @@ CCA704D029D5A9B600560FA3 /* Colors.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Colors.swift; sourceTree = ""; }; CCA704E429D625E200560FA3 /* HomeSheetToShow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeSheetToShow.swift; sourceTree = ""; }; CCA704E629D626D400560FA3 /* FilePickerController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FilePickerController.swift; sourceTree = ""; }; + CCD494BA2C56B38500C54C8D /* FeedFlow.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = FeedFlow.entitlements; sourceTree = ""; }; CCEA11BA2A6D2AC300C661AC /* GoogleService-Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = "GoogleService-Info.plist"; sourceTree = ""; }; CCEB9C5729B33F5A0064D14A /* Koin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Koin.swift; sourceTree = ""; }; CCF21A6D2BD32AB60017F663 /* CommonViewRoute.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommonViewRoute.swift; sourceTree = ""; }; @@ -231,6 +232,7 @@ 7555FF72242A565900829871 = { isa = PBXGroup; children = ( + CCD494BA2C56B38500C54C8D /* FeedFlow.entitlements */, CC21E74C2BED7F980001F160 /* PrivacyInfo.xcprivacy */, CCA704CD29D5A83D00560FA3 /* Assets */, CC23292D29D62FE2005D0E06 /* Kotlin */, @@ -953,6 +955,7 @@ baseConfigurationReference = CC0799452C29BC5F00067E06 /* Config.xcconfig */; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIconDebug; + CODE_SIGN_ENTITLEMENTS = FeedFlow.entitlements; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_ASSET_PATHS = "\"Preview Content\""; @@ -989,6 +992,7 @@ baseConfigurationReference = CC0799452C29BC5F00067E06 /* Config.xcconfig */; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CODE_SIGN_ENTITLEMENTS = FeedFlow.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; diff --git a/iosApp/Info.plist b/iosApp/Info.plist index 3cfc3e60..7fa3efcb 100644 --- a/iosApp/Info.plist +++ b/iosApp/Info.plist @@ -86,5 +86,17 @@ DropboxApiKey $(DROPBOX_API_KEY) + NSUbiquitousContainers + + iCloud.com.prof18.feedflow + + NSUbiquitousContainerIsDocumentScopePublic + + NSUbiquitousContainerName + FeedFlow + NSUbiquitousContainerSupportedFolderLevels + Any + + diff --git a/shared/src/iosMain/kotlin/com/prof18/feedflow/shared/di/KoinIOS.kt b/shared/src/iosMain/kotlin/com/prof18/feedflow/shared/di/KoinIOS.kt index f0912123..538c7ae3 100644 --- a/shared/src/iosMain/kotlin/com/prof18/feedflow/shared/di/KoinIOS.kt +++ b/shared/src/iosMain/kotlin/com/prof18/feedflow/shared/di/KoinIOS.kt @@ -100,6 +100,7 @@ internal actual fun getPlatformModule(appEnvironment: AppEnvironment): Module = dropboxSettings = get(), settingsRepository = get(), accountsRepository = get(), + iCloudSettings = get(), ) } @@ -124,6 +125,7 @@ internal actual fun getPlatformModule(appEnvironment: AppEnvironment): Module = dateFormatter = get(), accountsRepository = get(), feedSyncMessageQueue = get(), + feedSyncRepository = get(), ) } } diff --git a/shared/src/iosMain/kotlin/com/prof18/feedflow/shared/domain/feedsync/FeedSyncIosWorker.kt b/shared/src/iosMain/kotlin/com/prof18/feedflow/shared/domain/feedsync/FeedSyncIosWorker.kt index 7a6b199b..29da3b84 100644 --- a/shared/src/iosMain/kotlin/com/prof18/feedflow/shared/domain/feedsync/FeedSyncIosWorker.kt +++ b/shared/src/iosMain/kotlin/com/prof18/feedflow/shared/domain/feedsync/FeedSyncIosWorker.kt @@ -11,6 +11,7 @@ import com.prof18.feedflow.feedsync.dropbox.DropboxDownloadParam import com.prof18.feedflow.feedsync.dropbox.DropboxSettings import com.prof18.feedflow.feedsync.dropbox.DropboxStringCredentials import com.prof18.feedflow.feedsync.dropbox.DropboxUploadParam +import com.prof18.feedflow.feedsync.icloud.ICloudSettings import com.prof18.feedflow.shared.domain.model.SyncResult import com.prof18.feedflow.shared.domain.settings.SettingsRepository import kotlinx.cinterop.BetaInteropApi @@ -28,6 +29,7 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import kotlinx.datetime.Clock import platform.Foundation.NSApplicationSupportDirectory +import platform.Foundation.NSDocumentDirectory import platform.Foundation.NSError import platform.Foundation.NSFileManager import platform.Foundation.NSFileManagerItemReplacementUsingNewMetadataOnly @@ -45,6 +47,7 @@ internal class FeedSyncIosWorker( private val dropboxSettings: DropboxSettings, private val settingsRepository: SettingsRepository, private val accountsRepository: AccountsRepository, + private val iCloudSettings: ICloudSettings, ) : FeedSyncWorker { private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) @@ -185,6 +188,7 @@ internal class FeedSyncIosWorker( return false } + @OptIn(ExperimentalForeignApi::class, BetaInteropApi::class) private suspend fun accountSpecificUpload(databasePath: NSURL) = when (accountsRepository.getCurrentSyncAccount()) { SyncAccounts.DROPBOX -> { @@ -199,8 +203,31 @@ internal class FeedSyncIosWorker( } SyncAccounts.ICLOUD -> { - // TODO - logger.d { "TODO: Upload to iCloud" } + val iCloudUrl = getICloudFolderURL() + if (iCloudUrl != null) { + memScoped { + val errorPtr: ObjCObjectVar = alloc() + + // Copy doesn't override the item, so we need to clear it before. + // An alternative would be checking the existence of the file before and copy or replace. + NSFileManager.defaultManager.removeItemAtURL( + iCloudUrl, + null, + ) + + NSFileManager.defaultManager.copyItemAtURL( + srcURL = databasePath, + toURL = iCloudUrl, + error = errorPtr.ptr, + ) + + if (errorPtr.value != null) { + logger.e { "Error uploading to iCloud: ${errorPtr.value}" } + } + } + } + iCloudSettings.setLastUploadTimestamp(Clock.System.now().toEpochMilliseconds()) + logger.d { "Upload to iCloud successfully" } } SyncAccounts.LOCAL -> { @@ -229,9 +256,7 @@ internal class FeedSyncIosWorker( } SyncAccounts.ICLOUD -> { - // TODO - logger.d { "TODO: Download from iCloud" } - SyncResult.Success + return iCloudDownload() } SyncAccounts.LOCAL -> { @@ -240,4 +265,53 @@ internal class FeedSyncIosWorker( } } } + + @OptIn(ExperimentalForeignApi::class, BetaInteropApi::class) + private fun iCloudDownload(): SyncResult { + val iCloudUrl = getICloudFolderURL() + val tempUrl = getTemporaryFileUrl() + if (iCloudUrl == null || tempUrl == null) { + logger.e { "Error downloading database" } + return SyncResult.Error + } + NSFileManager.defaultManager.removeItemAtURL( + tempUrl, + null, + ) + + memScoped { + val errorPtr: ObjCObjectVar = alloc() + + NSFileManager.defaultManager.copyItemAtURL( + srcURL = iCloudUrl, + toURL = tempUrl, + error = errorPtr.ptr, + ) + + if (errorPtr.value != null) { + logger.e { "Error downloading from iCloud: ${errorPtr.value}" } + return SyncResult.Error + } + + replaceDatabase(tempUrl) + iCloudSettings.setLastDownloadTimestamp(Clock.System.now().toEpochMilliseconds()) + logger.d { "Download from iCloud successfully" } + return SyncResult.Success + } + } + + private fun getICloudFolderURL(): NSURL? = NSFileManager.defaultManager + .URLForUbiquityContainerIdentifier("iCloud.com.prof18.feedflow") + ?.URLByAppendingPathComponent("Documents") + ?.URLByAppendingPathComponent(getDatabaseName()) + + private fun getTemporaryFileUrl(): NSURL? { + val documentsDirectory: NSURL? = NSFileManager.defaultManager.URLsForDirectory( + directory = NSDocumentDirectory, + inDomains = NSUserDomainMask, + ).firstOrNull() as? NSURL? + val databaseUrl = documentsDirectory?.URLByAppendingPathComponent(getDatabaseName()) + + return databaseUrl + } } diff --git a/shared/src/iosMain/kotlin/com/prof18/feedflow/shared/presentation/ICloudSyncViewModel.kt b/shared/src/iosMain/kotlin/com/prof18/feedflow/shared/presentation/ICloudSyncViewModel.kt index e7f33e16..f9436638 100644 --- a/shared/src/iosMain/kotlin/com/prof18/feedflow/shared/presentation/ICloudSyncViewModel.kt +++ b/shared/src/iosMain/kotlin/com/prof18/feedflow/shared/presentation/ICloudSyncViewModel.kt @@ -6,17 +6,20 @@ import com.prof18.feedflow.feedsync.icloud.ICloudSettings import com.prof18.feedflow.shared.domain.DateFormatter import com.prof18.feedflow.shared.domain.feedsync.AccountsRepository import com.prof18.feedflow.shared.domain.feedsync.FeedSyncMessageQueue +import com.prof18.feedflow.shared.domain.feedsync.FeedSyncRepository import com.rickclephas.kmp.nativecoroutines.NativeCoroutines import com.rickclephas.kmp.nativecoroutines.NativeCoroutinesState import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch class ICloudSyncViewModel internal constructor( private val iCloudSettings: ICloudSettings, private val dateFormatter: DateFormatter, private val accountsRepository: AccountsRepository, + private val feedSyncRepository: FeedSyncRepository, feedSyncMessageQueue: FeedSyncMessageQueue, ) : BaseViewModel() { @@ -52,7 +55,11 @@ class ICloudSyncViewModel internal constructor( } fun triggerBackup() { - // TODO + scope.launch { + emitSyncLoading() + feedSyncRepository.performBackup(forceBackup = true) + emitLastSyncUpdate() + } } fun unlink() { @@ -99,4 +106,28 @@ class ICloudSyncViewModel internal constructor( iCloudSettings.getLastDownloadTimestamp()?.let { timestamp -> dateFormatter.formatDateForLastRefresh(timestamp) } + + private fun emitSyncLoading() { + iCloudSyncUiMutableState.update { oldState -> + if (oldState is AccountConnectionUiState.Linked) { + AccountConnectionUiState.Linked( + syncState = AccountSyncUIState.Loading, + ) + } else { + oldState + } + } + } + + private fun emitLastSyncUpdate() { + iCloudSyncUiMutableState.update { oldState -> + if (oldState is AccountConnectionUiState.Linked) { + AccountConnectionUiState.Linked( + syncState = getSyncState(), + ) + } else { + oldState + } + } + } }