Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .github/workflows/build-ios.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,13 @@ on:
- '.github/workflows/build-ios.yml'
- '**/ios/**'
- '**/*.podspec'
- '**/scripts/test-ios-utils.sh'
pull_request:
paths:
- '.github/workflows/build-ios.yml'
- '**/ios/**'
- '**/*.podspec'
- '**/scripts/test-ios-utils.sh'
jobs:
build:
name: Build iOS example app
Expand All @@ -20,6 +22,8 @@ jobs:
- uses: actions/checkout@v7
- name: Setup
uses: ./.github/actions/setup
- name: Test iOS file utilities
run: pnpm package test:ios
- name: Install xcpretty
run: gem install xcpretty
- name: Build package
Expand Down
11 changes: 10 additions & 1 deletion apps/docs/content/docs/example.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,16 @@ An example project is available within the [`apps/example` directory](https://gi

## iOS Setup

When setting up the example project for iOS, you will need to follow the [iOS installation steps](/docs/installation/react-native) in order to provide your own iCloud container.
Set `expo.ios.bundleIdentifier` in `apps/example/app.json` to an App ID owned by your Apple team. The existing `react-native-cloud-storage` plugin uses `iCloud.<bundleIdentifier>` as the container identifier. To use a different container, set `iCloudContainerIdentifier` in that plugin's options.

Register the App ID and container for your team. Use a provisioning profile that supports them. See [Expo installation](/docs/installation/expo) for plugin options and [iCloud setup checks](/docs/guides/icloud-sync#check-the-installed-build) for signing and device settings.

From the repository root, run:

```sh
pnpm package build
pnpm example ios
```

## Android Setup

Expand Down
47 changes: 47 additions & 0 deletions apps/docs/content/docs/guides/icloud-sync.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
---
title: iCloud file synchronization
description: Understand local completion, download requests, and iCloud setup checks.
---

## Local completion is not server completion

The iCloud file provider uses Foundation ubiquity APIs for iCloud Documents. It does not use CloudKit records or databases. iOS manages synchronization between the local ubiquity container and iCloud.

- `writeFile()` and `appendFile()` resolve after the local write.
- `uploadFile()` resolves after it copies the source file into the local container. It does not wait for iCloud to receive the file. Google Drive resolves after its HTTP upload completes.
- `triggerSync()` requests a download of one iCloud file. It does not upload local changes, refresh a directory, or wait for download completion. It has no effect on Google Drive.
- `readdir()` combines local entries with an iCloud metadata query. `exists()` and reads also query metadata when the path is not known locally. These queries reflect the device's current view, which can lag behind another device.
- `readFile()` and `downloadFile(remotePath, localPath)` use coordinated reads. iOS can download file contents during that read. The promise resolves after the read or local copy, not just after requesting a download.

Do not report an iCloud backup as uploaded based only on a resolved write or upload promise. The API provides no server-upload completion signal. Check the file from another device when testing cross-device delivery.

For binary transfers, pass an absolute local filesystem path. On iOS, `file://` URLs also work, including percent-encoded filenames. `downloadFile(remotePath, localPath)` does not overwrite an existing local destination.

## Refresh file content explicitly

`useCloudFile` reads on mount and when its path, scope, or instance changes. Its own writes and removals update its content. It does not subscribe to remote file changes or poll for them.

Call the hook's `read()` to refresh content. Its `sync()` only calls `triggerSync()`; it does not read content or wait for a download. Handle read errors while files are unavailable. A successful read is not a guarantee that it contains the latest server version.

## Choose the scope for your data

`CloudStorageScope.AppData` is a valid iCloud file scope. It uses the ubiquity container root for app-private files. `CloudStorageScope.Documents` uses that container's `Documents` directory for user-facing files. Switching to Documents does not fix a signing or synchronization problem.

The `documentsMode: 'legacy_sandbox'` option reads and writes the local app sandbox instead. Use it only for [legacy data migration](/docs/guides/migrating-icloud-documents), not cloud synchronization.

[`CloudKVStorage`](/docs/guides/key-value-storage) uses the separate `NSUbiquitousKeyValueStore` service on iOS. Working key-value synchronization does not prove that iCloud Documents is configured correctly. Key-value `sync()` does not synchronize files.

## Check the installed build

1. Enable iCloud Documents for the Apple App ID. Associate the intended container with that App ID and use a provisioning profile that permits it.
2. Inspect the **signed app**, not only the source entitlements file. For a built app, run:

```sh
codesign -d --entitlements :- /path/to/YourApp.app
```

Confirm `com.apple.developer.icloud-services` includes `CloudDocuments`. Check that `com.apple.developer.ubiquity-container-identifiers` and `com.apple.developer.icloud-container-identifiers` contain the intended container. Both devices must use the same container. This library uses the default ubiquity container; check its selection if you configure multiple containers.
3. On both devices, sign in to the same iCloud account. Enable iCloud Drive and allow the app to use iCloud. Check available iCloud storage and network access. iOS controls transfer timing; reopening the app or requesting a download does not force a server upload.
4. Rebuild and reinstall after entitlement or container changes. For Expo, keep these settings in the app configuration and [plugin options](/docs/installation/expo). Do not rely on edits to generated native projects.

`isCloudAvailable()` and `useIsCloudAvailable()` check for an iCloud identity. They do not check network reachability, access to the selected container, or synchronization progress.
10 changes: 7 additions & 3 deletions apps/docs/content/docs/guides/quick-start.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ Then complete the native setup for your platform:

## Provide a Google Drive access token

iCloud works out of the box on iOS. Google Drive requires an access token that you obtain from the user with a library such as [`@react-native-google-signin/google-signin`](https://github.com/react-native-google-signin/google-signin), then hand to the library:
iCloud needs native configuration and a signed-in iCloud account on iOS, but no access token. Google Drive requires an access token that you obtain from the user with a library such as [`@react-native-google-signin/google-signin`](https://github.com/react-native-google-signin/google-signin), then hand to the library:

```ts
import { CloudStorage, CloudStorageProvider } from 'react-native-cloud-storage';
Expand Down Expand Up @@ -60,7 +60,7 @@ if (await CloudStorage.exists('/user.json', CloudStorageScope.AppData)) {

## Use the React hook

Inside components, [`useCloudFile`](/docs/api/functions/useCloudFile) keeps a single file's content in sync and gives you helpers to write and remove it:
Inside components, [`useCloudFile`](/docs/api/functions/useCloudFile) reads a single file and gives you helpers to read, write, and remove it. It updates content after its own writes and removals. It does not subscribe to remote file changes or poll for them.

```tsx
import { Button, Text, View } from 'react-native';
Expand All @@ -79,7 +79,11 @@ function Profile() {
}
```

To react to whether the cloud is reachable at all (for example, iCloud right after launch or before the user signs in), use [`useIsCloudAvailable`](/docs/api/functions/useIsCloudAvailable).
Call the hook's `read()` to refresh content. Its `sync()` only requests an iCloud download. It does not wait for completion or read the file.

[`useIsCloudAvailable`](/docs/api/functions/useIsCloudAvailable) checks for an iCloud identity or a configured Google Drive token. It does not check network reachability or synchronization status.

See [iCloud file synchronization](/docs/guides/icloud-sync) for completion semantics and device setup checks.

## Next steps

Expand Down
2 changes: 1 addition & 1 deletion apps/docs/content/docs/guides/using-multiple-providers.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ title: Using multiple Cloud Storage Providers
description: Override the default provider or create per-provider CloudStorage instances to back up to iCloud and Google Drive at the same time.
---

By default, the [`CloudStorage`](/docs/api/classes/CloudStorage) API will use a default storage provider based on the platform (CloudKit for iOS, Google Drive for all other platforms).
By default, the [`CloudStorage`](/docs/api/classes/CloudStorage) API will use a default storage provider based on the platform (iCloud for iOS, Google Drive for all other platforms).

If you want to use _one specific provider_ in your app for all platforms, you can override the default provider used by the static default instance by calling [`CloudStorage.setProvider()`](/docs/api/classes/CloudStorage#setprovider) statically.

Expand Down
6 changes: 3 additions & 3 deletions apps/docs/content/docs/index.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,8 @@ React Native Cloud Storage allows you to use iCloud (iOS only) and Google Drive

- **File storage with `fs`-like API** — `readFile`, `writeFile`, `appendFile`, `readdir`, `mkdir`, `stat`, `unlink`, and more, modeled on Node's `fs` so there's nothing new to learn.
- **Key-value storage** — save preferences and small app state through native iCloud key-value storage or an emulated Google Drive store.
- **Two providers, one API** — [iCloud](/docs/api/enumerations/CloudStorageProvider) (via a native CloudKit module) and [Google Drive](/docs/installation/configure-google-drive) (via the REST API). Use the platform default or [switch between them at runtime](/docs/guides/using-multiple-providers).
- **React hooks** — [`useCloudFile`](/docs/api/functions/useCloudFile), [`useCloudKV`](/docs/api/functions/useCloudKV), and [`useIsCloudAvailable`](/docs/api/functions/useIsCloudAvailable) keep your components in sync with cloud state.
- **Two providers, one API** — [iCloud](/docs/api/enumerations/CloudStorageProvider) (via Foundation ubiquity APIs for iCloud Documents) and [Google Drive](/docs/installation/configure-google-drive) (via the REST API). Use the platform default or [switch between them at runtime](/docs/guides/using-multiple-providers).
- **React hooks** — [`useCloudFile`](/docs/api/functions/useCloudFile), [`useCloudKV`](/docs/api/functions/useCloudKV), and [`useIsCloudAvailable`](/docs/api/functions/useIsCloudAvailable) expose file content, key-value state, and provider availability. The file hook does not monitor remote changes.
- **Scopes** — read and write in a hidden, app-private container ([`AppData`](/docs/api/enumerations/CloudStorageScope)) or the user-visible iCloud Drive / Google Drive [`Documents`](/docs/api/enumerations/CloudStorageScope) folder.
- **Expo config plugin** — configures the native iCloud capability automatically, with no manual Xcode steps.
- **Built for the New Architecture** — ships as a Turbo Module and is fully typed with TypeScript.
Expand Down Expand Up @@ -38,7 +38,7 @@ Prefer hooks? [`useCloudFile`](/docs/api/functions/useCloudFile) and [`useCloudK

| Provider | iOS | Android | Notes |
| ------------ | :-: | :-----: | ------------------------------------------------------------- |
| iCloud | ✅ | — | Backed by a native CloudKit module; available out of the box. |
| iCloud | ✅ | — | Uses iCloud Documents; requires native setup and an iCloud account. |
| Google Drive | ✅ | ✅ | Backed by the Drive REST API; you provide an access token. |

By default, the library picks the right provider for each platform: iCloud on iOS, Google Drive everywhere else. You can override this or even use both providers at once.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,10 @@ Please note that filenames are not unique in Google Drive. There can be multiple
</Callout>

<Callout type="info">
Be aware that all file operations on Google Drive will take severely more time than on iCloud. This is because iCloud is implemented using a direct native API that uses a local mirror of the cloud filesystem (CloudKit) while Google Drive is implemented using the HTTP REST API. A file read operation that might only take a split second on iCloud might take several seconds on Google Drive.
Google Drive operations use the HTTP REST API. iCloud file operations use Foundation ubiquity APIs and a local iCloud Documents container, not CloudKit. Local iCloud operations can finish before iOS synchronizes the changes. Reads can also need a download. See [iCloud file synchronization](/docs/guides/icloud-sync).
</Callout>

While iCloud for iOS devices works out of the box, Google Drive support requires some additional setup. Specifically, you will need to get and provide an access token for the Google Drive API. This module does **not** provide any way of acquiring such a token from the user, as it is out of scope.
iCloud needs native setup on iOS but no access token. For Google Drive, you must acquire and provide an access token. This module does **not** acquire tokens from the user.

You therefore need to acquire the token with another library. A popular choice is [`@react-native-google-signin/google-signin`](https://github.com/react-native-google-signin/google-signin). Whatever you do, you will also need a Google OAuth client ID in order to make authentication requests. The linked Expo module has good documentation on this topic. When creating this client ID, make sure to request at least the `https://www.googleapis.com/auth/drive.appdata` scope. This will allow you to use the [`CloudStorageScope.AppData`](/docs/api/enumerations/CloudStorageScope) scope of this library. If you also want to access `CloudStorageScope.Documents`, you will also require the `https://www.googleapis.com/auth/drive` scope, which is a restricted Google API scope. This means your app needs to be audited in order to use it. For more documentation on this matter, consult the [Google documentation](https://developers.google.com/identity/protocols/oauth2/production-readiness/restricted-scope-verification).

Expand Down
8 changes: 4 additions & 4 deletions apps/example/src/screens/home/home-file-operations-card.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -99,11 +99,11 @@ const HomeFileOperationsCard: React.FC<HomeFileOperationsCardProps> = ({
onLoadingChange(true);
try {
const file = result.assets[0];
await cloudStorage.uploadFile(filePath, file.uri.replace(/^file:\/\//, ''), {
await cloudStorage.uploadFile(filePath, decodeURIComponent(file.uri.replace(/^file:\/\//, '')), {
mimeType: file.mimeType ?? 'application/octet-stream',
});
setStats(await cloudStorage.stat(filePath));
Alert.alert('File uploaded', 'File uploaded successfully.');
Alert.alert('File saved', 'File saved. On iCloud, iOS uploads the local container copy asynchronously.');
} catch (error) {
console.warn(error);
} finally {
Expand All @@ -116,7 +116,7 @@ const HomeFileOperationsCard: React.FC<HomeFileOperationsCardProps> = ({
try {
const directory = FileSystem.cacheDirectory;
if (!directory) throw new Error('Could not get cache directory');
const newFilename = directory.replace(/^file:\/\//, '') + Crypto.randomUUID();
const newFilename = decodeURIComponent(directory.replace(/^file:\/\//, '')) + Crypto.randomUUID();
await cloudStorage.downloadFile(filePath, newFilename);
Alert.alert('File downloaded', `File downloaded to ${newFilename}`);
} catch (error) {
Expand Down Expand Up @@ -154,7 +154,7 @@ const HomeFileOperationsCard: React.FC<HomeFileOperationsCardProps> = ({
onLoadingChange(true);
try {
await cloudStorage.triggerSync(filePath);
Alert.alert('File download', 'File downloaded successfully.');
Alert.alert('Download requested', 'iOS received the download request. This does not confirm completion.');
} catch (error) {
console.warn(error);
} finally {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,12 @@ public class CloudStorageCloudKit: NSObject {
@objc(appendToFile:withData:withScope:withResolver:withRejecter:)
public func appendToFile(path: String, data: String, scope: String, resolve: @escaping RCTPromiseResolveBlock, reject: @escaping RCTPromiseRejectBlock) {
withBackgroundPromise(resolve: resolve, reject: reject) {
let fileUrl = try CloudKitUtils.getFileURL(path: path, scope: scope)
let fileUrl: URL
do {
fileUrl = try CloudKitUtils.getFileURL(path: path, scope: scope, true)
} catch CloudStorageError.fileNotFound {
fileUrl = try CloudKitUtils.getFileURL(path: path, scope: scope)
}
return try FileUtils.appendFile(fileUrl: fileUrl, content: data)
}
}
Expand Down Expand Up @@ -66,15 +71,15 @@ public class CloudStorageCloudKit: NSObject {
@objc(deleteFile:withScope:withResolver:withRejecter:)
public func deleteFile(path: String, scope: String, resolve: @escaping RCTPromiseResolveBlock, reject: @escaping RCTPromiseRejectBlock) {
withBackgroundPromise(resolve: resolve, reject: reject) {
let fileUrl = try CloudKitUtils.getFileURL(path: path, scope: scope)
let fileUrl = try CloudKitUtils.getFileURL(path: path, scope: scope, true)
return try FileUtils.deleteFileOrDirectory(fileUrl: fileUrl)
}
}

@objc(deleteDirectory:withRecursive:withScope:withResolver:withRejecter:)
public func deleteDirectory(path: String, recursive _: Bool, scope: String, resolve: @escaping RCTPromiseResolveBlock, reject: @escaping RCTPromiseRejectBlock) {
withBackgroundPromise(resolve: resolve, reject: reject) {
let fileUrl = try CloudKitUtils.getFileURL(path: path, scope: scope)
let fileUrl = try CloudKitUtils.getFileURL(path: path, scope: scope, true)
return try FileUtils.deleteFileOrDirectory(fileUrl: fileUrl)
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -179,7 +179,7 @@ public class CloudStorageLocalFileSystem: NSObject {

guard let httpResponse = response as? HTTPURLResponse, (200 ... 299).contains(httpResponse.statusCode) else {
let httpResponse = response as? HTTPURLResponse
let message = "Upload failed for path \(sanitizedPath) with status code: \(httpResponse?.statusCode ?? -1)"
let message = "Upload failed for path \(localPath) with status code: \(httpResponse?.statusCode ?? -1)"
let cloudError = CloudStorageError.networkError(message: message)
reject(cloudError.code, cloudError.message, nil)
return
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -70,18 +70,18 @@ enum CloudKitUtils {
// append path to scope directory
let fileUrl = directory.appendingPathComponent(FileUtils.sanitizePath(path: path))

if shouldExist != nil {
if let shouldExist {
var fileExists = try FileUtils.checkFileExists(fileUrl: fileUrl)
if !fileExists, scope != .documentsLegacy {
let urls = try ICloudMetadataQuery().gather()
if let discoveredUrl = urls.first(where: { canonicalPath($0) == canonicalPath(fileUrl) }), shouldExist == true {
if let discoveredUrl = urls.first(where: { canonicalPath($0) == canonicalPath(fileUrl) }), shouldExist {
return discoveredUrl
}
fileExists = contains(fileUrl, in: urls)
}
if shouldExist! && !fileExists {
if shouldExist && !fileExists {
throw CloudStorageError.fileNotFound(path: path)
} else if !shouldExist! && fileExists {
} else if !shouldExist && fileExists {
throw CloudStorageError.fileAlreadyExists(path: path)
}
}
Expand Down Expand Up @@ -112,7 +112,10 @@ enum CloudKitUtils {

static func contains(_ url: URL, in metadataURLs: [URL]) -> Bool {
let path = canonicalPath(url)
return metadataURLs.contains { canonicalPath($0) == path || canonicalPath($0).hasPrefix(path + "/") }
return metadataURLs.contains { url in
let candidate = canonicalPath(url)
return candidate == path || candidate.hasPrefix(path + "/")
}
}

static func directoryEntries(at directoryUrl: URL, localNames: [String], metadataURLs: [URL]) -> [String] {
Expand Down
Loading