Skip to content
Merged
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
2 changes: 1 addition & 1 deletion android/app/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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'}\""
Expand Down
4 changes: 2 additions & 2 deletions app.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
"ios": {
"bundleIdentifier": "com.builderpro.opennotes",
"icon": "./assets/icon.png",
"buildNumber": "11",
"buildNumber": "12",
"supportsTablet": true,
"usesIcloudStorage": true,
"entitlements": {
Expand Down Expand Up @@ -65,7 +65,7 @@
},
"android": {
"package": "com.builderpro.opennotes",
"versionCode": 11,
"versionCode": 12,
"adaptiveIcon": {
"foregroundImage": "./assets/adaptive-icon.png",
"backgroundColor": "#F7F7F4"
Expand Down
4 changes: 2 additions & 2 deletions ios/OpenNotes.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -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 = (
Expand Down Expand Up @@ -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;
Expand Down
5 changes: 5 additions & 0 deletions ios/OpenNotes/ICloudBackupModule.m
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
103 changes: 98 additions & 5 deletions ios/OpenNotes/ICloudBackupModule.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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)
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion ios/OpenNotes/Info.plist
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@
</dict>
</array>
<key>CFBundleVersion</key>
<string>11</string>
<string>12</string>
<key>ITSAppUsesNonExemptEncryption</key>
<false/>
<key>LSMinimumSystemVersion</key>
Expand Down
10 changes: 9 additions & 1 deletion src/hooks/useBackup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { Alert, Platform } from 'react-native';
import { catalogStore } from '../services/catalogEnv';
import {
checkBackupRestoreAvailable,
getBackupUploadStatus,
isBackupAvailable,
isBackupEnabled,
performBackupRestore,
Expand Down Expand Up @@ -43,6 +44,7 @@ export function useBackup(
): UseBackupResult {
const [enabled, setEnabled] = useState<boolean | null>(null);
const [available, setAvailable] = useState<boolean | null>(null);
const [pendingUploads, setPendingUploads] = useState(0);
const restorePromptShownRef = useRef(false);

useEffect(() => {
Expand All @@ -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;
Expand Down Expand Up @@ -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,
Expand Down
2 changes: 2 additions & 0 deletions src/i18n/locales/de.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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?',
Expand Down
2 changes: 2 additions & 0 deletions src/i18n/locales/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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?',
Expand Down
2 changes: 2 additions & 0 deletions src/i18n/locales/es.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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?',
Expand Down
2 changes: 2 additions & 0 deletions src/i18n/locales/fr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 ?',
Expand Down
2 changes: 2 additions & 0 deletions src/i18n/locales/it.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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?',
Expand Down
2 changes: 2 additions & 0 deletions src/i18n/locales/ja.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,8 @@ export const ja: Strings = {
backup: {
rowTitle: 'iCloudバックアップ',
statusAutomatic: '自動 — アプリを削除してもノートは残ります',
statusUploading: (pending: number) =>
`バックアップ中 — ${pending}個のファイルをiCloudにアップロード中`,
statusUnavailable: 'iCloudを利用できません — iCloudにサインインしてノートを守りましょう',
statusOff: 'オフ — ノートはこの端末にのみ存在します',
turnOffTitle: 'iCloudバックアップをオフにしますか?',
Expand Down
2 changes: 2 additions & 0 deletions src/i18n/locales/ko.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,8 @@ export const ko: Strings = {
backup: {
rowTitle: 'iCloud 백업',
statusAutomatic: '자동 — 앱을 삭제해도 노트가 남습니다',
statusUploading: (pending: number) =>
`백업 중 — 파일 ${pending}개를 iCloud에 업로드하는 중`,
statusUnavailable: 'iCloud 사용 불가 — iCloud에 로그인해 노트를 보호하세요',
statusOff: '꺼짐 — 노트가 이 기기에만 존재합니다',
turnOffTitle: 'iCloud 백업을 끌까요?',
Expand Down
2 changes: 2 additions & 0 deletions src/i18n/locales/nl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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?',
Expand Down
2 changes: 2 additions & 0 deletions src/i18n/locales/pt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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?',
Expand Down
2 changes: 2 additions & 0 deletions src/i18n/locales/ru.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,8 @@ export const ru: Strings = {
backup: {
rowTitle: 'Копия в iCloud',
statusAutomatic: 'Автоматически — заметки переживут удаление приложения',
statusUploading: (pending: number) =>
`Копирование — ещё ${pending} файл(ов) загружается в iCloud`,
statusUnavailable: 'iCloud недоступен — войдите в iCloud, чтобы защитить заметки',
statusOff: 'Выключено — заметки существуют только на этом устройстве',
turnOffTitle: 'Выключить копию в iCloud?',
Expand Down
2 changes: 2 additions & 0 deletions src/i18n/locales/zhHans.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,8 @@ export const zhHans: Strings = {
backup: {
rowTitle: 'iCloud 备份',
statusAutomatic: '自动 — 即使删除应用,笔记也会保留',
statusUploading: (pending: number) =>
`备份中 — 还有 ${pending} 个文件正在上传到 iCloud`,
statusUnavailable: 'iCloud 不可用 — 请登录 iCloud 以保护你的笔记',
statusOff: '已关闭 — 笔记仅存在于此设备',
turnOffTitle: '关闭 iCloud 备份?',
Expand Down
2 changes: 2 additions & 0 deletions src/i18n/locales/zhHant.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,8 @@ export const zhHant: Strings = {
backup: {
rowTitle: 'iCloud 備份',
statusAutomatic: '自動 — 即使刪除 App,筆記也會保留',
statusUploading: (pending: number) =>
`備份中 — 還有 ${pending} 個檔案正在上傳到 iCloud`,
statusUnavailable: 'iCloud 無法使用 — 請登入 iCloud 以保護你的筆記',
statusOff: '已關閉 — 筆記僅存在於此裝置',
turnOffTitle: '關閉 iCloud 備份?',
Expand Down
30 changes: 30 additions & 0 deletions src/services/backupService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,10 @@ type ICloudBackupModuleType = {
dir: string,
timeoutMs: number,
) => Promise<Array<{ rel: string; size: number; downloaded: boolean }>>;
uploadStatus?: (
dir: string,
timeoutMs: number,
) => Promise<{ total: number; uploaded: number; pending: string[] }>;
};

const ICloudBackupModule = NativeModules.ICloudBackupModule as
Expand Down Expand Up @@ -328,6 +332,32 @@ export async function isBackupAvailable(): Promise<boolean> {
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<BackupUploadStatus | null> {
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<void> {
await AsyncStorage.setItem(ENABLED_KEY, enabled ? 'true' : 'false');
if (enabled) {
Expand Down
Loading