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
+ }
+ }
+ }
}