diff --git a/android/app/build.gradle b/android/app/build.gradle
index d330c91..0f5ff5f 100644
--- a/android/app/build.gradle
+++ b/android/app/build.gradle
@@ -92,7 +92,7 @@ android {
applicationId 'com.builderpro.opennotes'
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
- versionCode 11
+ versionCode 12
versionName "1.3"
buildConfigField "String", "REACT_NATIVE_RELEASE_LEVEL", "\"${findProperty('reactNativeReleaseLevel') ?: 'stable'}\""
diff --git a/app.json b/app.json
index ec263e9..1e732b5 100644
--- a/app.json
+++ b/app.json
@@ -16,7 +16,7 @@
"ios": {
"bundleIdentifier": "com.builderpro.opennotes",
"icon": "./assets/icon.png",
- "buildNumber": "11",
+ "buildNumber": "12",
"supportsTablet": true,
"usesIcloudStorage": true,
"entitlements": {
@@ -65,7 +65,7 @@
},
"android": {
"package": "com.builderpro.opennotes",
- "versionCode": 11,
+ "versionCode": 12,
"adaptiveIcon": {
"foregroundImage": "./assets/adaptive-icon.png",
"backgroundColor": "#F7F7F4"
diff --git a/ios/OpenNotes.xcodeproj/project.pbxproj b/ios/OpenNotes.xcodeproj/project.pbxproj
index d71e9f4..53a338e 100644
--- a/ios/OpenNotes.xcodeproj/project.pbxproj
+++ b/ios/OpenNotes.xcodeproj/project.pbxproj
@@ -364,7 +364,7 @@
CODE_SIGN_ENTITLEMENTS = OpenNotes/OpenNotes.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
- CURRENT_PROJECT_VERSION = 11;
+ CURRENT_PROJECT_VERSION = 12;
DEVELOPMENT_TEAM = U2CPXQV7AJ;
ENABLE_BITCODE = NO;
GCC_PREPROCESSOR_DEFINITIONS = (
@@ -403,7 +403,7 @@
CODE_SIGN_ENTITLEMENTS = OpenNotes/OpenNotes.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
- CURRENT_PROJECT_VERSION = 11;
+ CURRENT_PROJECT_VERSION = 12;
DEVELOPMENT_TEAM = U2CPXQV7AJ;
INFOPLIST_FILE = OpenNotes/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 15.1;
diff --git a/ios/OpenNotes/ICloudBackupModule.m b/ios/OpenNotes/ICloudBackupModule.m
index 7aaf7ad..8b3f146 100644
--- a/ios/OpenNotes/ICloudBackupModule.m
+++ b/ios/OpenNotes/ICloudBackupModule.m
@@ -32,6 +32,11 @@ @interface RCT_EXTERN_MODULE(ICloudBackupModule, NSObject)
resolver:(RCTPromiseResolveBlock)resolver
rejecter:(RCTPromiseRejectBlock)rejecter)
+RCT_EXTERN_METHOD(uploadStatus:(NSString *)dir
+ timeoutMs:(nonnull NSNumber *)timeoutMs
+ resolver:(RCTPromiseResolveBlock)resolver
+ rejecter:(RCTPromiseRejectBlock)rejecter)
+
RCT_EXTERN_METHOD(listCloudFiles:(NSString *)dir
timeoutMs:(nonnull NSNumber *)timeoutMs
resolver:(RCTPromiseResolveBlock)resolver
diff --git a/ios/OpenNotes/ICloudBackupModule.swift b/ios/OpenNotes/ICloudBackupModule.swift
index 500d2c7..61c60c2 100644
--- a/ios/OpenNotes/ICloudBackupModule.swift
+++ b/ios/OpenNotes/ICloudBackupModule.swift
@@ -49,10 +49,12 @@ class ICloudBackupModule: NSObject {
at: toUrl.deletingLastPathComponent(),
withIntermediateDirectories: true
)
- if FileManager.default.fileExists(atPath: toUrl.path) {
- try FileManager.default.removeItem(at: toUrl)
+ try Self.coordinatedWrite(toUrl, options: .forReplacing) { url in
+ if FileManager.default.fileExists(atPath: url.path) {
+ try FileManager.default.removeItem(at: url)
+ }
+ try FileManager.default.copyItem(at: fromUrl, to: url)
}
- try FileManager.default.copyItem(at: fromUrl, to: toUrl)
resolver(true)
} catch {
rejecter("E_COPY", "Copy failed: \(fromUrl.lastPathComponent)", error)
@@ -71,7 +73,16 @@ class ICloudBackupModule: NSObject {
at: url.deletingLastPathComponent(),
withIntermediateDirectories: true
)
- try contents.write(to: url, atomically: true, encoding: .utf8)
+ guard let data = contents.data(using: .utf8) else {
+ rejecter("E_WRITE", "Write failed (encoding): \(url.lastPathComponent)", nil)
+ return
+ }
+ // Deliberately NOT String.write(atomically:) - the rename-into-place
+ // it performs is what left files invisible to the sync daemon. The
+ // file coordinator provides the crash consistency instead.
+ try Self.coordinatedWrite(url, options: .forReplacing) { coordUrl in
+ try data.write(to: coordUrl, options: [])
+ }
resolver(true)
} catch {
rejecter("E_WRITE", "Write failed: \(url.lastPathComponent)", error)
@@ -105,7 +116,9 @@ class ICloudBackupModule: NSObject {
let url = URL(fileURLWithPath: Self.plainPath(path))
do {
if FileManager.default.fileExists(atPath: url.path) {
- try FileManager.default.removeItem(at: url)
+ try Self.coordinatedWrite(url, options: .forDeleting) { coordUrl in
+ try FileManager.default.removeItem(at: coordUrl)
+ }
}
resolver(true)
} catch {
@@ -152,6 +165,25 @@ class ICloudBackupModule: NSObject {
}
}
+ /// All mutations inside the ubiquity container are file-coordinated:
+ /// uncoordinated writes (and atomic rename-into-place in particular) are
+ /// not reliably picked up by the iCloud sync daemon - observed in
+ /// production as "directories sync, files never upload".
+ private static func coordinatedWrite(
+ _ url: URL, options: NSFileCoordinator.WritingOptions,
+ _ body: (URL) throws -> Void
+ ) throws {
+ var coordError: NSError?
+ var innerError: Error?
+ NSFileCoordinator(filePresenter: nil).coordinate(
+ writingItemAt: url, options: options, error: &coordError
+ ) { coordinatedUrl in
+ do { try body(coordinatedUrl) } catch { innerError = error }
+ }
+ if let error = coordError { throw error }
+ if let error = innerError { throw error }
+ }
+
private static func plainPath(_ path: String) -> String {
if path.hasPrefix("file://"), let url = URL(string: path) {
return url.path
@@ -187,6 +219,67 @@ class ICloudBackupModule: NSObject {
}
}
+ /// Reports how many files under `dir` iCloud has actually uploaded to the
+ /// server, via NSMetadataUbiquitousItemIsUploadedKey. This is the only
+ /// honest signal that a backup is durable - a file sitting in the local
+ /// container replica is NOT safe until uploaded.
+ @objc
+ func uploadStatus(_ dir: String,
+ timeoutMs: NSNumber,
+ resolver: @escaping RCTPromiseResolveBlock,
+ rejecter: @escaping RCTPromiseRejectBlock) {
+ DispatchQueue.main.async {
+ let resolvedBase = URL(fileURLWithPath: Self.plainPath(dir))
+ .resolvingSymlinksInPath().path
+ let basePath = resolvedBase.hasSuffix("/") ? resolvedBase : resolvedBase + "/"
+ let query = NSMetadataQuery()
+ query.searchScopes = [
+ NSMetadataQueryUbiquitousDocumentsScope,
+ NSMetadataQueryUbiquitousDataScope,
+ ]
+ query.predicate = NSPredicate(format: "%K LIKE '*'", NSMetadataItemFSNameKey)
+
+ var finished = false
+ var observer: NSObjectProtocol?
+ func complete() {
+ guard !finished else { return }
+ finished = true
+ query.disableUpdates()
+ query.stop()
+ if let obs = observer { NotificationCenter.default.removeObserver(obs) }
+ var total = 0
+ var uploaded = 0
+ var pending: [String] = []
+ for case let item as NSMetadataItem in query.results {
+ guard let rawPath = item.value(forAttribute: NSMetadataItemPathKey) as? String
+ else { continue }
+ let itemPath = URL(fileURLWithPath: rawPath).resolvingSymlinksInPath().path
+ guard itemPath.hasPrefix(basePath) else { continue }
+ var isDir: ObjCBool = false
+ if FileManager.default.fileExists(atPath: itemPath, isDirectory: &isDir),
+ isDir.boolValue { continue }
+ total += 1
+ let isUploaded = (item.value(
+ forAttribute: NSMetadataUbiquitousItemIsUploadedKey) as? NSNumber)?.boolValue ?? false
+ if isUploaded {
+ uploaded += 1
+ } else {
+ pending.append(String(itemPath.dropFirst(basePath.count)))
+ }
+ }
+ resolver(["total": total, "uploaded": uploaded, "pending": pending])
+ }
+
+ observer = NotificationCenter.default.addObserver(
+ forName: .NSMetadataQueryDidFinishGathering, object: query, queue: .main
+ ) { _ in complete() }
+ DispatchQueue.main.asyncAfter(
+ deadline: .now() + timeoutMs.doubleValue / 1000.0
+ ) { complete() }
+ query.start()
+ }
+ }
+
/// Lists every item iCloud knows about under the container's Documents dir
/// via NSMetadataQuery - the canonical discovery API. Unlike a directory
/// walk it sees items whose contents have not been downloaded yet, and
diff --git a/ios/OpenNotes/Info.plist b/ios/OpenNotes/Info.plist
index e8d6c72..fa7730d 100644
--- a/ios/OpenNotes/Info.plist
+++ b/ios/OpenNotes/Info.plist
@@ -54,7 +54,7 @@
CFBundleVersion
- 11
+ 12
ITSAppUsesNonExemptEncryption
LSMinimumSystemVersion
diff --git a/src/hooks/useBackup.ts b/src/hooks/useBackup.ts
index 864f041..f7875c6 100644
--- a/src/hooks/useBackup.ts
+++ b/src/hooks/useBackup.ts
@@ -3,6 +3,7 @@ import { Alert, Platform } from 'react-native';
import { catalogStore } from '../services/catalogEnv';
import {
checkBackupRestoreAvailable,
+ getBackupUploadStatus,
isBackupAvailable,
isBackupEnabled,
performBackupRestore,
@@ -43,6 +44,7 @@ export function useBackup(
): UseBackupResult {
const [enabled, setEnabled] = useState(null);
const [available, setAvailable] = useState(null);
+ const [pendingUploads, setPendingUploads] = useState(0);
const restorePromptShownRef = useRef(false);
useEffect(() => {
@@ -56,6 +58,10 @@ export function useBackup(
if (cancelled) return;
setEnabled(isEnabled);
setAvailable(isAvailable);
+ if (isEnabled && isAvailable) {
+ const status = await getBackupUploadStatus();
+ if (!cancelled && status) setPendingUploads(status.pending);
+ }
})();
return () => {
cancelled = true;
@@ -156,7 +162,9 @@ export function useBackup(
? t.backup.statusOff
: available === false
? t.backup.statusUnavailable
- : t.backup.statusAutomatic;
+ : pendingUploads > 0
+ ? t.backup.statusUploading(pendingUploads)
+ : t.backup.statusAutomatic;
return {
backupSupported: BACKUP_SUPPORTED,
diff --git a/src/i18n/locales/de.ts b/src/i18n/locales/de.ts
index 9db41a5..7b8ada7 100644
--- a/src/i18n/locales/de.ts
+++ b/src/i18n/locales/de.ts
@@ -98,6 +98,8 @@ export const de: Strings = {
backup: {
rowTitle: 'iCloud-Backup',
statusAutomatic: 'Automatisch — deine Notizen überstehen das Löschen der App',
+ statusUploading: (pending: number) =>
+ `Sichert — ${pending} ${pending === 1 ? 'Datei wird' : 'Dateien werden'} noch zu iCloud hochgeladen`,
statusUnavailable: 'iCloud nicht verfügbar — melde dich bei iCloud an, um deine Notizen zu schützen',
statusOff: 'Aus — Notizen existieren nur auf diesem Gerät',
turnOffTitle: 'iCloud-Backup ausschalten?',
diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts
index e3ad3ab..5b3d25b 100644
--- a/src/i18n/locales/en.ts
+++ b/src/i18n/locales/en.ts
@@ -98,6 +98,8 @@ export const en = {
backup: {
rowTitle: 'iCloud backup',
statusAutomatic: 'Automatic — your notes survive app deletion',
+ statusUploading: (pending: number) =>
+ `Backing up — ${pending} ${pending === 1 ? 'file' : 'files'} still uploading to iCloud`,
statusUnavailable: 'iCloud unavailable — sign in to iCloud to protect your notes',
statusOff: 'Off — notes exist only on this device',
turnOffTitle: 'Turn off iCloud backup?',
diff --git a/src/i18n/locales/es.ts b/src/i18n/locales/es.ts
index 832c989..e8d8dc4 100644
--- a/src/i18n/locales/es.ts
+++ b/src/i18n/locales/es.ts
@@ -98,6 +98,8 @@ export const es: Strings = {
backup: {
rowTitle: 'Copia en iCloud',
statusAutomatic: 'Automática — tus notas sobreviven si borras la app',
+ statusUploading: (pending: number) =>
+ `Copiando — ${pending} archivo${pending === 1 ? '' : 's'} aún subiendo a iCloud`,
statusUnavailable: 'iCloud no disponible — inicia sesión en iCloud para proteger tus notas',
statusOff: 'Desactivada — las notas solo existen en este dispositivo',
turnOffTitle: '¿Desactivar la copia en iCloud?',
diff --git a/src/i18n/locales/fr.ts b/src/i18n/locales/fr.ts
index 18e41b6..ca26165 100644
--- a/src/i18n/locales/fr.ts
+++ b/src/i18n/locales/fr.ts
@@ -98,6 +98,8 @@ export const fr: Strings = {
backup: {
rowTitle: 'Sauvegarde iCloud',
statusAutomatic: 'Automatique — vos notes survivent à la suppression de l’app',
+ statusUploading: (pending: number) =>
+ `Sauvegarde — ${pending} fichier${pending === 1 ? '' : 's'} encore en cours d’envoi vers iCloud`,
statusUnavailable: 'iCloud indisponible — connectez-vous à iCloud pour protéger vos notes',
statusOff: 'Désactivée — les notes n’existent que sur cet appareil',
turnOffTitle: 'Désactiver la sauvegarde iCloud ?',
diff --git a/src/i18n/locales/it.ts b/src/i18n/locales/it.ts
index ac91e8b..655473c 100644
--- a/src/i18n/locales/it.ts
+++ b/src/i18n/locales/it.ts
@@ -98,6 +98,8 @@ export const it: Strings = {
backup: {
rowTitle: 'Backup iCloud',
statusAutomatic: 'Automatico — le tue note sopravvivono all’eliminazione dell’app',
+ statusUploading: (pending: number) =>
+ `Backup in corso — ${pending} file ancora in caricamento su iCloud`,
statusUnavailable: 'iCloud non disponibile — accedi a iCloud per proteggere le tue note',
statusOff: 'Disattivato — le note esistono solo su questo dispositivo',
turnOffTitle: 'Disattivare il backup iCloud?',
diff --git a/src/i18n/locales/ja.ts b/src/i18n/locales/ja.ts
index 6feddc5..fd3f06e 100644
--- a/src/i18n/locales/ja.ts
+++ b/src/i18n/locales/ja.ts
@@ -98,6 +98,8 @@ export const ja: Strings = {
backup: {
rowTitle: 'iCloudバックアップ',
statusAutomatic: '自動 — アプリを削除してもノートは残ります',
+ statusUploading: (pending: number) =>
+ `バックアップ中 — ${pending}個のファイルをiCloudにアップロード中`,
statusUnavailable: 'iCloudを利用できません — iCloudにサインインしてノートを守りましょう',
statusOff: 'オフ — ノートはこの端末にのみ存在します',
turnOffTitle: 'iCloudバックアップをオフにしますか?',
diff --git a/src/i18n/locales/ko.ts b/src/i18n/locales/ko.ts
index 41d8959..d763390 100644
--- a/src/i18n/locales/ko.ts
+++ b/src/i18n/locales/ko.ts
@@ -98,6 +98,8 @@ export const ko: Strings = {
backup: {
rowTitle: 'iCloud 백업',
statusAutomatic: '자동 — 앱을 삭제해도 노트가 남습니다',
+ statusUploading: (pending: number) =>
+ `백업 중 — 파일 ${pending}개를 iCloud에 업로드하는 중`,
statusUnavailable: 'iCloud 사용 불가 — iCloud에 로그인해 노트를 보호하세요',
statusOff: '꺼짐 — 노트가 이 기기에만 존재합니다',
turnOffTitle: 'iCloud 백업을 끌까요?',
diff --git a/src/i18n/locales/nl.ts b/src/i18n/locales/nl.ts
index caf7601..db0828c 100644
--- a/src/i18n/locales/nl.ts
+++ b/src/i18n/locales/nl.ts
@@ -98,6 +98,8 @@ export const nl: Strings = {
backup: {
rowTitle: 'iCloud-back-up',
statusAutomatic: 'Automatisch — je notities overleven het verwijderen van de app',
+ statusUploading: (pending: number) =>
+ `Back-up bezig — ${pending} bestand${pending === 1 ? ' wordt' : 'en worden'} nog geüpload naar iCloud`,
statusUnavailable: 'iCloud niet beschikbaar — log in bij iCloud om je notities te beschermen',
statusOff: 'Uit — notities bestaan alleen op dit apparaat',
turnOffTitle: 'iCloud-back-up uitzetten?',
diff --git a/src/i18n/locales/pt.ts b/src/i18n/locales/pt.ts
index 7056d85..361bf2a 100644
--- a/src/i18n/locales/pt.ts
+++ b/src/i18n/locales/pt.ts
@@ -98,6 +98,8 @@ export const pt: Strings = {
backup: {
rowTitle: 'Backup no iCloud',
statusAutomatic: 'Automático — suas notas sobrevivem se o app for apagado',
+ statusUploading: (pending: number) =>
+ `Fazendo backup — ${pending} arquivo${pending === 1 ? '' : 's'} ainda enviando para o iCloud`,
statusUnavailable: 'iCloud indisponível — entre no iCloud para proteger suas notas',
statusOff: 'Desativado — as notas existem apenas neste aparelho',
turnOffTitle: 'Desativar o backup no iCloud?',
diff --git a/src/i18n/locales/ru.ts b/src/i18n/locales/ru.ts
index d578195..3ffa035 100644
--- a/src/i18n/locales/ru.ts
+++ b/src/i18n/locales/ru.ts
@@ -107,6 +107,8 @@ export const ru: Strings = {
backup: {
rowTitle: 'Копия в iCloud',
statusAutomatic: 'Автоматически — заметки переживут удаление приложения',
+ statusUploading: (pending: number) =>
+ `Копирование — ещё ${pending} файл(ов) загружается в iCloud`,
statusUnavailable: 'iCloud недоступен — войдите в iCloud, чтобы защитить заметки',
statusOff: 'Выключено — заметки существуют только на этом устройстве',
turnOffTitle: 'Выключить копию в iCloud?',
diff --git a/src/i18n/locales/zhHans.ts b/src/i18n/locales/zhHans.ts
index 46a2bdf..1d8c495 100644
--- a/src/i18n/locales/zhHans.ts
+++ b/src/i18n/locales/zhHans.ts
@@ -98,6 +98,8 @@ export const zhHans: Strings = {
backup: {
rowTitle: 'iCloud 备份',
statusAutomatic: '自动 — 即使删除应用,笔记也会保留',
+ statusUploading: (pending: number) =>
+ `备份中 — 还有 ${pending} 个文件正在上传到 iCloud`,
statusUnavailable: 'iCloud 不可用 — 请登录 iCloud 以保护你的笔记',
statusOff: '已关闭 — 笔记仅存在于此设备',
turnOffTitle: '关闭 iCloud 备份?',
diff --git a/src/i18n/locales/zhHant.ts b/src/i18n/locales/zhHant.ts
index 0783e46..07d7b5d 100644
--- a/src/i18n/locales/zhHant.ts
+++ b/src/i18n/locales/zhHant.ts
@@ -98,6 +98,8 @@ export const zhHant: Strings = {
backup: {
rowTitle: 'iCloud 備份',
statusAutomatic: '自動 — 即使刪除 App,筆記也會保留',
+ statusUploading: (pending: number) =>
+ `備份中 — 還有 ${pending} 個檔案正在上傳到 iCloud`,
statusUnavailable: 'iCloud 無法使用 — 請登入 iCloud 以保護你的筆記',
statusOff: '已關閉 — 筆記僅存在於此裝置',
turnOffTitle: '關閉 iCloud 備份?',
diff --git a/src/services/backupService.ts b/src/services/backupService.ts
index 5e9c1fb..3e65914 100644
--- a/src/services/backupService.ts
+++ b/src/services/backupService.ts
@@ -38,6 +38,10 @@ type ICloudBackupModuleType = {
dir: string,
timeoutMs: number,
) => Promise>;
+ uploadStatus?: (
+ dir: string,
+ timeoutMs: number,
+ ) => Promise<{ total: number; uploaded: number; pending: string[] }>;
};
const ICloudBackupModule = NativeModules.ICloudBackupModule as
@@ -328,6 +332,32 @@ export async function isBackupAvailable(): Promise {
return (await env.getContainerDir()) !== null;
}
+export interface BackupUploadStatus {
+ total: number;
+ uploaded: number;
+ pending: number;
+}
+
+/**
+ * Asks iCloud how much of the mirrored backup has actually reached the
+ * server. Local container copies are NOT durable until uploaded - this is
+ * the only honest basis for telling the user their notes are safe. Returns
+ * null when unknowable (no module, dev override, container unavailable).
+ */
+export async function getBackupUploadStatus(): Promise {
+ if (!ICloudBackupModule?.uploadStatus) return null;
+ if (await devContainerOverride()) return null;
+ const containerDir = await env.getContainerDir();
+ if (!containerDir) return null;
+ try {
+ const status = await ICloudBackupModule.uploadStatus(containerDir, 10000);
+ return { total: status.total, uploaded: status.uploaded, pending: status.pending.length };
+ } catch (error) {
+ env.warn('[backupService] upload status query failed', error);
+ return null;
+ }
+}
+
export async function setBackupEnabled(enabled: boolean): Promise {
await AsyncStorage.setItem(ENABLED_KEY, enabled ? 'true' : 'false');
if (enabled) {