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
10 changes: 10 additions & 0 deletions app.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,16 @@
"icon": "./assets/icon.png",
"buildNumber": "9",
"supportsTablet": true,
"usesIcloudStorage": true,
"entitlements": {
"com.apple.developer.icloud-container-identifiers": [
"iCloud.com.builderpro.opennotes"
],
"com.apple.developer.icloud-services": ["CloudDocuments"],
"com.apple.developer.ubiquity-container-identifiers": [
"iCloud.com.builderpro.opennotes"
]
},
"infoPlist": {
"ITSAppUsesNonExemptEncryption": false,
"NSPhotoLibraryUsageDescription": "OpenNotes needs access to your photo library so you can insert images into your notes.",
Expand Down
7 changes: 7 additions & 0 deletions app/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import { CommunityInviteSheet } from '../src/components/library/CommunityInviteS
import { OpenNotesSheet } from '../src/components/library/OpenNotesSheet';
import { LibrarySection } from '../src/components/library/LibrarySection';
import { useOnboarding } from '../src/hooks/useOnboarding';
import { useBackup } from '../src/hooks/useBackup';
import { useLibrarySupport } from '../src/hooks/useLibrarySupport';
import { useTheme } from '../src/hooks/useTheme';
import { spacing } from '../src/theme/spacing';
Expand Down Expand Up @@ -89,6 +90,8 @@ export default function LibraryScreen() {
() => setAction({ kind: 'community' }),
[],
);
const { backupSupported, backupEnabled, backupSubtitle, toggleBackup } =
useBackup(refresh);
const { dismissCommunity, joinCommunity, rateOpenNotes } = useLibrarySupport({
canShowAutomaticPrompt:
onboarding.ready && !onboarding.visible && action === null,
Expand Down Expand Up @@ -312,6 +315,10 @@ export default function LibraryScreen() {
setAction(null);
onboarding.show();
}}
backupSupported={backupSupported}
backupEnabled={backupEnabled}
backupSubtitle={backupSubtitle}
onToggleBackup={toggleBackup}
/>

<CommunityInviteSheet
Expand Down
8 changes: 8 additions & 0 deletions ios/OpenNotes.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@
4788858903E15B3CAEB129E6 /* ExpoModulesProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9B82DFB67047998EABD93C92 /* ExpoModulesProvider.swift */; };
7D3A80162E1A4CC89B1B09C1 /* PDFUtilsModule.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7D3A80142E1A4CC89B1B09C1 /* PDFUtilsModule.swift */; };
7D3A80172E1A4CC89B1B09C1 /* PDFUtilsModule.m in Sources */ = {isa = PBXBuildFile; fileRef = 7D3A80152E1A4CC89B1B09C1 /* PDFUtilsModule.m */; };
7D3A801A2E1A4CC89B1B09C1 /* ICloudBackupModule.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7D3A80182E1A4CC89B1B09C1 /* ICloudBackupModule.swift */; };
7D3A801B2E1A4CC89B1B09C1 /* ICloudBackupModule.m in Sources */ = {isa = PBXBuildFile; fileRef = 7D3A80192E1A4CC89B1B09C1 /* ICloudBackupModule.m */; };
9C2FD7C620026BB779139CCD /* libPods-OpenNotes.a in Frameworks */ = {isa = PBXBuildFile; fileRef = A7A85B485F4321855B927960 /* libPods-OpenNotes.a */; };
BB2F792D24A3F905000567C9 /* Expo.plist in Resources */ = {isa = PBXBuildFile; fileRef = BB2F792C24A3F905000567C9 /* Expo.plist */; };
F11748422D0307B40044C1D9 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = F11748412D0307B40044C1D9 /* AppDelegate.swift */; };
Expand All @@ -25,6 +27,8 @@
575BE98DD16F76BFF21A39E6 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; includeInIndex = 1; name = PrivacyInfo.xcprivacy; path = OpenNotes/PrivacyInfo.xcprivacy; sourceTree = "<group>"; };
7D3A80142E1A4CC89B1B09C1 /* PDFUtilsModule.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = PDFUtilsModule.swift; path = OpenNotes/PDFUtilsModule.swift; sourceTree = "<group>"; };
7D3A80152E1A4CC89B1B09C1 /* PDFUtilsModule.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; name = PDFUtilsModule.m; path = OpenNotes/PDFUtilsModule.m; sourceTree = "<group>"; };
7D3A80182E1A4CC89B1B09C1 /* ICloudBackupModule.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = ICloudBackupModule.swift; path = OpenNotes/ICloudBackupModule.swift; sourceTree = "<group>"; };
7D3A80192E1A4CC89B1B09C1 /* ICloudBackupModule.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; name = ICloudBackupModule.m; path = OpenNotes/ICloudBackupModule.m; sourceTree = "<group>"; };
9B82DFB67047998EABD93C92 /* ExpoModulesProvider.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = ExpoModulesProvider.swift; path = "Pods/Target Support Files/Pods-OpenNotes/ExpoModulesProvider.swift"; sourceTree = "<group>"; };
A7A85B485F4321855B927960 /* libPods-OpenNotes.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-OpenNotes.a"; sourceTree = BUILT_PRODUCTS_DIR; };
AA286B85B6C04FC6940260E9 /* SplashScreen.storyboard */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.storyboard; name = SplashScreen.storyboard; path = OpenNotes/SplashScreen.storyboard; sourceTree = "<group>"; };
Expand Down Expand Up @@ -54,6 +58,8 @@
F11748412D0307B40044C1D9 /* AppDelegate.swift */,
7D3A80142E1A4CC89B1B09C1 /* PDFUtilsModule.swift */,
7D3A80152E1A4CC89B1B09C1 /* PDFUtilsModule.m */,
7D3A80182E1A4CC89B1B09C1 /* ICloudBackupModule.swift */,
7D3A80192E1A4CC89B1B09C1 /* ICloudBackupModule.m */,
F11748442D0722820044C1D9 /* OpenNotes-Bridging-Header.h */,
BB2F792B24A3F905000567C9 /* Supporting */,
13B07FB51A68108700A75B9A /* Images.xcassets */,
Expand Down Expand Up @@ -338,6 +344,8 @@
F11748422D0307B40044C1D9 /* AppDelegate.swift in Sources */,
7D3A80162E1A4CC89B1B09C1 /* PDFUtilsModule.swift in Sources */,
7D3A80172E1A4CC89B1B09C1 /* PDFUtilsModule.m in Sources */,
7D3A801A2E1A4CC89B1B09C1 /* ICloudBackupModule.swift in Sources */,
7D3A801B2E1A4CC89B1B09C1 /* ICloudBackupModule.m in Sources */,
4788858903E15B3CAEB129E6 /* ExpoModulesProvider.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
Expand Down
35 changes: 35 additions & 0 deletions ios/OpenNotes/ICloudBackupModule.m
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
#import <React/RCTBridgeModule.h>

@interface RCT_EXTERN_MODULE(ICloudBackupModule, NSObject)

RCT_EXTERN_METHOD(getContainerPath:(RCTPromiseResolveBlock)resolver
rejecter:(RCTPromiseRejectBlock)rejecter)

RCT_EXTERN_METHOD(copyItem:(NSString *)from
to:(NSString *)to
resolver:(RCTPromiseResolveBlock)resolver
rejecter:(RCTPromiseRejectBlock)rejecter)

RCT_EXTERN_METHOD(writeFileAtomic:(NSString *)path
contents:(NSString *)contents
resolver:(RCTPromiseResolveBlock)resolver
rejecter:(RCTPromiseRejectBlock)rejecter)

RCT_EXTERN_METHOD(readFileAsString:(NSString *)path
resolver:(RCTPromiseResolveBlock)resolver
rejecter:(RCTPromiseRejectBlock)rejecter)

RCT_EXTERN_METHOD(deleteItem:(NSString *)path
resolver:(RCTPromiseResolveBlock)resolver
rejecter:(RCTPromiseRejectBlock)rejecter)

RCT_EXTERN_METHOD(listFilesRecursive:(NSString *)dir
resolver:(RCTPromiseResolveBlock)resolver
rejecter:(RCTPromiseRejectBlock)rejecter)

RCT_EXTERN_METHOD(ensureDownloaded:(NSString *)path
timeoutMs:(nonnull NSNumber *)timeoutMs
resolver:(RCTPromiseResolveBlock)resolver
rejecter:(RCTPromiseRejectBlock)rejecter)

@end
205 changes: 205 additions & 0 deletions ios/OpenNotes/ICloudBackupModule.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,205 @@
import Foundation
import React

@objc(ICloudBackupModule)
class ICloudBackupModule: NSObject {
@objc static func requiresMainQueueSetup() -> Bool {
return false
}

/// Resolves the app's iCloud Drive container Documents directory, creating
/// it if needed. Resolves null when iCloud is unavailable (signed out, iCloud
/// Drive disabled, or missing entitlement) - callers treat that as "backup
/// unavailable", never as an error.
@objc
func getContainerPath(_ resolver: @escaping RCTPromiseResolveBlock,
rejecter: @escaping RCTPromiseRejectBlock) {
DispatchQueue.global(qos: .utility).async {
guard let containerUrl = FileManager.default.url(forUbiquityContainerIdentifier: nil) else {
resolver(NSNull())
return
}
let documentsUrl = containerUrl.appendingPathComponent("Documents", isDirectory: true)
do {
try FileManager.default.createDirectory(
at: documentsUrl,
withIntermediateDirectories: true
)
} catch {
rejecter("E_CONTAINER_DIR", "Could not create iCloud Documents directory", error)
return
}
resolver(documentsUrl.path)
}
}

/// File operations below use FileManager because expo-file-system refuses
/// paths outside the app sandbox scopes - which includes the ubiquity
/// container. All backup-side I/O must therefore go through this module.

@objc
func copyItem(_ from: String, to: String,
resolver: @escaping RCTPromiseResolveBlock,
rejecter: @escaping RCTPromiseRejectBlock) {
DispatchQueue.global(qos: .utility).async {
let fromUrl = URL(fileURLWithPath: Self.plainPath(from))
let toUrl = URL(fileURLWithPath: Self.plainPath(to))
do {
try FileManager.default.createDirectory(
at: toUrl.deletingLastPathComponent(),
withIntermediateDirectories: true
)
if FileManager.default.fileExists(atPath: toUrl.path) {
try FileManager.default.removeItem(at: toUrl)
}
try FileManager.default.copyItem(at: fromUrl, to: toUrl)
resolver(true)
} catch {
rejecter("E_COPY", "Copy failed: \(fromUrl.lastPathComponent)", error)
}
}
}

@objc
func writeFileAtomic(_ path: String, contents: String,
resolver: @escaping RCTPromiseResolveBlock,
rejecter: @escaping RCTPromiseRejectBlock) {
DispatchQueue.global(qos: .utility).async {
let url = URL(fileURLWithPath: Self.plainPath(path))
do {
try FileManager.default.createDirectory(
at: url.deletingLastPathComponent(),
withIntermediateDirectories: true
)
try contents.write(to: url, atomically: true, encoding: .utf8)
resolver(true)
} catch {
rejecter("E_WRITE", "Write failed: \(url.lastPathComponent)", error)
}
}
}

@objc
func readFileAsString(_ path: String,
resolver: @escaping RCTPromiseResolveBlock,
rejecter: @escaping RCTPromiseRejectBlock) {
DispatchQueue.global(qos: .utility).async {
let url = URL(fileURLWithPath: Self.plainPath(path))
guard FileManager.default.fileExists(atPath: url.path) else {
resolver(NSNull())
return
}
do {
resolver(try String(contentsOf: url, encoding: .utf8))
} catch {
rejecter("E_READ", "Read failed: \(url.lastPathComponent)", error)
}
}
}

@objc
func deleteItem(_ path: String,
resolver: @escaping RCTPromiseResolveBlock,
rejecter: @escaping RCTPromiseRejectBlock) {
DispatchQueue.global(qos: .utility).async {
let url = URL(fileURLWithPath: Self.plainPath(path))
do {
if FileManager.default.fileExists(atPath: url.path) {
try FileManager.default.removeItem(at: url)
}
resolver(true)
} catch {
rejecter("E_DELETE", "Delete failed: \(url.lastPathComponent)", error)
}
}
}

/// Lists every regular file under `dir` recursively. Returns
/// [{ rel, size, mtimeMs }] with `rel` relative to `dir`.
@objc
func listFilesRecursive(_ dir: String,
resolver: @escaping RCTPromiseResolveBlock,
rejecter: @escaping RCTPromiseRejectBlock) {
DispatchQueue.global(qos: .utility).async {
let baseUrl = URL(fileURLWithPath: Self.plainPath(dir))
guard FileManager.default.fileExists(atPath: baseUrl.path) else {
resolver([])
return
}
guard let enumerator = FileManager.default.enumerator(
at: baseUrl,
includingPropertiesForKeys: [.isRegularFileKey, .fileSizeKey, .contentModificationDateKey]
) else {
resolver([])
return
}
var out: [[String: Any]] = []
let basePath = baseUrl.path.hasSuffix("/") ? baseUrl.path : baseUrl.path + "/"
for case let fileUrl as URL in enumerator {
guard let values = try? fileUrl.resourceValues(
forKeys: [.isRegularFileKey, .fileSizeKey, .contentModificationDateKey]
), values.isRegularFile == true else { continue }
guard fileUrl.path.hasPrefix(basePath) else { continue }
let rel = String(fileUrl.path.dropFirst(basePath.count))
let mtimeMs = (values.contentModificationDate?.timeIntervalSince1970 ?? 0) * 1000
out.append([
"rel": rel,
"size": values.fileSize ?? 0,
"mtimeMs": Int(mtimeMs.rounded()),
])
}
resolver(out)
}
}

private static func plainPath(_ path: String) -> String {
if path.hasPrefix("file://"), let url = URL(string: path) {
return url.path
}
return path
}

/// Ensures a file in the ubiquity container is downloaded locally (iCloud
/// may have evicted it). Resolves true when the file is readable, false when
/// the download did not complete within the timeout.
@objc
func ensureDownloaded(_ path: String,
timeoutMs: NSNumber,
resolver: @escaping RCTPromiseResolveBlock,
rejecter: @escaping RCTPromiseRejectBlock) {
DispatchQueue.global(qos: .utility).async {
let url = URL(fileURLWithPath: Self.plainPath(path))
let fileManager = FileManager.default

if fileManager.fileExists(atPath: url.path) {
resolver(true)
return
}

// A not-yet-downloaded ubiquitous file appears as ".<name>.icloud".
let placeholderName = ".\(url.lastPathComponent).icloud"
let placeholderUrl = url.deletingLastPathComponent().appendingPathComponent(placeholderName)
guard fileManager.fileExists(atPath: placeholderUrl.path) else {
resolver(false)
return
}

do {
try fileManager.startDownloadingUbiquitousItem(at: url)
} catch {
rejecter("E_DOWNLOAD_START", "Could not start iCloud download for \(url.lastPathComponent)", error)
return
}

let deadline = Date().addingTimeInterval(timeoutMs.doubleValue / 1000.0)
while Date() < deadline {
if fileManager.fileExists(atPath: url.path) {
resolver(true)
return
}
Thread.sleep(forTimeInterval: 0.2)
}
resolver(false)
}
}
}
17 changes: 15 additions & 2 deletions ios/OpenNotes/OpenNotes.entitlements
Original file line number Diff line number Diff line change
@@ -1,5 +1,18 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict/>
</plist>
<dict>
<key>com.apple.developer.icloud-container-identifiers</key>
<array>
<string>iCloud.com.builderpro.opennotes</string>
</array>
<key>com.apple.developer.icloud-services</key>
<array>
<string>CloudDocuments</string>
</array>
<key>com.apple.developer.ubiquity-container-identifiers</key>
<array>
<string>iCloud.com.builderpro.opennotes</string>
</array>
</dict>
</plist>
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,8 @@
"android": "expo run:android",
"test:lifecycle": "node --experimental-strip-types --test scripts/lifecyclePolicy.test.mjs",
"test:catalog": "node --experimental-strip-types --test scripts/catalogStore.test.mjs",
"test": "node --experimental-strip-types --test scripts/lifecyclePolicy.test.mjs scripts/catalogStore.test.mjs",
"test": "node --experimental-strip-types --test scripts/lifecyclePolicy.test.mjs scripts/catalogStore.test.mjs scripts/backupEngine.test.mjs",
"test:backup": "node --experimental-strip-types --test scripts/backupEngine.test.mjs",
"typecheck": "tsc --noEmit -p tsconfig.json"
},
"dependencies": {
Expand Down
Loading
Loading