diff --git a/.github/workflows/ios.yml b/.github/workflows/ios.yml new file mode 100644 index 0000000..b183ac5 --- /dev/null +++ b/.github/workflows/ios.yml @@ -0,0 +1,39 @@ +name: iOS + +on: + pull_request: + paths: ['ios/**', '.github/workflows/ios.yml'] + push: + paths: ['ios/**', '.github/workflows/ios.yml'] + +jobs: + build-and-test: + permissions: + contents: read + runs-on: macos-15 + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + - name: Test persistence and backups + run: swift test --package-path ios/FlowCore + - name: Build iOS app + run: >- + xcodebuild -project ios/Flow.xcodeproj -scheme Flow + -destination 'generic/platform=iOS Simulator' + -derivedDataPath ios/.build/DerivedData CODE_SIGNING_ALLOWED=NO build + - name: Select an available iPhone simulator + run: | + DEVICE_ID=$(xcrun simctl list devices available -j | python3 -c 'import json,sys; devices=json.load(sys.stdin)["devices"]; print(next(d["udid"] for group in devices.values() for d in group if d["name"].startswith("iPhone")))') + echo "FLOW_SIMULATOR_ID=$DEVICE_ID" >> "$GITHUB_ENV" + - name: Test UIKit flows + run: >- + xcodebuild -project ios/Flow.xcodeproj -scheme Flow + -destination "platform=iOS Simulator,id=$FLOW_SIMULATOR_ID" + -derivedDataPath ios/.build/DerivedData -resultBundlePath ios/.build/UITests.xcresult + CODE_SIGNING_ALLOWED=NO test + - uses: actions/upload-artifact@v4 + if: failure() + with: + name: iOS-test-results + path: ios/.build/UITests.xcresult diff --git a/.gitignore b/.gitignore index aa724b7..f381b60 100644 --- a/.gitignore +++ b/.gitignore @@ -13,3 +13,12 @@ .externalNativeBuild .cxx local.properties + +# iOS and Swift +ios/.build/ +ios/FlowCore/.build/ +ios/FlowCore/.swiftpm/ +DerivedData/ +*.xcresult +xcuserdata/ +*.xcuserstate diff --git a/README.md b/README.md index a36cf35..2875fc5 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ Flow -Notes & Tasks app for Android. Fully native. Just your stuff, no bullshit +Notes & Tasks app for Android and iOS. Fully native. Just your stuff, no bullshit [**Features**](#features) · [**Usage**](https://github.com/jvqtil/Flow/wiki/Usage) · [**Screenshots**](#screenshots) · [**Contributing**](#contributing) @@ -55,7 +55,11 @@ Notes & Tasks app for Android. Fully native. Just your stuff, no bullshit ## Contributing -### Building +### iOS + +Open [`ios/Flow.xcodeproj`](ios/Flow.xcodeproj) in Xcode 16 or later and run the **Flow** scheme on an iPhone or iPad simulator. The native UIKit app supports iOS 16+, local notes and tasks, folders, attachments, and Android-compatible `.flow` backups. See the [iOS guide](ios/README.md) for build commands, tests, and platform details. + +### Building Android Clone the repository and open it in Android Studio. Build it with: diff --git a/ios/Flow.xcodeproj/project.pbxproj b/ios/Flow.xcodeproj/project.pbxproj new file mode 100644 index 0000000..d6591c2 --- /dev/null +++ b/ios/Flow.xcodeproj/project.pbxproj @@ -0,0 +1,276 @@ +// !$*UTF8*$! +{ + "archiveVersion" = 1; + "classes" = { + }; + "objectVersion" = 77; + "objects" = { + "000000000000000000000001" = { + "isa" = "PBXProject"; + "attributes" = { + "BuildIndependentTargetsInParallel" = "YES"; + "LastUpgradeCheck" = "1600"; + "TargetAttributes" = { + "00000000000000000000000A" = { + "CreatedOnToolsVersion" = "16.0"; + }; + "000000000000000000000014" = { + "CreatedOnToolsVersion" = "16.0"; + "TestTargetID" = "00000000000000000000000A"; + }; + }; + }; + "buildConfigurationList" = "00000000000000000000001E"; + "compatibilityVersion" = "Xcode 16.0"; + "developmentRegion" = "en"; + "hasScannedForEncodings" = 0; + "knownRegions" = ("en", "Base"); + "mainGroup" = "000000000000000000000002"; + "minimizedProjectReferenceProxies" = 1; + "packageReferences" = ("000000000000000000000032"); + "preferredProjectObjectVersion" = 77; + "productRefGroup" = "000000000000000000000005"; + "projectDirPath" = ""; + "projectRoot" = ""; + "targets" = ("00000000000000000000000A", "000000000000000000000014"); + }; + "000000000000000000000002" = { + "isa" = "PBXGroup"; + "children" = ("000000000000000000000003", "000000000000000000000004", "000000000000000000000005"); + "sourceTree" = ""; + }; + "000000000000000000000003" = { + "isa" = "PBXFileSystemSynchronizedRootGroup"; + "exceptions" = ("000000000000000000000006"); + "path" = "Flow"; + "sourceTree" = ""; + }; + "000000000000000000000004" = { + "isa" = "PBXFileSystemSynchronizedRootGroup"; + "path" = "FlowUITests"; + "sourceTree" = ""; + }; + "000000000000000000000005" = { + "isa" = "PBXGroup"; + "children" = ("00000000000000000000000B", "000000000000000000000015"); + "name" = "Products"; + "sourceTree" = ""; + }; + "000000000000000000000006" = { + "isa" = "PBXFileSystemSynchronizedBuildFileExceptionSet"; + "membershipExceptions" = ("Info.plist"); + "target" = "00000000000000000000000A"; + }; + "00000000000000000000000A" = { + "isa" = "PBXNativeTarget"; + "buildConfigurationList" = "000000000000000000000021"; + "buildPhases" = ("00000000000000000000000C", "00000000000000000000000D", "00000000000000000000000E"); + "buildRules" = (); + "dependencies" = (); + "fileSystemSynchronizedGroups" = ("000000000000000000000003"); + "name" = "Flow"; + "packageProductDependencies" = ("000000000000000000000033"); + "productName" = "Flow"; + "productReference" = "00000000000000000000000B"; + "productType" = "com.apple.product-type.application"; + }; + "00000000000000000000000B" = { + "isa" = "PBXFileReference"; + "explicitFileType" = "wrapper.application"; + "includeInIndex" = 0; + "path" = "Flow.app"; + "sourceTree" = "BUILT_PRODUCTS_DIR"; + }; + "00000000000000000000000C" = { + "isa" = "PBXSourcesBuildPhase"; + "buildActionMask" = 2147483647; + "files" = (); + "runOnlyForDeploymentPostprocessing" = 0; + }; + "00000000000000000000000D" = { + "isa" = "PBXFrameworksBuildPhase"; + "buildActionMask" = 2147483647; + "files" = ("000000000000000000000034"); + "runOnlyForDeploymentPostprocessing" = 0; + }; + "00000000000000000000000E" = { + "isa" = "PBXResourcesBuildPhase"; + "buildActionMask" = 2147483647; + "files" = (); + "runOnlyForDeploymentPostprocessing" = 0; + }; + "000000000000000000000014" = { + "isa" = "PBXNativeTarget"; + "buildConfigurationList" = "000000000000000000000024"; + "buildPhases" = ("000000000000000000000016", "000000000000000000000017", "000000000000000000000018"); + "buildRules" = (); + "dependencies" = ("000000000000000000000019"); + "fileSystemSynchronizedGroups" = ("000000000000000000000004"); + "name" = "FlowUITests"; + "packageProductDependencies" = (); + "productName" = "FlowUITests"; + "productReference" = "000000000000000000000015"; + "productType" = "com.apple.product-type.bundle.ui-testing"; + }; + "000000000000000000000015" = { + "isa" = "PBXFileReference"; + "explicitFileType" = "wrapper.cfbundle"; + "includeInIndex" = 0; + "path" = "FlowUITests.xctest"; + "sourceTree" = "BUILT_PRODUCTS_DIR"; + }; + "000000000000000000000016" = { + "isa" = "PBXSourcesBuildPhase"; + "buildActionMask" = 2147483647; + "files" = (); + "runOnlyForDeploymentPostprocessing" = 0; + }; + "000000000000000000000017" = { + "isa" = "PBXFrameworksBuildPhase"; + "buildActionMask" = 2147483647; + "files" = (); + "runOnlyForDeploymentPostprocessing" = 0; + }; + "000000000000000000000018" = { + "isa" = "PBXResourcesBuildPhase"; + "buildActionMask" = 2147483647; + "files" = (); + "runOnlyForDeploymentPostprocessing" = 0; + }; + "000000000000000000000019" = { + "isa" = "PBXTargetDependency"; + "target" = "00000000000000000000000A"; + "targetProxy" = "00000000000000000000001A"; + }; + "00000000000000000000001A" = { + "isa" = "PBXContainerItemProxy"; + "containerPortal" = "000000000000000000000001"; + "proxyType" = 1; + "remoteGlobalIDString" = "00000000000000000000000A"; + "remoteInfo" = "Flow"; + }; + "00000000000000000000001E" = { + "isa" = "XCConfigurationList"; + "buildConfigurations" = ("00000000000000000000001F", "000000000000000000000020"); + "defaultConfigurationIsVisible" = 0; + "defaultConfigurationName" = "Release"; + }; + "00000000000000000000001F" = { + "isa" = "XCBuildConfiguration"; + "name" = "Debug"; + "buildSettings" = { + "CLANG_ENABLE_MODULES" = "YES"; + "CLANG_ENABLE_OBJC_ARC" = "YES"; + "IPHONEOS_DEPLOYMENT_TARGET" = "16.0"; + "SDKROOT" = "iphoneos"; + "SWIFT_VERSION" = "5.0"; + "DEBUG_INFORMATION_FORMAT" = "dwarf"; + "ENABLE_TESTABILITY" = "YES"; + "GCC_OPTIMIZATION_LEVEL" = "0"; + "ONLY_ACTIVE_ARCH" = "YES"; + "SWIFT_ACTIVE_COMPILATION_CONDITIONS" = "DEBUG"; + "SWIFT_OPTIMIZATION_LEVEL" = "-Onone"; + }; + }; + "000000000000000000000020" = { + "isa" = "XCBuildConfiguration"; + "name" = "Release"; + "buildSettings" = { + "CLANG_ENABLE_MODULES" = "YES"; + "CLANG_ENABLE_OBJC_ARC" = "YES"; + "IPHONEOS_DEPLOYMENT_TARGET" = "16.0"; + "SDKROOT" = "iphoneos"; + "SWIFT_VERSION" = "5.0"; + "DEBUG_INFORMATION_FORMAT" = "dwarf-with-dsym"; + "SWIFT_COMPILATION_MODE" = "wholemodule"; + "SWIFT_OPTIMIZATION_LEVEL" = "-O"; + "VALIDATE_PRODUCT" = "YES"; + }; + }; + "000000000000000000000021" = { + "isa" = "XCConfigurationList"; + "buildConfigurations" = ("000000000000000000000022", "000000000000000000000023"); + "defaultConfigurationIsVisible" = 0; + "defaultConfigurationName" = "Release"; + }; + "000000000000000000000022" = { + "isa" = "XCBuildConfiguration"; + "name" = "Debug"; + "buildSettings" = { + "ASSETCATALOG_COMPILER_APPICON_NAME" = "AppIcon"; + "CODE_SIGN_STYLE" = "Automatic"; + "CURRENT_PROJECT_VERSION" = "1"; + "GENERATE_INFOPLIST_FILE" = "NO"; + "INFOPLIST_FILE" = "Flow/Info.plist"; + "LD_RUNPATH_SEARCH_PATHS" = ("$(inherited)", "@executable_path/Frameworks"); + "MARKETING_VERSION" = "1.0"; + "PRODUCT_BUNDLE_IDENTIFIER" = "dev.jvqtil.flow.ios"; + "PRODUCT_NAME" = "$(TARGET_NAME)"; + "SUPPORTED_PLATFORMS" = "iphoneos iphonesimulator"; + "SUPPORTS_MACCATALYST" = "NO"; + "TARGETED_DEVICE_FAMILY" = "1,2"; + }; + }; + "000000000000000000000023" = { + "isa" = "XCBuildConfiguration"; + "name" = "Release"; + "buildSettings" = { + "ASSETCATALOG_COMPILER_APPICON_NAME" = "AppIcon"; + "CODE_SIGN_STYLE" = "Automatic"; + "CURRENT_PROJECT_VERSION" = "1"; + "GENERATE_INFOPLIST_FILE" = "NO"; + "INFOPLIST_FILE" = "Flow/Info.plist"; + "LD_RUNPATH_SEARCH_PATHS" = ("$(inherited)", "@executable_path/Frameworks"); + "MARKETING_VERSION" = "1.0"; + "PRODUCT_BUNDLE_IDENTIFIER" = "dev.jvqtil.flow.ios"; + "PRODUCT_NAME" = "$(TARGET_NAME)"; + "SUPPORTED_PLATFORMS" = "iphoneos iphonesimulator"; + "SUPPORTS_MACCATALYST" = "NO"; + "TARGETED_DEVICE_FAMILY" = "1,2"; + }; + }; + "000000000000000000000024" = { + "isa" = "XCConfigurationList"; + "buildConfigurations" = ("000000000000000000000025", "000000000000000000000026"); + "defaultConfigurationIsVisible" = 0; + "defaultConfigurationName" = "Release"; + }; + "000000000000000000000025" = { + "isa" = "XCBuildConfiguration"; + "name" = "Debug"; + "buildSettings" = { + "CODE_SIGN_STYLE" = "Automatic"; + "GENERATE_INFOPLIST_FILE" = "YES"; + "PRODUCT_BUNDLE_IDENTIFIER" = "dev.jvqtil.flow.ios.uitests"; + "PRODUCT_NAME" = "$(TARGET_NAME)"; + "TARGETED_DEVICE_FAMILY" = "1,2"; + "TEST_TARGET_NAME" = "Flow"; + }; + }; + "000000000000000000000026" = { + "isa" = "XCBuildConfiguration"; + "name" = "Release"; + "buildSettings" = { + "CODE_SIGN_STYLE" = "Automatic"; + "GENERATE_INFOPLIST_FILE" = "YES"; + "PRODUCT_BUNDLE_IDENTIFIER" = "dev.jvqtil.flow.ios.uitests"; + "PRODUCT_NAME" = "$(TARGET_NAME)"; + "TARGETED_DEVICE_FAMILY" = "1,2"; + "TEST_TARGET_NAME" = "Flow"; + }; + }; + "000000000000000000000032" = { + "isa" = "XCLocalSwiftPackageReference"; + "relativePath" = "FlowCore"; + }; + "000000000000000000000033" = { + "isa" = "XCSwiftPackageProductDependency"; + "productName" = "FlowCore"; + }; + "000000000000000000000034" = { + "isa" = "PBXBuildFile"; + "productRef" = "000000000000000000000033"; + }; + }; + "rootObject" = "000000000000000000000001"; +} diff --git a/ios/Flow.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/ios/Flow.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved new file mode 100644 index 0000000..ca92014 --- /dev/null +++ b/ios/Flow.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -0,0 +1,15 @@ +{ + "originHash" : "853cd5cae26932bada3fe9aad0c6f3ae8c5984f4fa78c14923035daafc09ecf2", + "pins" : [ + { + "identity" : "zipfoundation", + "kind" : "remoteSourceControl", + "location" : "https://github.com/weichsel/ZIPFoundation.git", + "state" : { + "revision" : "22787ffb59de99e5dc1fbfe80b19c97a904ad48d", + "version" : "0.9.20" + } + } + ], + "version" : 3 +} diff --git a/ios/Flow.xcodeproj/xcshareddata/xcschemes/Flow.xcscheme b/ios/Flow.xcodeproj/xcshareddata/xcschemes/Flow.xcscheme new file mode 100644 index 0000000..1591d00 --- /dev/null +++ b/ios/Flow.xcodeproj/xcshareddata/xcschemes/Flow.xcscheme @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/ios/Flow/AppDelegate.swift b/ios/Flow/AppDelegate.swift new file mode 100644 index 0000000..499f253 --- /dev/null +++ b/ios/Flow/AppDelegate.swift @@ -0,0 +1,11 @@ +import UIKit + +@main +final class AppDelegate: UIResponder, UIApplicationDelegate { + func application(_ application: UIApplication, configurationForConnecting connectingSceneSession: UISceneSession, + options: UIScene.ConnectionOptions) -> UISceneConfiguration { + let configuration = UISceneConfiguration(name: "Flow", sessionRole: connectingSceneSession.role) + configuration.delegateClass = SceneDelegate.self + return configuration + } +} diff --git a/ios/Flow/Assets.xcassets/AppIcon.appiconset/AppIcon.png b/ios/Flow/Assets.xcassets/AppIcon.appiconset/AppIcon.png new file mode 100644 index 0000000..7515274 Binary files /dev/null and b/ios/Flow/Assets.xcassets/AppIcon.appiconset/AppIcon.png differ diff --git a/ios/Flow/Assets.xcassets/AppIcon.appiconset/Contents.json b/ios/Flow/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..0a42994 --- /dev/null +++ b/ios/Flow/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1 @@ +{"images":[{"filename":"AppIcon.png","idiom":"universal","platform":"ios","size":"1024x1024"}],"info":{"author":"xcode","version":1}} diff --git a/ios/Flow/Assets.xcassets/Contents.json b/ios/Flow/Assets.xcassets/Contents.json new file mode 100644 index 0000000..f223229 --- /dev/null +++ b/ios/Flow/Assets.xcassets/Contents.json @@ -0,0 +1 @@ +{"info":{"author":"xcode","version":1}} diff --git a/ios/Flow/EditorViewController.swift b/ios/Flow/EditorViewController.swift new file mode 100644 index 0000000..d007a7e --- /dev/null +++ b/ios/Flow/EditorViewController.swift @@ -0,0 +1,310 @@ +import UIKit +import UniformTypeIdentifiers +import QuickLook +import FlowCore + +final class EditorViewController: UIViewController, UITextViewDelegate, UITableViewDataSource, UITableViewDelegate, + UIDocumentPickerDelegate, QLPreviewControllerDataSource { + private let store: FlowStore + private var entry: Entry + private let isNew: Bool + private let textView = UITextView() + private let placeholder = UILabel() + private let kindControl = UISegmentedControl(items: ["Note", "Task"]) + private let attachmentsTable = UITableView(frame: .zero, style: .plain) + private var attachments: [Attachment] = [] + private var attachmentsHeight: NSLayoutConstraint! + private var previewItem: AttachmentPreview? + private var errorPresented = false + private var pendingAutosave: DispatchWorkItem? + private var hasUnsavedChanges = false + private var discarded = false + + init(store: FlowStore, entry: Entry, isNew: Bool = false) { + self.store = store + self.entry = entry + self.isNew = isNew + super.init(nibName: nil, bundle: nil) + } + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } + + override func viewDidLoad() { + super.viewDidLoad() + view.backgroundColor = Theme.background + navigationItem.largeTitleDisplayMode = .never + navigationItem.titleView = kindControl + kindControl.selectedSegmentIndex = entry.type == .note ? 0 : 1 + kindControl.addTarget(self, action: #selector(kindChanged), for: .valueChanged) + navigationItem.rightBarButtonItem = UIBarButtonItem(barButtonSystemItem: .done, target: self, action: #selector(done)) + textView.delegate = self + textView.text = entry.text + textView.font = Theme.font(editor: true) + textView.adjustsFontForContentSizeCategory = true + textView.backgroundColor = .clear + textView.alwaysBounceVertical = true + textView.keyboardDismissMode = .interactive + textView.textContainerInset = UIEdgeInsets(top: 24, left: 20, bottom: 24, right: 20) + textView.accessibilityLabel = "Entry text" + textView.accessibilityIdentifier = "entryText" + let keyboardToolbar = UIToolbar(frame: CGRect(x: 0, y: 0, width: 0, height: 44)) + let attach = UIBarButtonItem(image: UIImage(systemName: "paperclip"), style: .plain, target: self, action: #selector(addAttachment)) + attach.accessibilityLabel = "Attach file" + let hideKeyboard = UIBarButtonItem(image: UIImage(systemName: "keyboard.chevron.compact.down"), style: .plain, target: self, action: #selector(dismissKeyboard)) + hideKeyboard.accessibilityLabel = "Hide keyboard" + hideKeyboard.accessibilityIdentifier = "hideEntryKeyboard" + keyboardToolbar.items = [attach, .flexibleSpace(), hideKeyboard] + textView.inputAccessoryView = keyboardToolbar + if Preferences.codeKeyboard { + textView.autocorrectionType = .no + textView.autocapitalizationType = .none + textView.smartQuotesType = .no + textView.smartDashesType = .no + textView.spellCheckingType = .no + } + placeholder.text = "Any ideas?" + placeholder.font = textView.font + placeholder.textColor = .placeholderText + placeholder.isUserInteractionEnabled = false + placeholder.isAccessibilityElement = false + textView.addSubview(placeholder) + placeholder.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + placeholder.topAnchor.constraint(equalTo: textView.contentLayoutGuide.topAnchor, constant: 24), + placeholder.leadingAnchor.constraint(equalTo: textView.frameLayoutGuide.leadingAnchor, constant: 25) + ]) + placeholder.isHidden = !entry.text.isEmpty + attachmentsTable.dataSource = self + attachmentsTable.delegate = self + attachmentsTable.backgroundColor = .clear + attachmentsTable.rowHeight = 60 + attachmentsTable.accessibilityLabel = "Attachments" + let stack = UIStackView(arrangedSubviews: [textView, attachmentsTable]) + stack.axis = .vertical + stack.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(stack) + attachmentsHeight = attachmentsTable.heightAnchor.constraint(equalToConstant: 0) + NSLayoutConstraint.activate([ + stack.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor), + stack.leadingAnchor.constraint(equalTo: view.readableContentGuide.leadingAnchor), + stack.trailingAnchor.constraint(equalTo: view.readableContentGuide.trailingAnchor), + stack.bottomAnchor.constraint(equalTo: view.keyboardLayoutGuide.topAnchor), + attachmentsHeight + ]) + NotificationCenter.default.addObserver(self, selector: #selector(saveRequested), name: .flowSaveRequested, object: nil) + refreshAttachments() + updateToolbar() + } + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + navigationController?.setToolbarHidden(false, animated: animated) + } + + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + if isNew && entry.text.isEmpty && attachments.isEmpty { textView.becomeFirstResponder() } + } + + override func viewWillDisappear(_ animated: Bool) { + super.viewWillDisappear(animated) + // This also covers the navigation bar's Back button and interactive pop. + if !discarded { saveRequested() } + } + + func textViewDidChange(_ textView: UITextView) { + entry.text = textView.text + placeholder.isHidden = !entry.text.isEmpty + hasUnsavedChanges = true + pendingAutosave?.cancel() + let work = DispatchWorkItem { [weak self] in self?.saveRequested() } + pendingAutosave = work + DispatchQueue.main.asyncAfter(deadline: .now() + 0.35, execute: work) + } + + @objc private func saveRequested() { + guard !discarded else { return } + do { try save() } + catch { + guard !errorPresented else { return } + errorPresented = true + let alert = UIAlertController(title: "Your changes could not be saved", message: error.localizedDescription + "\nKeep this editor open and try again.", preferredStyle: .alert) + alert.addAction(UIAlertAction(title: "OK", style: .default) { [weak self] _ in self?.errorPresented = false }) + present(alert, animated: true) + } + } + + private func save(force: Bool = false) throws { + pendingAutosave?.cancel() + pendingAutosave = nil + guard !discarded, force || hasUnsavedChanges else { return } + if let saved = store.entry(entry.id) { entry.position = saved.position } + if !force && store.entry(entry.id) == nil && entry.text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + hasUnsavedChanges = false + return + } + try store.save(entry) + hasUnsavedChanges = false + } + + @objc private func done() { + do { try save(); navigationController?.popViewController(animated: true) } + catch { showError(error) } + } + + @objc private func dismissKeyboard() { textView.resignFirstResponder() } + + @objc private func kindChanged() { + entry.type = kindControl.selectedSegmentIndex == 0 ? .note : .task + if entry.type == .note { entry.completed = false } + hasUnsavedChanges = true + saveRequested() + updateToolbar() + } + + private func updateToolbar() { + let attach = UIBarButtonItem(image: UIImage(systemName: "paperclip"), style: .plain, target: self, action: #selector(addAttachment)) + attach.accessibilityLabel = "Attach file" + let share = UIBarButtonItem(barButtonSystemItem: .action, target: self, action: #selector(shareEntry)) + let undo = UIBarButtonItem(title: "Undo", style: .plain, target: self, action: #selector(undoDelete)) + undo.isEnabled = store.undoActionName != nil + var actions: [UIMenuElement] = [] + if Preferences.folders { + actions.append(UIAction(title: "Move to folder", image: UIImage(systemName: "folder")) { [weak self] _ in self?.moveEntry() }) + } + actions.append(UIAction(title: "Delete entry", image: UIImage(systemName: "trash"), attributes: .destructive) { [weak self] _ in + guard let self else { return } + do { + if self.store.entry(self.entry.id) != nil { try self.store.deleteEntry(self.entry.id) } + self.discarded = true + self.pendingAutosave?.cancel() + self.pendingAutosave = nil + self.navigationController?.popViewController(animated: true) + } catch { self.showError(error) } + }) + let more = UIBarButtonItem(image: UIImage(systemName: "ellipsis.circle"), menu: UIMenu(children: actions)) + more.accessibilityLabel = "Entry actions" + var items: [UIBarButtonItem] = [attach, .flexibleSpace(), share, .flexibleSpace(), undo] + if entry.type == .task { + let completed = UIBarButtonItem(image: UIImage(systemName: entry.completed ? "checkmark.circle.fill" : "circle"), style: .plain, target: self, action: #selector(toggleCompleted)) + completed.accessibilityLabel = entry.completed ? "Mark incomplete" : "Complete task" + items += [.flexibleSpace(), completed] + } + items += [.flexibleSpace(), more] + toolbarItems = items + } + + @objc private func toggleCompleted() { + entry.completed.toggle() + hasUnsavedChanges = true + saveRequested() + updateToolbar() + } + + private func moveEntry() { + let picker = FoldersViewController(store: store, mode: .move { [weak self] id in + guard let self else { return } + do { + try self.save(force: true) + try self.store.moveEntry(self.entry.id, to: id) + if let entry = self.store.entry(self.entry.id) { self.entry = entry } + } catch { self.showError(error) } + }) + present(UINavigationController(rootViewController: picker), animated: true) + } + + @objc private func addAttachment() { + let picker = UIDocumentPickerViewController(forOpeningContentTypes: [.item], asCopy: true) + picker.allowsMultipleSelection = true + picker.delegate = self + textView.resignFirstResponder() + present(picker, animated: true) + } + + func documentPicker(_ controller: UIDocumentPickerViewController, didPickDocumentsAt urls: [URL]) { + do { + try save(force: true) + for url in urls { + let access = url.startAccessingSecurityScopedResource() + defer { if access { url.stopAccessingSecurityScopedResource() } } + let type = try url.resourceValues(forKeys: [.contentTypeKey]).contentType + try store.addAttachment(from: url, to: entry.id, mimeType: type?.preferredMIMEType) + } + } catch { showError(error) } + refreshAttachments() + } + + private func refreshAttachments() { + attachments = store.attachments(for: entry.id) + attachmentsHeight.constant = CGFloat(min(attachments.count, 3)) * 60 + attachmentsTable.reloadData() + updateToolbar() + } + + func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { attachments.count } + func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + let attachment = attachments[indexPath.row] + let cell = UITableViewCell(style: .subtitle, reuseIdentifier: nil) + var content = cell.defaultContentConfiguration() + content.text = attachment.fileName + let fileExists = (try? store.attachmentURL(attachment)).map { FileManager.default.fileExists(atPath: $0.path) } ?? false + content.secondaryText = fileExists ? ByteCountFormatter.string(fromByteCount: attachment.size, countStyle: .file) : "File missing. Restore a backup to recover it." + content.image = UIImage(systemName: "doc") + cell.contentConfiguration = content + cell.backgroundColor = .clear + cell.accessoryType = .disclosureIndicator + return cell + } + + func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + tableView.deselectRow(at: indexPath, animated: true) + do { + let attachment = attachments[indexPath.row] + let url = try store.attachmentURL(attachment) + guard FileManager.default.fileExists(atPath: url.path) else { + throw FlowError.invalid("This file is missing. Restore a backup to recover it.") + } + previewItem = AttachmentPreview(url: url, title: attachment.fileName) + let preview = QLPreviewController() + preview.dataSource = self + present(preview, animated: true) + } catch { showError(error) } + } + + func tableView(_ tableView: UITableView, trailingSwipeActionsConfigurationForRowAt indexPath: IndexPath) -> UISwipeActionsConfiguration? { + let attachment = attachments[indexPath.row] + let delete = UIContextualAction(style: .destructive, title: "Delete") { [weak self] _, _, done in + guard let self else { done(false); return } + self.performStoreAction { try self.store.deleteAttachment(attachment.id) } + self.refreshAttachments() + done(true) + } + return UISwipeActionsConfiguration(actions: [delete]) + } + + @objc private func undoDelete() { + performStoreAction { try store.undoDeletion() } + refreshAttachments() + } + + @objc private func shareEntry(_ sender: UIBarButtonItem) { + do { + try save() + var items: [Any] = [] + if !entry.text.isEmpty { items.append(entry.text) } + items += try attachments.map { try store.attachmentURL($0) } + guard !items.isEmpty else { return } + let sheet = UIActivityViewController(activityItems: items, applicationActivities: nil) + sheet.popoverPresentationController?.barButtonItem = sender + present(sheet, animated: true) + } catch { showError(error) } + } + + func numberOfPreviewItems(in controller: QLPreviewController) -> Int { previewItem == nil ? 0 : 1 } + func previewController(_ controller: QLPreviewController, previewItemAt index: Int) -> QLPreviewItem { previewItem! } +} + +private final class AttachmentPreview: NSObject, QLPreviewItem { + let previewItemURL: URL? + let previewItemTitle: String? + init(url: URL, title: String) { previewItemURL = url; previewItemTitle = title } +} diff --git a/ios/Flow/FoldersViewController.swift b/ios/Flow/FoldersViewController.swift new file mode 100644 index 0000000..fcc1061 --- /dev/null +++ b/ios/Flow/FoldersViewController.swift @@ -0,0 +1,111 @@ +import UIKit +import FlowCore + +final class FoldersViewController: UITableViewController { + enum Mode { + case select((String) -> Void) + case move((String) -> Void) + var onSelect: (String) -> Void { + switch self { case .select(let action), .move(let action): return action } + } + } + private let store: FlowStore + private let mode: Mode + private var folders: [Folder] = [] + private var entryCounts: [String: Int] = [:] + + init(store: FlowStore, mode: Mode) { + self.store = store + self.mode = mode + super.init(style: .insetGrouped) + } + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } + + override func viewDidLoad() { + super.viewDidLoad() + if case .move = mode { title = "Move to folder" } else { title = "Folders" } + navigationItem.leftBarButtonItem = UIBarButtonItem(barButtonSystemItem: .done, target: self, action: #selector(close)) + navigationItem.rightBarButtonItems = [UIBarButtonItem(barButtonSystemItem: .add, target: self, action: #selector(addFolder)), editButtonItem] + navigationItem.rightBarButtonItems?.first?.accessibilityLabel = "New folder" + tableView.dragInteractionEnabled = true + reload() + } + + private func reload() { + folders = store.folders + entryCounts = store.library.entries.reduce(into: [:]) { $0[$1.folderId, default: 0] += 1 } + tableView.reloadData() + } + @objc private func close() { dismiss(animated: true) } + @objc private func addFolder() { editFolder(nil) } + + private func editFolder(_ folder: Folder?) { + let alert = UIAlertController(title: folder == nil ? "New folder" : "Rename folder", message: nil, preferredStyle: .alert) + alert.addTextField { field in field.placeholder = "Name"; field.text = folder?.name; field.autocapitalizationType = .sentences } + alert.addAction(UIAlertAction(title: "Cancel", style: .cancel)) + alert.addAction(UIAlertAction(title: "Save", style: .default) { [weak self, weak alert] _ in + guard let self else { return } + var updated = folder ?? Folder(name: "") + updated.name = alert?.textFields?.first?.text ?? "" + self.performStoreAction { try self.store.saveFolder(updated) } + self.reload() + }) + present(alert, animated: true) + } + + override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { folders.count } + override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + let folder = folders[indexPath.row] + let cell = UITableViewCell(style: .value1, reuseIdentifier: nil) + var content = cell.defaultContentConfiguration() + content.text = folder.name + content.secondaryText = "\(entryCounts[folder.id, default: 0])" + content.image = UIImage(systemName: "folder") + cell.contentConfiguration = content + cell.accessoryType = folder.id == Preferences.currentFolder ? .checkmark : .none + return cell + } + + override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + let folder = folders[indexPath.row] + if isEditing { editFolder(folder) } + else { dismiss(animated: true) { [mode] in mode.onSelect(folder.id) } } + } + + override func setEditing(_ editing: Bool, animated: Bool) { + super.setEditing(editing, animated: animated) + tableView.allowsSelectionDuringEditing = true + } + + override func tableView(_ tableView: UITableView, canMoveRowAt indexPath: IndexPath) -> Bool { true } + override func tableView(_ tableView: UITableView, moveRowAt source: IndexPath, to destination: IndexPath) { + let folder = folders.remove(at: source.row) + folders.insert(folder, at: destination.row) + performStoreAction { try store.reorderFolders(folders.map(\.id)) } + reload() + } + + override func tableView(_ tableView: UITableView, editingStyleForRowAt indexPath: IndexPath) -> UITableViewCell.EditingStyle { + folders[indexPath.row].id == Folder.masterID ? .none : .delete + } + + override func tableView(_ tableView: UITableView, commit editingStyle: UITableViewCell.EditingStyle, forRowAt indexPath: IndexPath) { + let folder = folders[indexPath.row] + let count = store.entries(in: folder.id).count + let alert = UIAlertController(title: "Delete \(folder.name)?", message: "This also deletes \(count) entries. You can undo this from the home screen.", preferredStyle: .alert) + alert.addAction(UIAlertAction(title: "Cancel", style: .cancel)) + alert.addAction(UIAlertAction(title: "Delete", style: .destructive) { [weak self] _ in + guard let self else { return } + self.performStoreAction { try self.store.deleteFolder(folder.id) } + self.reload() + }) + present(alert, animated: true) + } + + override func tableView(_ tableView: UITableView, contextMenuConfigurationForRowAt indexPath: IndexPath, point: CGPoint) -> UIContextMenuConfiguration? { + let folder = folders[indexPath.row] + return UIContextMenuConfiguration(identifier: nil, previewProvider: nil) { [weak self] _ in + UIMenu(children: [UIAction(title: "Rename", image: UIImage(systemName: "pencil")) { _ in self?.editFolder(folder) }]) + } + } +} diff --git a/ios/Flow/HomeViewController.swift b/ios/Flow/HomeViewController.swift new file mode 100644 index 0000000..fec4937 --- /dev/null +++ b/ios/Flow/HomeViewController.swift @@ -0,0 +1,260 @@ +import UIKit +import FlowCore + +final class HomeViewController: UITableViewController, UISearchResultsUpdating { + private let store: FlowStore + private var entries: [Entry] = [] + private var entriesWithAttachments: Set = [] + private let search = UISearchController(searchResultsController: nil) + private let emptyLabel = UILabel() + private var activeFolder: String? { Preferences.folders ? Preferences.currentFolder : nil } + + init(store: FlowStore) { + self.store = store + super.init(style: .plain) + } + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } + + override func viewDidLoad() { + super.viewDidLoad() + title = "Flow" + tableView.register(EntryCell.self, forCellReuseIdentifier: "Entry") + tableView.separatorStyle = .none + tableView.rowHeight = UITableView.automaticDimension + tableView.estimatedRowHeight = 108 + tableView.keyboardDismissMode = .onDrag + tableView.cellLayoutMarginsFollowReadableWidth = true + tableView.dragInteractionEnabled = true + tableView.accessibilityIdentifier = "entries" + search.searchResultsUpdater = self + search.obscuresBackgroundDuringPresentation = false + search.searchBar.placeholder = "Search entries" + navigationItem.searchController = search + definesPresentationContext = true + navigationItem.rightBarButtonItems = [ + UIBarButtonItem(image: UIImage(systemName: "gearshape"), style: .plain, target: self, action: #selector(openSettings)), + editButtonItem + ] + navigationItem.rightBarButtonItems?.first?.accessibilityLabel = "Settings" + emptyLabel.numberOfLines = 0 + emptyLabel.textAlignment = .center + emptyLabel.font = .preferredFont(forTextStyle: .body) + emptyLabel.adjustsFontForContentSizeCategory = true + emptyLabel.textColor = .secondaryLabel + store.onChange = { [weak self] in self?.reload() } + NotificationCenter.default.addObserver(self, selector: #selector(reload), name: .flowPreferencesChanged, object: nil) + reload() + } + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + navigationController?.setToolbarHidden(false, animated: animated) + reload() + } + + @objc private func reload() { + guard isViewLoaded else { return } + if !store.folders.contains(where: { $0.id == Preferences.currentFolder }) { + Preferences.currentFolder = Folder.masterID + } + tableView.backgroundColor = Theme.background + let query = search.searchBar.text?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + entries = store.entries(in: activeFolder).filter { query.isEmpty || $0.text.localizedCaseInsensitiveContains(query) } + entriesWithAttachments = Set(store.library.attachments.map(\.entryId)) + tableView.reloadData() + emptyLabel.text = query.isEmpty ? "Nothing here yet.\nTap + to add a note or task." : "No matching entries." + tableView.backgroundView = entries.isEmpty ? emptyLabel : nil + if Preferences.folders { + let folder = store.folders.first { $0.id == Preferences.currentFolder } + let button = UIBarButtonItem(title: folder?.name ?? "Master", image: UIImage(systemName: "folder"), target: self, action: #selector(openFolders)) + button.accessibilityLabel = "Folders" + navigationItem.leftBarButtonItem = button + } else { + navigationItem.leftBarButtonItem = nil + } + let undo = UIBarButtonItem(title: "Undo", image: UIImage(systemName: "arrow.uturn.backward"), target: self, action: #selector(undoDelete)) + undo.isEnabled = store.undoActionName != nil + undo.accessibilityHint = store.undoActionName + let countLabel = UILabel() + countLabel.text = "\(entries.count) \(entries.count == 1 ? "entry" : "entries")" + countLabel.font = .preferredFont(forTextStyle: .footnote) + countLabel.adjustsFontForContentSizeCategory = true + countLabel.textColor = .secondaryLabel + let count = UIBarButtonItem(customView: countLabel) + let add = UIBarButtonItem(image: UIImage(systemName: "plus"), style: .plain, target: self, action: #selector(addEntry)) + add.accessibilityLabel = "New entry" + add.accessibilityIdentifier = "newEntry" + toolbarItems = [undo, .flexibleSpace(), count, .flexibleSpace(), add] + } + + func updateSearchResults(for searchController: UISearchController) { reload() } + + @objc private func addEntry() { + let entry = Entry(type: Preferences.defaultKind, folderId: Preferences.folders ? Preferences.currentFolder : Folder.masterID) + let editor = EditorViewController(store: store, entry: entry, isNew: true) + navigationController?.pushViewController(editor, animated: true) + } + + @objc private func openSettings() { + let navigation = UINavigationController(rootViewController: SettingsViewController(store: store)) + navigation.modalPresentationStyle = .formSheet + present(navigation, animated: true) + } + + @objc private func openFolders() { + let controller = FoldersViewController(store: store, mode: .select { [weak self] id in + Preferences.currentFolder = id + self?.reload() + }) + present(UINavigationController(rootViewController: controller), animated: true) + } + + @objc private func undoDelete() { performStoreAction { try store.undoDeletion() } } + + override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { entries.count } + + override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + let entry = entries[indexPath.row] + let cell = tableView.dequeueReusableCell(withIdentifier: "Entry", for: indexPath) as! EntryCell + cell.configure(entry, hasAttachments: entriesWithAttachments.contains(entry.id)) + cell.onToggle = { [weak self] in + guard let self else { return } + self.performStoreAction { try self.store.toggleCompleted(entry.id) } + } + return cell + } + + override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + tableView.deselectRow(at: indexPath, animated: true) + navigationController?.pushViewController(EditorViewController(store: store, entry: entries[indexPath.row]), animated: true) + } + + override func tableView(_ tableView: UITableView, canMoveRowAt indexPath: IndexPath) -> Bool { + search.searchBar.text?.isEmpty != false + } + + override func tableView(_ tableView: UITableView, moveRowAt source: IndexPath, to destination: IndexPath) { + let entry = entries.remove(at: source.row) + entries.insert(entry, at: destination.row) + performStoreAction { try store.reorderEntries(entries.map(\.id), in: activeFolder) } + reload() + } + + override func tableView(_ tableView: UITableView, commit editingStyle: UITableViewCell.EditingStyle, forRowAt indexPath: IndexPath) { + if editingStyle == .delete { delete(entries[indexPath.row]) } + } + + override func tableView(_ tableView: UITableView, trailingSwipeActionsConfigurationForRowAt indexPath: IndexPath) -> UISwipeActionsConfiguration? { + guard Preferences.swipes else { return UISwipeActionsConfiguration(actions: []) } + let entry = entries[indexPath.row] + let delete = UIContextualAction(style: .destructive, title: "Delete") { [weak self] _, _, done in + self?.delete(entry) + done(true) + } + delete.image = UIImage(systemName: "trash") + return UISwipeActionsConfiguration(actions: [delete]) + } + + override func tableView(_ tableView: UITableView, leadingSwipeActionsConfigurationForRowAt indexPath: IndexPath) -> UISwipeActionsConfiguration? { + guard Preferences.swipes else { return UISwipeActionsConfiguration(actions: []) } + let entry = entries[indexPath.row] + let action = UIContextualAction(style: .normal, title: entry.type == .task ? "Toggle" : "Make task") { [weak self] _, _, done in + self?.performStoreAction { + guard let self else { return } + if entry.type == .task { try self.store.toggleCompleted(entry.id) } + else { var updated = entry; updated.type = .task; try self.store.save(updated) } + } + done(true) + } + action.backgroundColor = Theme.accent + action.image = UIImage(systemName: "checkmark.circle") + return UISwipeActionsConfiguration(actions: [action]) + } + + override func tableView(_ tableView: UITableView, contextMenuConfigurationForRowAt indexPath: IndexPath, point: CGPoint) -> UIContextMenuConfiguration? { + let entry = entries[indexPath.row] + return UIContextMenuConfiguration(identifier: nil, previewProvider: nil) { [weak self] _ in + guard let self else { return nil } + let convert = UIAction(title: entry.type == .note ? "Make task" : "Make note", image: UIImage(systemName: "checklist")) { _ in + var updated = entry + updated.type = entry.type == .note ? .task : .note + updated.completed = false + self.performStoreAction { try self.store.save(updated) } + } + var actions: [UIMenuElement] = [convert] + if Preferences.folders { + actions.append(UIAction(title: "Move to folder", image: UIImage(systemName: "folder")) { _ in + let picker = FoldersViewController(store: self.store, mode: .move { id in + self.performStoreAction { try self.store.moveEntry(entry.id, to: id) } + }) + self.present(UINavigationController(rootViewController: picker), animated: true) + }) + } + actions.append(UIAction(title: "Delete", image: UIImage(systemName: "trash"), attributes: .destructive) { _ in self.delete(entry) }) + return UIMenu(children: actions) + } + } + + private func delete(_ entry: Entry) { performStoreAction { try store.deleteEntry(entry.id) } } +} + +private final class EntryCell: UITableViewCell { + var onToggle: (() -> Void)? + private let card = UIView() + private let text = UILabel() + private let toggle = UIButton(type: .system) + private let attachment = UIImageView(image: UIImage(systemName: "paperclip")) + + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + super.init(style: style, reuseIdentifier: reuseIdentifier) + backgroundColor = .clear + selectionStyle = .none + card.backgroundColor = .secondarySystemGroupedBackground + card.layer.cornerRadius = 22 + card.layer.cornerCurve = .continuous + card.translatesAutoresizingMaskIntoConstraints = false + contentView.addSubview(card) + text.adjustsFontForContentSizeCategory = true + text.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) + toggle.addTarget(self, action: #selector(toggled), for: .touchUpInside) + toggle.setContentHuggingPriority(.required, for: .horizontal) + toggle.widthAnchor.constraint(equalToConstant: 44).isActive = true + toggle.heightAnchor.constraint(equalToConstant: 44).isActive = true + attachment.tintColor = .tertiaryLabel + attachment.contentMode = .scaleAspectFit + attachment.widthAnchor.constraint(equalToConstant: 16).isActive = true + attachment.heightAnchor.constraint(equalToConstant: 22).isActive = true + let stack = UIStackView(arrangedSubviews: [toggle, text, attachment]) + stack.alignment = .top + stack.spacing = 10 + stack.translatesAutoresizingMaskIntoConstraints = false + card.addSubview(stack) + NSLayoutConstraint.activate([ + card.leadingAnchor.constraint(equalTo: contentView.layoutMarginsGuide.leadingAnchor), + card.trailingAnchor.constraint(equalTo: contentView.layoutMarginsGuide.trailingAnchor), + card.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 5), + card.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -5), + stack.leadingAnchor.constraint(equalTo: card.leadingAnchor, constant: 18), + stack.trailingAnchor.constraint(equalTo: card.trailingAnchor, constant: -18), + stack.topAnchor.constraint(equalTo: card.topAnchor, constant: 18), + stack.bottomAnchor.constraint(equalTo: card.bottomAnchor, constant: -18) + ]) + } + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } + @objc private func toggled() { onToggle?() } + + func configure(_ entry: Entry, hasAttachments: Bool) { + text.font = Theme.font() + text.numberOfLines = Preferences.previewLines + text.textColor = entry.completed ? .secondaryLabel : .label + let value = entry.text.isEmpty ? (hasAttachments ? "Attachment" : "Empty \(entry.type.rawValue)") : entry.text + text.attributedText = NSAttributedString(string: value, attributes: entry.completed ? [.strikethroughStyle: NSUnderlineStyle.single.rawValue] : [:]) + toggle.isHidden = entry.type != .task + toggle.setImage(UIImage(systemName: entry.completed ? "checkmark.circle.fill" : "circle", withConfiguration: UIImage.SymbolConfiguration(pointSize: 24)), for: .normal) + toggle.accessibilityLabel = entry.completed ? "Mark incomplete" : "Complete task" + toggle.accessibilityValue = entry.completed ? "Completed" : "Incomplete" + attachment.isHidden = !hasAttachments + attachment.accessibilityLabel = "Has attachments" + attachment.isAccessibilityElement = hasAttachments + } +} diff --git a/ios/Flow/Info.plist b/ios/Flow/Info.plist new file mode 100644 index 0000000..bf11606 --- /dev/null +++ b/ios/Flow/Info.plist @@ -0,0 +1,37 @@ + + + + CFBundleDisplayNameFlow + CFBundleExecutable$(EXECUTABLE_NAME) + CFBundleIdentifier$(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion6.0 + CFBundleName$(PRODUCT_NAME) + CFBundlePackageTypeAPPL + CFBundleShortVersionString$(MARKETING_VERSION) + CFBundleVersion$(CURRENT_PROJECT_VERSION) + LSRequiresIPhoneOS + UIApplicationSceneManifest + UIApplicationSupportsMultipleScenes + + UILaunchScreen + UISupportedInterfaceOrientations + UIInterfaceOrientationPortrait + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UISupportedInterfaceOrientations~ipad + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UTImportedTypeDeclarations + UTTypeIdentifierdev.jvqtil.flow.backup + UTTypeDescriptionFlow backup + UTTypeConformsTopublic.zip-archive + UTTypeTagSpecification + public.filename-extensionflow + public.mime-typeapplication/zip + + + diff --git a/ios/Flow/Preferences.swift b/ios/Flow/Preferences.swift new file mode 100644 index 0000000..0982527 --- /dev/null +++ b/ios/Flow/Preferences.swift @@ -0,0 +1,74 @@ +import UIKit +import FlowCore + +enum Preferences { + static var folders: Bool { + get { UserDefaults.standard.bool(forKey: "folders") } + set { UserDefaults.standard.set(newValue, forKey: "folders"); changed() } + } + static var swipes: Bool { + get { UserDefaults.standard.object(forKey: "swipes") as? Bool ?? true } + set { UserDefaults.standard.set(newValue, forKey: "swipes"); changed() } + } + static var pureBlack: Bool { + get { UserDefaults.standard.bool(forKey: "pureBlack") } + set { UserDefaults.standard.set(newValue, forKey: "pureBlack"); changed() } + } + static var codeKeyboard: Bool { + get { UserDefaults.standard.bool(forKey: "codeKeyboard") } + set { UserDefaults.standard.set(newValue, forKey: "codeKeyboard"); changed() } + } + static var defaultKind: EntryKind { + get { EntryKind(rawValue: UserDefaults.standard.string(forKey: "defaultKind") ?? "") ?? .note } + set { UserDefaults.standard.set(newValue.rawValue, forKey: "defaultKind"); changed() } + } + static var previewLines: Int { + get { max(1, min(12, UserDefaults.standard.object(forKey: "previewLines") as? Int ?? 4)) } + set { UserDefaults.standard.set(newValue, forKey: "previewLines"); changed() } + } + static var uiFont: String { + get { UserDefaults.standard.string(forKey: "uiFont") ?? "System" } + set { UserDefaults.standard.set(newValue, forKey: "uiFont"); changed() } + } + static var editorFont: String { + get { UserDefaults.standard.string(forKey: "editorFont") ?? "UI font" } + set { UserDefaults.standard.set(newValue, forKey: "editorFont"); changed() } + } + static var currentFolder: String { + get { UserDefaults.standard.string(forKey: "currentFolder") ?? Folder.masterID } + set { UserDefaults.standard.set(newValue, forKey: "currentFolder") } + } + private static func changed() { NotificationCenter.default.post(name: .flowPreferencesChanged, object: nil) } +} + +enum Theme { + static let accent = UIColor { traits in + traits.userInterfaceStyle == .dark + ? UIColor(red: 0.72, green: 0.65, blue: 1, alpha: 1) + : UIColor(red: 0.46, green: 0.35, blue: 0.80, alpha: 1) + } + static var background: UIColor { + UIColor { traits in + Preferences.pureBlack && traits.userInterfaceStyle == .dark ? .black : .systemGroupedBackground + } + } + static func font(editor: Bool = false, style: UIFont.TextStyle = .body) -> UIFont { + let name = editor && Preferences.editorFont != "UI font" ? Preferences.editorFont : Preferences.uiFont + let base = UIFont.systemFont(ofSize: editor ? 19 : 17) + let design: UIFontDescriptor.SystemDesign = name == "Monospaced" ? .monospaced : name == "Serif" ? .serif : .default + let descriptor = base.fontDescriptor.withDesign(design) ?? base.fontDescriptor + return UIFontMetrics(forTextStyle: style).scaledFont(for: UIFont(descriptor: descriptor, size: base.pointSize)) + } +} + +extension UIViewController { + func showError(_ error: Error) { + let alert = UIAlertController(title: "Could not complete the action", message: error.localizedDescription, preferredStyle: .alert) + alert.addAction(UIAlertAction(title: "OK", style: .default)) + present(alert, animated: true) + } + + func performStoreAction(_ action: () throws -> Void) { + do { try action() } catch { showError(error) } + } +} diff --git a/ios/Flow/PrivacyInfo.xcprivacy b/ios/Flow/PrivacyInfo.xcprivacy new file mode 100644 index 0000000..ad40e9b --- /dev/null +++ b/ios/Flow/PrivacyInfo.xcprivacy @@ -0,0 +1,16 @@ + + + + NSPrivacyTracking + NSPrivacyCollectedDataTypes + NSPrivacyAccessedAPITypes + + NSPrivacyAccessedAPITypeNSPrivacyAccessedAPICategoryUserDefaults + NSPrivacyAccessedAPITypeReasonsCA92.1 + + + NSPrivacyAccessedAPITypeNSPrivacyAccessedAPICategoryFileTimestamp + NSPrivacyAccessedAPITypeReasonsC617.1 + + + diff --git a/ios/Flow/SceneDelegate.swift b/ios/Flow/SceneDelegate.swift new file mode 100644 index 0000000..dee358a --- /dev/null +++ b/ios/Flow/SceneDelegate.swift @@ -0,0 +1,87 @@ +import UIKit +import FlowCore + +final class SceneDelegate: UIResponder, UIWindowSceneDelegate { + var window: UIWindow? + private var store: FlowStore? + private var libraryDirectory: URL? + + func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) { + guard let scene = scene as? UIWindowScene else { return } + let window = UIWindow(windowScene: scene) + self.window = window + window.tintColor = Theme.accent + do { + let base = try FileManager.default.url(for: .applicationSupportDirectory, in: .userDomainMask, + appropriateFor: nil, create: true) + var directory = base.appendingPathComponent("Flow", isDirectory: true) + #if DEBUG + if ProcessInfo.processInfo.arguments.contains("--ui-testing") { + directory = base.appendingPathComponent("FlowUITests", isDirectory: true) + if ProcessInfo.processInfo.arguments.contains("--reset-library") { + try? FileManager.default.removeItem(at: directory) + UserDefaults.standard.removePersistentDomain(forName: Bundle.main.bundleIdentifier!) + } + } + #endif + libraryDirectory = directory + let store = try FlowStore(directory: directory) + try install(store) + } catch { + showStartupError(error) + } + } + + private func install(_ store: FlowStore) throws { + guard let window else { return } + // Flow has no account or cloud sync. Exclude its local library from device backups too. + var directory = store.directory + var values = URLResourceValues() + values.isExcludedFromBackup = true + try directory.setResourceValues(values) + self.store = store + let home = HomeViewController(store: store) + let navigation = UINavigationController(rootViewController: home) + navigation.navigationBar.prefersLargeTitles = true + window.rootViewController = navigation + window.makeKeyAndVisible() + if !store.missingAttachments.isEmpty { + DispatchQueue.main.async { + let count = store.missingAttachments.count + let alert = UIAlertController(title: "Some attachment files are missing", + message: "\(count) files could not be found. Your notes and attachment records are still available. Restore a backup to recover the missing files.", preferredStyle: .alert) + alert.addAction(UIAlertAction(title: "OK", style: .default)) + home.present(alert, animated: true) + } + } + } + + private func showStartupError(_ error: Error) { + guard let window else { return } + let controller = UIViewController() + controller.view.backgroundColor = .systemBackground + window.rootViewController = controller + window.makeKeyAndVisible() + let alert = UIAlertController(title: "Could not open Flow", message: error.localizedDescription + "\nYour saved files have not been replaced. Starting a new library preserves the original files separately so they can be recovered later.", preferredStyle: .alert) + alert.addAction(UIAlertAction(title: "Close", style: .cancel)) + if let directory = libraryDirectory { + alert.addAction(UIAlertAction(title: "Start a new library", style: .destructive) { [weak self] _ in + guard let self else { return } + do { + let recovery = try FlowStore.startNewLibrary(at: directory) + try self.install(recovery.store) + } catch { self.showStartupError(error) } + }) + } + controller.present(alert, animated: false) + } + + func sceneWillResignActive(_ scene: UIScene) { + NotificationCenter.default.post(name: .flowSaveRequested, object: nil) + } +} + +extension Notification.Name { + static let flowSaveRequested = Notification.Name("flowSaveRequested") + static let flowPreferencesChanged = Notification.Name("flowPreferencesChanged") +} diff --git a/ios/Flow/SettingsViewController.swift b/ios/Flow/SettingsViewController.swift new file mode 100644 index 0000000..ab9962e --- /dev/null +++ b/ios/Flow/SettingsViewController.swift @@ -0,0 +1,233 @@ +import UIKit +import UniformTypeIdentifiers +import FlowCore + +final class SettingsViewController: UITableViewController, UIDocumentPickerDelegate { + private let store: FlowStore + private var isBusy = false + private var exportURL: URL? + + init(store: FlowStore) { self.store = store; super.init(style: .insetGrouped) } + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } + + override func viewDidLoad() { + super.viewDidLoad() + title = "Settings" + navigationItem.rightBarButtonItem = UIBarButtonItem(barButtonSystemItem: .done, target: self, action: #selector(close)) + tableView.backgroundColor = Theme.background + } + @objc private func close() { dismiss(animated: true) } + override func numberOfSections(in tableView: UITableView) -> Int { 4 } + override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { [3, 4, 2, 1][section] } + override func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? { + ["Features", "Editor", "Your data", "About"][section] + } + override func tableView(_ tableView: UITableView, titleForFooterInSection section: Int) -> String? { + switch section { + case 0: return "Folders are optional. With folders off, all entries appear together." + case 2: return "Backups include notes, tasks, folders, and files. The .flow format works with Flow for Android. Import replaces the current library after you confirm." + case 3: return "Everything stays on this device. No account, analytics, or cloud sync." + default: return nil + } + } + override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + let cell = UITableViewCell(style: .value1, reuseIdentifier: nil) + switch (indexPath.section, indexPath.row) { + case (0, 0): configureSwitch(cell, title: "Folders", value: Preferences.folders, tag: 0) + case (0, 1): configureSwitch(cell, title: "Swipe gestures", value: Preferences.swipes, tag: 1) + case (0, 2): configureSwitch(cell, title: "Pure black", value: Preferences.pureBlack, tag: 2) + case (1, 0): cell.textLabel?.text = "Default entry type"; cell.detailTextLabel?.text = Preferences.defaultKind.rawValue.capitalized + case (1, 1): cell.textLabel?.text = "Fonts"; cell.detailTextLabel?.text = Preferences.uiFont + case (1, 2): cell.textLabel?.text = "Preview lines"; cell.detailTextLabel?.text = String(Preferences.previewLines) + case (1, 3): configureSwitch(cell, title: "Code keyboard", value: Preferences.codeKeyboard, tag: 3) + case (2, 0): cell.textLabel?.text = isBusy ? "Working…" : "Export backup"; cell.imageView?.image = UIImage(systemName: "square.and.arrow.up") + case (2, 1): cell.textLabel?.text = "Import backup"; cell.imageView?.image = UIImage(systemName: "square.and.arrow.down") + default: cell.textLabel?.text = "Flow for iOS"; cell.detailTextLabel?.text = "1.0" + } + if indexPath.section == 1 && indexPath.row < 3 { cell.accessoryType = .disclosureIndicator } + if indexPath.section == 2 { cell.textLabel?.textColor = view.tintColor; cell.isUserInteractionEnabled = !isBusy } + return cell + } + + private func configureSwitch(_ cell: UITableViewCell, title: String, value: Bool, tag: Int) { + cell.textLabel?.text = title + let toggle = UISwitch() + toggle.isOn = value + toggle.tag = tag + toggle.accessibilityLabel = title + toggle.addTarget(self, action: #selector(toggleChanged), for: .valueChanged) + cell.accessoryView = toggle + cell.selectionStyle = .none + } + + @objc private func toggleChanged(_ toggle: UISwitch) { + switch toggle.tag { + case 0: Preferences.folders = toggle.isOn + case 1: Preferences.swipes = toggle.isOn + case 2: Preferences.pureBlack = toggle.isOn; tableView.backgroundColor = Theme.background + default: Preferences.codeKeyboard = toggle.isOn + } + } + + override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + tableView.deselectRow(at: indexPath, animated: true) + switch (indexPath.section, indexPath.row) { + case (1, 0): choose("Default entry type", values: ["Note", "Task"], selected: Preferences.defaultKind.rawValue.capitalized) { Preferences.defaultKind = $0 == "Task" ? .task : .note } + case (1, 1): + let controller = FontSettingsViewController() + navigationController?.pushViewController(controller, animated: true) + case (1, 2): choose("Preview lines", values: (1...12).map(String.init), selected: String(Preferences.previewLines)) { Preferences.previewLines = Int($0) ?? 4 } + case (2, 0): exportBackup() + case (2, 1): + let picker = UIDocumentPickerViewController(forOpeningContentTypes: [.flowBackup, .zip, .json, .data], asCopy: true) + picker.delegate = self + present(picker, animated: true) + default: break + } + } + + private func choose(_ title: String, values: [String], selected: String, action: @escaping (String) -> Void) { + navigationController?.pushViewController(ChoiceViewController(title: title, values: values, selected: selected) { [weak self] value in + action(value) + self?.tableView.reloadData() + }, animated: true) + } + + private func setBusy(_ busy: Bool) { + isBusy = busy + isModalInPresentation = busy + navigationController?.isModalInPresentation = busy + navigationItem.rightBarButtonItem?.isEnabled = !busy + tableView.reloadData() + if busy { + let spinner = UIActivityIndicatorView(style: .medium) + spinner.startAnimating() + navigationItem.leftBarButtonItem = UIBarButtonItem(customView: spinner) + } else { + navigationItem.leftBarButtonItem = nil + } + } + + private func exportBackup() { + guard !isBusy else { return } + do { + let library = store.library + let urls = try Dictionary(uniqueKeysWithValues: library.attachments.map { ($0.id, try store.attachmentURL($0)) }) + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + let url = directory.appendingPathComponent("Flow-backup.flow") + setBusy(true) + Task { + do { + try await Task.detached(priority: .userInitiated) { try FlowBackup.export(library: library, attachmentURLs: urls, to: url) }.value + exportURL = url + setBusy(false) + let picker = UIDocumentPickerViewController(forExporting: [url], asCopy: true) + picker.delegate = self + present(picker, animated: true) + } catch { + try? FileManager.default.removeItem(at: directory) + setBusy(false) + showError(error) + } + } + } catch { showError(error) } + } + + func documentPickerWasCancelled(_ controller: UIDocumentPickerViewController) { cleanExport() } + + func documentPicker(_ controller: UIDocumentPickerViewController, didPickDocumentsAt urls: [URL]) { + if exportURL != nil { cleanExport(); return } + guard let url = urls.first else { return } + setBusy(true) + Task { + do { + let backup = try await Task.detached(priority: .userInitiated) { + let access = url.startAccessingSecurityScopedResource() + defer { if access { url.stopAccessingSecurityScopedResource() } } + return try FlowBackup.prepare(from: url) + }.value + setBusy(false) + confirmImport(backup) + } catch { setBusy(false); showError(error) } + } + } + + private func confirmImport(_ backup: PreparedBackup) { + let library = backup.library + let notes = library.entries.filter { $0.type == .note }.count + let tasks = library.entries.count - notes + let message = "This backup contains \(notes) notes, \(tasks) tasks, \(library.folders.count) folders, and \(library.attachments.count) attachments.\n\nIt will replace all entries and attachments on this device. Export a backup first if you want to keep them." + let alert = UIAlertController(title: "Replace library?", message: message, preferredStyle: .alert) + alert.addAction(UIAlertAction(title: "Cancel", style: .cancel)) + alert.addAction(UIAlertAction(title: "Replace library", style: .destructive) { [weak self] _ in + guard let self else { return } + self.setBusy(true) + Task { + do { + try await self.store.restore(backup) + Preferences.currentFolder = Folder.masterID + self.setBusy(false) + self.dismiss(animated: true) + } catch { self.setBusy(false); self.showError(error) } + } + }) + present(alert, animated: true) + } + + private func cleanExport() { + if let exportURL { try? FileManager.default.removeItem(at: exportURL.deletingLastPathComponent()) } + exportURL = nil + } +} + +private final class FontSettingsViewController: UITableViewController { + init() { super.init(style: .insetGrouped); title = "Fonts" } + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } + override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { 2 } + override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + let cell = UITableViewCell(style: .value1, reuseIdentifier: nil) + cell.textLabel?.text = indexPath.row == 0 ? "UI font" : "Editor font" + cell.detailTextLabel?.text = indexPath.row == 0 ? Preferences.uiFont : Preferences.editorFont + cell.accessoryType = .disclosureIndicator + return cell + } + override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + let editor = indexPath.row == 1 + let values = (editor ? ["UI font"] : []) + ["System", "Monospaced", "Serif"] + let controller = ChoiceViewController(title: editor ? "Editor font" : "UI font", values: values, selected: editor ? Preferences.editorFont : Preferences.uiFont) { [weak self] value in + if editor { Preferences.editorFont = value } else { Preferences.uiFont = value } + self?.tableView.reloadData() + } + navigationController?.pushViewController(controller, animated: true) + } +} + +private final class ChoiceViewController: UITableViewController { + private let values: [String] + private let selected: String + private let onSelect: (String) -> Void + init(title: String, values: [String], selected: String, onSelect: @escaping (String) -> Void) { + self.values = values + self.selected = selected + self.onSelect = onSelect + super.init(style: .insetGrouped) + self.title = title + } + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } + override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { values.count } + override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + let cell = UITableViewCell(style: .default, reuseIdentifier: nil) + cell.textLabel?.text = values[indexPath.row] + cell.accessoryType = values[indexPath.row] == selected ? .checkmark : .none + return cell + } + override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + onSelect(values[indexPath.row]) + navigationController?.popViewController(animated: true) + } +} + +extension UTType { + static let flowBackup = UTType(importedAs: "dev.jvqtil.flow.backup", conformingTo: .zip) +} diff --git a/ios/FlowCore/Package.resolved b/ios/FlowCore/Package.resolved new file mode 100644 index 0000000..c9f3ca8 --- /dev/null +++ b/ios/FlowCore/Package.resolved @@ -0,0 +1,14 @@ +{ + "pins" : [ + { + "identity" : "zipfoundation", + "kind" : "remoteSourceControl", + "location" : "https://github.com/weichsel/ZIPFoundation.git", + "state" : { + "revision" : "22787ffb59de99e5dc1fbfe80b19c97a904ad48d", + "version" : "0.9.20" + } + } + ], + "version" : 2 +} diff --git a/ios/FlowCore/Package.swift b/ios/FlowCore/Package.swift new file mode 100644 index 0000000..048a24a --- /dev/null +++ b/ios/FlowCore/Package.swift @@ -0,0 +1,13 @@ +// swift-tools-version: 5.9 +import PackageDescription + +let package = Package( + name: "FlowCore", + platforms: [.iOS(.v16), .macOS(.v13)], + products: [.library(name: "FlowCore", targets: ["FlowCore"])], + dependencies: [.package(url: "https://github.com/weichsel/ZIPFoundation.git", exact: "0.9.20")], + targets: [ + .target(name: "FlowCore", dependencies: ["ZIPFoundation"]), + .testTarget(name: "FlowCoreTests", dependencies: ["FlowCore", "ZIPFoundation"], resources: [.copy("Fixtures")]) + ] +) diff --git a/ios/FlowCore/Sources/FlowCore/FlowBackup.swift b/ios/FlowCore/Sources/FlowCore/FlowBackup.swift new file mode 100644 index 0000000..79d2b20 --- /dev/null +++ b/ios/FlowCore/Sources/FlowCore/FlowBackup.swift @@ -0,0 +1,189 @@ +import Foundation +import ZIPFoundation + +public final class PreparedBackup { + public let library: Library + public let createdAt: String + let files: [String: URL] + private let temporaryDirectory: URL + + init(library: Library, createdAt: String, files: [String: URL], directory: URL) { + self.library = library + self.createdAt = createdAt + self.files = files + temporaryDirectory = directory + } + + deinit { try? FileManager.default.removeItem(at: temporaryDirectory) } +} + +public enum FlowBackup { + // Bounds apply before extraction, including ZIP files with extreme compression ratios. + private static let metadataLimit: UInt64 = 32 * 1024 * 1024 + private static let attachmentLimit: UInt64 = 512 * 1024 * 1024 + private static let totalLimit: UInt64 = 2 * 1024 * 1024 * 1024 + + private struct Manifest: Codable { + let format: String + let formatVersion: Int + let flowVersion: String + let createdAt: String + } + + public static func export(library: Library, attachmentURLs: [String: URL], to url: URL) throws { + try library.validate() + let archive = try Archive(url: url, accessMode: .create) + do { + let manifest = Manifest(format: "flow", formatVersion: 1, flowVersion: "iOS 1.0", createdAt: FlowDate.string()) + try add(manifest, path: "manifest.json", to: archive) + try add(library.folders, path: "data/folders.json", to: archive) + try add(library.entries, path: "data/entries.json", to: archive) + let metadata = library.attachments.map { attachment -> Attachment in + var copy = attachment + copy.storageName = nil + return copy + } + try add(metadata, path: "data/attachments.json", to: archive) + for attachment in library.attachments { + try validateAttachmentID(attachment.id) + guard let source = attachmentURLs[attachment.id] else { + throw FlowError.invalid("An attachment is missing. The backup was not created.") + } + try archive.addEntry(with: "attachments/" + attachment.id, fileURL: source, compressionMethod: .deflate) + } + } catch { + try? FileManager.default.removeItem(at: url) + throw error + } + } + + public static func prepare(from url: URL) throws -> PreparedBackup { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + do { + let handle = try FileHandle(forReadingFrom: url) + let prefix = try handle.read(upToCount: 64) ?? Data() + try handle.close() + if prefix.first(where: { ![9, 10, 13, 32].contains($0) }) == 123 { + return try prepareLegacy(url, directory: directory) + } + let archive = try Archive(url: url, accessMode: .read) + let paths = archive.map(\.path) + guard Set(paths).count == paths.count else { throw FlowError.invalid("The archive contains duplicate file paths.") } + let manifest: Manifest = try read("manifest.json", from: archive) + guard manifest.format == "flow", manifest.formatVersion == 1 else { + throw FlowError.invalid("This Flow backup format is not supported.") + } + var library = Library(folders: try read("data/folders.json", from: archive), + entries: try read("data/entries.json", from: archive), + attachments: try read("data/attachments.json", from: archive)) + ensureMaster(in: &library) + // Android uses -1 when a document provider cannot report a file's size. + for index in library.attachments.indices where library.attachments[index].size == -1 { + let attachment = library.attachments[index] + guard let entry = archive["attachments/" + attachment.id], entry.type == .file, + entry.uncompressedSize <= attachmentLimit else { + throw FlowError.invalid("An attachment is missing or too large.") + } + library.attachments[index].size = Int64(entry.uncompressedSize) + } + try library.validate() + var total: UInt64 = 0 + var files: [String: URL] = [:] + for index in library.attachments.indices { + let attachment = library.attachments[index] + try validateAttachmentID(attachment.id) + guard let entry = archive["attachments/" + attachment.id], entry.type == .file, + entry.uncompressedSize <= attachmentLimit, + entry.uncompressedSize == UInt64(attachment.size) else { + throw FlowError.invalid("An attachment is missing, too large, or has the wrong size.") + } + total += entry.uncompressedSize + guard total <= totalLimit else { throw FlowError.invalid("The backup exceeds the 2 GB import limit.") } + // Never use an archive path or imported ID as a destination path. + let destination = directory.appendingPathComponent(UUID().uuidString) + let checksum = try archive.extract(entry, to: destination) + guard checksum == entry.checksum else { throw FlowError.invalid("An attachment is damaged.") } + files[attachment.id] = destination + library.attachments[index].storageName = nil + } + return PreparedBackup(library: library, createdAt: manifest.createdAt, files: files, directory: directory) + } catch { + try? FileManager.default.removeItem(at: directory) + throw error + } + } + + private static func add(_ value: T, path: String, to archive: Archive) throws { + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + let data = try encoder.encode(value) + try archive.addEntry(with: path, type: .file, uncompressedSize: Int64(data.count), compressionMethod: .deflate) { offset, size in + data.subdata(in: Int(offset)..<(Int(offset) + size)) + } + } + + private static func read(_ path: String, from archive: Archive) throws -> T { + guard let entry = archive[path], entry.type == .file, entry.uncompressedSize <= metadataLimit else { + throw FlowError.invalid("Missing or oversized backup metadata: \(path)") + } + var data = Data() + let checksum = try archive.extract(entry) { chunk in + guard UInt64(data.count + chunk.count) <= metadataLimit else { + throw FlowError.invalid("Backup metadata is too large.") + } + data.append(chunk) + } + guard checksum == entry.checksum else { throw FlowError.invalid("The backup metadata is damaged.") } + return try JSONDecoder().decode(T.self, from: data) + } + + private static func validateAttachmentID(_ id: String) throws { + guard !id.isEmpty, id != ".", id != "..", !id.contains("/"), !id.contains("\\"), !id.contains("\0") else { + throw FlowError.invalid("Invalid attachment ID.") + } + } + + private static func ensureMaster(in library: inout Library) { + if !library.folders.contains(where: { $0.id == Folder.masterID }) { + library.folders.insert(Folder(id: Folder.masterID, name: "Master", position: -1), at: 0) + } + } + + private struct LegacyBackup: Decodable { + let version: Int + let folders: [Folder] + let notes: [LegacyEntry] + } + + private struct LegacyEntry: Decodable { + let id: String + let text: String + let createdAt: Int64 + let position: Int64 + let type: EntryKind + let completed: Bool + let folderId: String + } + + private static func prepareLegacy(_ url: URL, directory: URL) throws -> PreparedBackup { + guard let size = try url.resourceValues(forKeys: [.fileSizeKey]).fileSize, + size >= 0, size <= metadataLimit else { + throw FlowError.invalid("The legacy backup is too large or its size cannot be read.") + } + let handle = try FileHandle(forReadingFrom: url) + defer { try? handle.close() } + // Bound the read too, in case the file grows after the metadata check. + let data = try handle.read(upToCount: Int(metadataLimit) + 1) ?? Data() + guard data.count <= metadataLimit else { throw FlowError.invalid("The legacy backup is too large.") } + let legacy = try JSONDecoder().decode(LegacyBackup.self, from: data) + guard legacy.version == 5 else { throw FlowError.invalid("Only version 5 legacy backups are supported.") } + var library = Library(folders: legacy.folders, entries: legacy.notes.map { + Entry(id: $0.id, text: $0.text, createdAt: FlowDate.string(Date(timeIntervalSince1970: Double($0.createdAt) / 1000)), + position: $0.position, type: $0.type, completed: $0.completed, folderId: $0.folderId) + }) + ensureMaster(in: &library) + try library.validate() + return PreparedBackup(library: library, createdAt: "", files: [:], directory: directory) + } +} diff --git a/ios/FlowCore/Sources/FlowCore/FlowStore.swift b/ios/FlowCore/Sources/FlowCore/FlowStore.swift new file mode 100644 index 0000000..b819f5a --- /dev/null +++ b/ios/FlowCore/Sources/FlowCore/FlowStore.swift @@ -0,0 +1,312 @@ +import Foundation + +@MainActor +public final class FlowStore { + public private(set) var library: Library + public private(set) var isRestoring = false + /// Records whose files were absent at startup. Keep the metadata for recovery. + public private(set) var missingAttachments: [Attachment] = [] + public var onChange: (() -> Void)? + public var undoActionName: String? { history.last?.name } + public let directory: URL + private let fileManager = FileManager.default + private var history: [(name: String, library: Library)] = [] + private var libraryURL: URL { directory.appendingPathComponent("library.json") } + private var attachmentsURL: URL { directory.appendingPathComponent("attachments", isDirectory: true) } + + public init(directory: URL) throws { + self.directory = directory + library = Library() + try fileManager.createDirectory(at: attachmentsURL, withIntermediateDirectories: true) + if fileManager.fileExists(atPath: libraryURL.path) { + library = try JSONDecoder().decode(Library.self, from: Data(contentsOf: libraryURL)) + try library.validate() + for attachment in library.attachments { + if !fileManager.fileExists(atPath: try attachmentURL(attachment).path) { + missingAttachments.append(attachment) + } + } + } else { + try persist(library) + } + // Deleted files survive during the session so deletion can be undone. + let referenced = Set(library.attachments.compactMap(\.storageName)) + for url in try fileManager.contentsOfDirectory(at: attachmentsURL, includingPropertiesForKeys: nil) + where !referenced.contains(url.lastPathComponent) { + try? fileManager.removeItem(at: url) + } + } + + public var folders: [Folder] { + library.folders.sorted { ($0.position, $0.id) < ($1.position, $1.id) } + } + + public func entries(in folderID: String? = nil) -> [Entry] { + library.entries.filter { folderID == nil || $0.folderId == folderID } + .sorted { ($0.position, $0.createdAt, $0.id) < ($1.position, $1.createdAt, $1.id) } + } + + public func attachments(for entryID: String) -> [Attachment] { + library.attachments.filter { $0.entryId == entryID } + } + + public func entry(_ id: String) -> Entry? { library.entries.first { $0.id == id } } + + public func save(_ entry: Entry) throws { + try change { library in + guard library.folders.contains(where: { $0.id == entry.folderId }) else { + throw FlowError.invalid("The folder no longer exists.") + } + if let index = library.entries.firstIndex(where: { $0.id == entry.id }) { + library.entries[index] = entry + } else { + let ordered = library.entries.filter { $0.folderId == entry.folderId } + .sorted { ($0.position, $0.id) < ($1.position, $1.id) } + for (position, old) in ordered.enumerated() { + if let index = library.entries.firstIndex(where: { $0.id == old.id }) { + library.entries[index].position = Int64(position + 1) + } + } + var newEntry = entry + newEntry.position = 0 + library.entries.append(newEntry) + } + } + } + + public func toggleCompleted(_ id: String) throws { + guard var entry = entry(id), entry.type == .task else { return } + entry.completed.toggle() + try save(entry) + } + + public func deleteEntry(_ id: String) throws { + try change(undoName: "Delete entry") { library in + library.entries.removeAll { $0.id == id } + library.attachments.removeAll { $0.entryId == id } + } + } + + public func saveFolder(_ folder: Folder) throws { + var folder = folder + folder.name = folder.name.trimmingCharacters(in: .whitespacesAndNewlines) + guard !folder.name.isEmpty else { throw FlowError.invalid("Enter a folder name.") } + try change { library in + if let index = library.folders.firstIndex(where: { $0.id == folder.id }) { + library.folders[index] = folder + } else { + let ordered = library.folders.sorted { ($0.position, $0.id) < ($1.position, $1.id) } + for (position, old) in ordered.enumerated() { + if let index = library.folders.firstIndex(where: { $0.id == old.id }) { + library.folders[index].position = Int64(position) + } + } + folder.position = Int64(library.folders.count) + library.folders.append(folder) + } + } + } + + public func deleteFolder(_ id: String) throws { + guard id != Folder.masterID else { throw FlowError.invalid("The Master folder cannot be deleted.") } + try change(undoName: "Delete folder") { library in + let removedIDs = Set(library.entries.filter { $0.folderId == id }.map(\.id)) + library.attachments.removeAll { removedIDs.contains($0.entryId) } + library.entries.removeAll { $0.folderId == id } + library.folders.removeAll { $0.id == id } + } + } + + public func moveEntry(_ id: String, to folderID: String) throws { + guard var entry = entry(id), entry.folderId != folderID else { return } + entry.folderId = folderID + try change { library in + guard library.folders.contains(where: { $0.id == folderID }) else { + throw FlowError.invalid("The folder no longer exists.") + } + let ordered = library.entries.filter { $0.folderId == folderID } + .sorted { ($0.position, $0.id) < ($1.position, $1.id) } + for (position, old) in ordered.enumerated() { + if let index = library.entries.firstIndex(where: { $0.id == old.id }) { + library.entries[index].position = Int64(position + 1) + } + } + entry.position = 0 + if let index = library.entries.firstIndex(where: { $0.id == id }) { library.entries[index] = entry } + } + } + + public func reorderEntries(_ ids: [String], in folderID: String?) throws { + guard Set(ids) == Set(entries(in: folderID).map(\.id)), Set(ids).count == ids.count else { + throw FlowError.invalid("The entry list has changed. Try again.") + } + try change { library in + for (position, id) in ids.enumerated() { + if let index = library.entries.firstIndex(where: { $0.id == id }) { + library.entries[index].position = Int64(position) + } + } + } + } + + public func reorderFolders(_ ids: [String]) throws { + guard Set(ids) == Set(library.folders.map(\.id)), Set(ids).count == ids.count else { + throw FlowError.invalid("The folder list has changed. Try again.") + } + try change { library in + for (position, id) in ids.enumerated() { + if let index = library.folders.firstIndex(where: { $0.id == id }) { + library.folders[index].position = Int64(position) + } + } + } + } + + public func addAttachment(from url: URL, to entryID: String, mimeType: String?) throws { + try requireWritable() + let name = UUID().uuidString + "." + url.pathExtension + let destination = attachmentsURL.appendingPathComponent(name) + try fileManager.copyItem(at: url, to: destination) + do { + let size = try destination.resourceValues(forKeys: [.fileSizeKey]).fileSize ?? 0 + let attachment = Attachment(entryId: entryID, fileName: url.lastPathComponent, + mimeType: mimeType, size: Int64(size), storageName: name) + try change { $0.attachments.append(attachment) } + } catch { + try? fileManager.removeItem(at: destination) + throw error + } + } + + public func deleteAttachment(_ id: String) throws { + try change(undoName: "Delete attachment") { $0.attachments.removeAll { $0.id == id } } + } + + public func attachmentURL(_ attachment: Attachment) throws -> URL { + guard let name = attachment.storageName, !name.isEmpty, name != ".", name != "..", + !name.contains("/"), !name.contains("\\") else { + throw FlowError.invalid("Invalid attachment path.") + } + return attachmentsURL.appendingPathComponent(name) + } + + public func undoDeletion() throws { + try requireWritable() + guard let snapshot = history.last else { return } + // Restore only deleted objects. Preserve edits made after the deletion. + var restored = library + let folderIDs = Set(restored.folders.map(\.id)) + restored.folders += snapshot.library.folders.filter { !folderIDs.contains($0.id) } + let entryIDs = Set(restored.entries.map(\.id)) + restored.entries += snapshot.library.entries.filter { !entryIDs.contains($0.id) } + let attachmentIDs = Set(restored.attachments.map(\.id)) + restored.attachments += snapshot.library.attachments.filter { !attachmentIDs.contains($0.id) } + try persist(restored) + library = restored + history.removeLast() + onChange?() + } + + /// Copies and commits a prepared backup off the main actor. Concurrent mutations + /// are rejected until the disk commit and in-memory publication have both finished. + /// A confirmed restore runs to completion even if the awaiting task is cancelled. + public func restore(_ backup: PreparedBackup) async throws { + try await restore(backup, write: Self.writeRestore) + } + + // The writer can be controlled by package tests to exercise concurrent mutations. + func restore(_ backup: PreparedBackup, write: @escaping @Sendable (PreparedBackup, URL) throws -> Library) async throws { + try requireWritable() + isRestoring = true + defer { isRestoring = false } + let directory = self.directory + let restored = try await Task.detached(priority: .userInitiated) { + try write(backup, directory) + }.value + library = restored + missingAttachments = [] + history.removeAll() + onChange?() + } + + nonisolated static func writeRestore(_ backup: PreparedBackup, directory: URL) throws -> Library { + let fileManager = FileManager.default + let attachmentsURL = directory.appendingPathComponent("attachments", isDirectory: true) + var restored = backup.library + var copied: [URL] = [] + do { + for index in restored.attachments.indices { + let attachment = restored.attachments[index] + guard let source = backup.files[attachment.id] else { + throw FlowError.invalid("An attachment is missing from the backup.") + } + let name = UUID().uuidString + "." + URL(fileURLWithPath: attachment.fileName).pathExtension + let destination = attachmentsURL.appendingPathComponent(name) + copied.append(destination) + try fileManager.copyItem(at: source, to: destination) + restored.attachments[index].storageName = name + } + try writeLibrary(restored, to: directory.appendingPathComponent("library.json")) + } catch { + copied.forEach { try? fileManager.removeItem(at: $0) } + throw error + } + return restored + } + + private func change(undoName: String? = nil, _ mutation: (inout Library) throws -> Void) throws { + try requireWritable() + var updated = library + try mutation(&updated) + try persist(updated) + if let undoName { + // Keep only what this operation deleted; undo must not resurrect earlier deletions. + let remainingFolders = Set(updated.folders.map(\.id)) + let remainingEntries = Set(updated.entries.map(\.id)) + let remainingAttachments = Set(updated.attachments.map(\.id)) + let deleted = Library(folders: library.folders.filter { !remainingFolders.contains($0.id) }, + entries: library.entries.filter { !remainingEntries.contains($0.id) }, + attachments: library.attachments.filter { !remainingAttachments.contains($0.id) }) + history.append((undoName, deleted)) + if history.count > 20 { history.removeFirst() } + } + library = updated + onChange?() + } + + private func persist(_ library: Library) throws { + try Self.writeLibrary(library, to: libraryURL) + } + + private func requireWritable() throws { + guard !isRestoring else { throw FlowError.invalid("A backup is being restored. Please wait until it finishes.") } + } + + nonisolated private static func writeLibrary(_ library: Library, to url: URL) throws { + try library.validate() + let encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys] + try encoder.encode(library).write(to: url, options: .atomic) + } + + /// Preserves an unreadable library by moving it beside the original directory. + /// If creating the new library fails, attempts to put the original back. + public static func startNewLibrary(at directory: URL) throws -> (store: FlowStore, preservedDirectory: URL?) { + let fileManager = FileManager.default + guard fileManager.fileExists(atPath: directory.path) else { + return (try FlowStore(directory: directory), nil) + } + let timestamp = FlowDate.string().replacingOccurrences(of: ":", with: "-") + let preserved = directory.deletingLastPathComponent() + .appendingPathComponent("\(directory.lastPathComponent)-recovery-\(timestamp)-\(UUID().uuidString)", isDirectory: true) + try fileManager.moveItem(at: directory, to: preserved) + do { + return (try FlowStore(directory: directory), preserved) + } catch { + // Only the fresh, failed initialization is removed. Never delete the preserved copy. + try? fileManager.removeItem(at: directory) + try? fileManager.moveItem(at: preserved, to: directory) + throw error + } + } +} diff --git a/ios/FlowCore/Sources/FlowCore/Models.swift b/ios/FlowCore/Sources/FlowCore/Models.swift new file mode 100644 index 0000000..1182592 --- /dev/null +++ b/ios/FlowCore/Sources/FlowCore/Models.swift @@ -0,0 +1,152 @@ +import Foundation + +public enum EntryKind: String, Codable, CaseIterable { + case note, task +} + +public struct Folder: Codable, Equatable, Identifiable { + public static let masterID = "master" + public var id: String + public var name: String + public var position: Int64 + + public init(id: String = UUID().uuidString, name: String, position: Int64 = 0) { + self.id = id + self.name = name + self.position = position + } +} + +public struct Entry: Codable, Equatable, Identifiable { + public var id: String + public var text: String + public var createdAt: String + public var position: Int64 + public var type: EntryKind + public var completed: Bool + public var folderId: String + + public init(id: String = UUID().uuidString, text: String = "", createdAt: String = FlowDate.string(), + position: Int64 = 0, type: EntryKind = .note, completed: Bool = false, + folderId: String = Folder.masterID) { + self.id = id + self.text = text + self.createdAt = createdAt + self.position = position + self.type = type + self.completed = completed + self.folderId = folderId + } +} + +public struct Attachment: Codable, Equatable, Identifiable { + public var id: String + public var entryId: String + public var fileName: String + public var mimeType: String? + public var size: Int64 + public var createdAt: String + // Only used by the local store. Omitted from exported Android-compatible metadata. + public var storageName: String? + + public init(id: String = UUID().uuidString, entryId: String, fileName: String, mimeType: String?, + size: Int64, createdAt: String = FlowDate.string(), storageName: String? = nil) { + self.id = id + self.entryId = entryId + self.fileName = fileName + self.mimeType = mimeType + self.size = size + self.createdAt = createdAt + self.storageName = storageName + } + + private enum CodingKeys: String, CodingKey { + case id, entryId, fileName, mimeType, size, createdAt, storageName + } + + public func encode(to encoder: Encoder) throws { + var values = encoder.container(keyedBy: CodingKeys.self) + try values.encode(id, forKey: .id) + try values.encode(entryId, forKey: .entryId) + try values.encode(fileName, forKey: .fileName) + // Kotlin's nullable field is still required in the Android backup schema. + if let mimeType { try values.encode(mimeType, forKey: .mimeType) } + else { try values.encodeNil(forKey: .mimeType) } + try values.encode(size, forKey: .size) + try values.encode(createdAt, forKey: .createdAt) + try values.encodeIfPresent(storageName, forKey: .storageName) + } +} + +public struct Library: Codable, Equatable { + public var schemaVersion = 1 + public var folders: [Folder] + public var entries: [Entry] + public var attachments: [Attachment] + + public init(folders: [Folder] = [Folder(id: Folder.masterID, name: "Master")], + entries: [Entry] = [], attachments: [Attachment] = []) { + self.folders = folders + self.entries = entries + self.attachments = attachments + } + + public func validate() throws { + guard schemaVersion == 1 else { throw FlowError.invalid("This library version is not supported.") } + try validateIDs(folders.map(\.id)) + try validateIDs(entries.map(\.id)) + try validateIDs(attachments.map(\.id)) + let folderIDs = Set(folders.map(\.id)) + let entryIDs = Set(entries.map(\.id)) + guard !folders.isEmpty, entries.allSatisfy({ folderIDs.contains($0.folderId) }), + attachments.allSatisfy({ entryIDs.contains($0.entryId) && $0.size >= 0 }) else { + throw FlowError.invalid("The backup contains missing folders, missing entries, or invalid attachments.") + } + guard entries.allSatisfy({ FlowDate.date($0.createdAt) != nil }), + attachments.allSatisfy({ FlowDate.date($0.createdAt) != nil }) else { + throw FlowError.invalid("The backup contains an invalid date.") + } + } + + private func validateIDs(_ ids: [String]) throws { + guard ids.allSatisfy({ !$0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }), + Set(ids).count == ids.count else { + throw FlowError.invalid("The backup contains empty or duplicate IDs.") + } + } +} + +public enum FlowDate { + // Backup work and UI saves share these formatters. Serialize access as well as + // keeping their configuration immutable; ISO8601DateFormatter isn't Sendable. + private static let lock = NSLock() + private static let fractional: ISO8601DateFormatter = { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + return formatter + }() + private static let plain: ISO8601DateFormatter = { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime] + return formatter + }() + + public static func string(_ date: Date = Date()) -> String { + lock.lock() + defer { lock.unlock() } + return fractional.string(from: date) + } + + public static func date(_ string: String) -> Date? { + lock.lock() + defer { lock.unlock() } + return fractional.date(from: string) ?? plain.date(from: string) + } +} + +public enum FlowError: LocalizedError { + case invalid(String) + public var errorDescription: String? { + switch self { case .invalid(let message): return message } + } +} diff --git a/ios/FlowCore/Tests/FlowCoreTests/Fixtures/android-v1.flow b/ios/FlowCore/Tests/FlowCoreTests/Fixtures/android-v1.flow new file mode 100644 index 0000000..c8468af Binary files /dev/null and b/ios/FlowCore/Tests/FlowCoreTests/Fixtures/android-v1.flow differ diff --git a/ios/FlowCore/Tests/FlowCoreTests/Fixtures/android-v5.json b/ios/FlowCore/Tests/FlowCoreTests/Fixtures/android-v5.json new file mode 100644 index 0000000..d532c5e --- /dev/null +++ b/ios/FlowCore/Tests/FlowCoreTests/Fixtures/android-v5.json @@ -0,0 +1,26 @@ +{ + "version": 5, + "folders": [ + { + "id": "master", + "name": "Master", + "position": 0 + }, + { + "id": "work", + "name": "Work", + "position": 1 + } + ], + "notes": [ + { + "id": "note-1", + "text": "Hello from Android\nПривет 👋", + "createdAt": 1700000000123, + "position": 0, + "type": "note", + "completed": false, + "folderId": "master" + } + ] +} diff --git a/ios/FlowCore/Tests/FlowCoreTests/FlowCoreTests.swift b/ios/FlowCore/Tests/FlowCoreTests/FlowCoreTests.swift new file mode 100644 index 0000000..27513ad --- /dev/null +++ b/ios/FlowCore/Tests/FlowCoreTests/FlowCoreTests.swift @@ -0,0 +1,311 @@ +import XCTest +import ZIPFoundation +@testable import FlowCore + +final class FlowCoreTests: XCTestCase { + private var directory: URL! + + override func setUpWithError() throws { + directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + } + + override func tearDownWithError() throws { try FileManager.default.removeItem(at: directory) } + + @MainActor + func testPersistsEntriesCompletionFoldersAndOrdering() async throws { + let store = try FlowStore(directory: directory) + let first = Entry(text: "First") + let second = Entry(text: "Second", type: .task) + try store.save(first) + try store.save(second) + XCTAssertEqual(store.entries().map(\.id), [second.id, first.id]) + try store.toggleCompleted(second.id) + try store.reorderEntries([first.id, second.id], in: Folder.masterID) + let folder = Folder(name: "Work") + try store.saveFolder(folder) + try store.moveEntry(second.id, to: folder.id) + let reopened = try FlowStore(directory: directory) + XCTAssertEqual(reopened.entries(in: folder.id).map(\.id), [second.id]) + XCTAssertEqual(reopened.entry(second.id)?.completed, true) + XCTAssertEqual(reopened.entries(in: Folder.masterID).map(\.id), [first.id]) + } + + @MainActor + func testNewFolderAppendsAfterDeletionLeavesPositionGaps() async throws { + let store = try FlowStore(directory: directory) + let first = Folder(name: "First") + let last = Folder(name: "Last") + let added = Folder(name: "Added") + try store.saveFolder(first) + try store.saveFolder(last) + try store.deleteFolder(first.id) + try store.saveFolder(added) + XCTAssertEqual(store.folders.map(\.id), [Folder.masterID, last.id, added.id]) + } + + @MainActor + func testUndoRestoresOnlyDeletedObjectsAndKeepsLaterEdits() async throws { + let store = try FlowStore(directory: directory) + let removed = Entry(text: "Deleted") + var kept = Entry(text: "Original") + try store.save(removed) + try store.save(kept) + try store.deleteEntry(removed.id) + kept.text = "Edited after deletion" + try store.save(kept) + try store.undoDeletion() + XCTAssertEqual(store.entry(kept.id)?.text, "Edited after deletion") + XCTAssertEqual(store.entry(removed.id)?.text, "Deleted") + XCTAssertNil(store.undoActionName) + } + + @MainActor + func testFolderDeletionAndUndoRestoresAttachmentBytes() async throws { + let store = try FlowStore(directory: directory) + let folder = Folder(name: "Files") + try store.saveFolder(folder) + let entry = Entry(text: "Document", folderId: folder.id) + try store.save(entry) + let source = directory.appendingPathComponent("hello.txt") + try Data("Hello, Flow".utf8).write(to: source) + try store.addAttachment(from: source, to: entry.id, mimeType: "text/plain") + let attachment = try XCTUnwrap(store.attachments(for: entry.id).first) + try store.deleteFolder(folder.id) + XCTAssertTrue(store.library.attachments.isEmpty) + try store.undoDeletion() + XCTAssertEqual(try Data(contentsOf: store.attachmentURL(attachment)), Data("Hello, Flow".utf8)) + XCTAssertEqual(store.entry(entry.id), entry) + XCTAssertTrue(store.folders.contains { $0.id == folder.id }) + } + + @MainActor + func testAttachmentUndoAndCleanupOnNextLaunch() async throws { + let store = try FlowStore(directory: directory) + let entry = Entry(text: "Files") + try store.save(entry) + let source = directory.appendingPathComponent("source.txt") + try Data("content".utf8).write(to: source) + try store.addAttachment(from: source, to: entry.id, mimeType: "text/plain") + let attachment = try XCTUnwrap(store.attachments(for: entry.id).first) + let file = try store.attachmentURL(attachment) + try store.deleteAttachment(attachment.id) + XCTAssertTrue(FileManager.default.fileExists(atPath: file.path)) + try store.undoDeletion() + XCTAssertEqual(store.attachments(for: entry.id), [attachment]) + try store.deleteAttachment(attachment.id) + _ = try FlowStore(directory: directory) + XCTAssertFalse(FileManager.default.fileExists(atPath: file.path)) + } + + @MainActor + func testInvalidMutationsLeaveLibraryAndDiskUnchanged() async throws { + let store = try FlowStore(directory: directory) + let original = store.library + XCTAssertThrowsError(try store.save(Entry(text: "Orphan", folderId: "missing"))) + XCTAssertThrowsError(try store.deleteFolder(Folder.masterID)) + XCTAssertThrowsError(try store.saveFolder(Folder(name: " "))) + XCTAssertThrowsError(try store.reorderEntries(["missing"], in: nil)) + XCTAssertEqual(store.library, original) + XCTAssertEqual(try FlowStore(directory: directory).library, original) + } + + @MainActor + func testCorruptLibraryIsNotReplaced() async throws { + let file = directory.appendingPathComponent("library.json") + let bytes = Data("not valid JSON".utf8) + try bytes.write(to: file) + XCTAssertThrowsError(try FlowStore(directory: directory)) + XCTAssertEqual(try Data(contentsOf: file), bytes) + } + + @MainActor + func testAndroidFixtureImportAndExportRoundTrip() async throws { + let fixture = try XCTUnwrap(Bundle.module.url(forResource: "android-v1", withExtension: "flow", subdirectory: "Fixtures")) + let prepared = try FlowBackup.prepare(from: fixture) + let store = try FlowStore(directory: directory.appendingPathComponent("library")) + try store.save(Entry(text: "Replaced")) + try await store.restore(prepared) + XCTAssertEqual(store.library.entries.count, 2) + XCTAssertEqual(store.entry("task-1")?.completed, true) + XCTAssertEqual(store.entry("note-1")?.text, "Hello from Android\nПривет 👋") + let attachment = try XCTUnwrap(store.library.attachments.first) + XCTAssertEqual(try String(contentsOf: store.attachmentURL(attachment), encoding: .utf8), "Attachment from Android\n") + let output = directory.appendingPathComponent("export.flow") + var exportedLibrary = store.library + exportedLibrary.attachments[0].mimeType = nil + try FlowBackup.export(library: exportedLibrary, attachmentURLs: [attachment.id: try store.attachmentURL(attachment)], to: output) + let reimported = try FlowBackup.prepare(from: output) + XCTAssertEqual(reimported.library.entries, prepared.library.entries) + XCTAssertEqual(reimported.library.folders, prepared.library.folders) + let archive = try Archive(url: output, accessMode: .read) + XCTAssertEqual(Set(archive.map(\.path)), ["manifest.json", "data/folders.json", "data/entries.json", "data/attachments.json", "attachments/file-1"]) + var metadata = Data() + _ = try archive.extract(try XCTUnwrap(archive["data/attachments.json"])) { metadata.append($0) } + XCTAssertFalse(String(decoding: metadata, as: UTF8.self).contains("storageName")) + let attachmentJSON = try XCTUnwrap(JSONSerialization.jsonObject(with: metadata) as? [[String: Any]]) + XCTAssertTrue(attachmentJSON[0]["mimeType"] is NSNull, "Android requires the nullable mimeType key to be present.") + } + + func testReadsAndroidLegacyVersionFiveDates() throws { + let fixture = try XCTUnwrap(Bundle.module.url(forResource: "android-v5", withExtension: "json", subdirectory: "Fixtures")) + let prepared = try FlowBackup.prepare(from: fixture) + let date = try XCTUnwrap(FlowDate.date(prepared.library.entries[0].createdAt)) + XCTAssertEqual(date.timeIntervalSince1970, 1_700_000_000.123, accuracy: 0.001) + } + + func testAndroidAttachmentWithUnknownSizeUsesArchiveSize() throws { + let fixture = try XCTUnwrap(Bundle.module.url(forResource: "android-v1", withExtension: "flow", subdirectory: "Fixtures")) + let file = directory.appendingPathComponent("unknown-size.flow") + try FileManager.default.copyItem(at: fixture, to: file) + let archive = try Archive(url: file, accessMode: .update) + let old = try XCTUnwrap(archive["data/attachments.json"]) + var data = Data() + _ = try archive.extract(old) { data.append($0) } + var metadata = try XCTUnwrap(JSONSerialization.jsonObject(with: data) as? [[String: Any]]) + metadata[0]["size"] = -1 + metadata[0]["mimeType"] = NSNull() + let changed = try JSONSerialization.data(withJSONObject: metadata) + try archive.remove(old) + try archive.addEntry(with: "data/attachments.json", type: .file, uncompressedSize: Int64(changed.count)) { offset, size in + changed.subdata(in: Int(offset)..<(Int(offset) + size)) + } + let prepared = try FlowBackup.prepare(from: file) + XCTAssertEqual(prepared.library.attachments[0].size, 24) + XCTAssertNil(prepared.library.attachments[0].mimeType) + } + + func testRejectsMalformedBackups() throws { + let cases: [(String, (inout [String: Any]) -> Void)] = [ + ("unsupported", { $0["manifest.json"] = ["format": "flow", "formatVersion": 99, "flowVersion": "1", "createdAt": "2026-01-01T00:00:00Z"] }), + ("duplicate", { $0["data/folders.json"] = [["id": "master", "name": "Master", "position": 0], ["id": "master", "name": "Duplicate", "position": 1]] }), + ("orphan", { $0["data/entries.json"] = [["id": "e", "text": "x", "createdAt": "2026-01-01T00:00:00Z", "position": 0, "type": "note", "completed": false, "folderId": "missing"]] }), + ("bad-date", { $0["data/entries.json"] = [["id": "e", "text": "x", "createdAt": "yesterday", "position": 0, "type": "note", "completed": false, "folderId": "master"]] }), + ("missing-file", { $0["data/attachments.json"] = [["id": "a", "entryId": "e", "fileName": "file.txt", "size": 1, "createdAt": "2026-01-01T00:00:00Z"]] }), + ("traversal", { $0["data/attachments.json"] = [["id": "../escape", "entryId": "e", "fileName": "file.txt", "size": 1, "createdAt": "2026-01-01T00:00:00Z"]] }) + ] + for (name, mutate) in cases { + var files: [String: Any] = [ + "manifest.json": ["format": "flow", "formatVersion": 1, "flowVersion": "1", "createdAt": "2026-01-01T00:00:00Z"], + "data/folders.json": [["id": "master", "name": "Master", "position": 0]], + "data/entries.json": [["id": "e", "text": "x", "createdAt": "2026-01-01T00:00:00Z", "position": 0, "type": "note", "completed": false, "folderId": "master"]], + "data/attachments.json": [] + ] + mutate(&files) + let url = directory.appendingPathComponent(name + ".flow") + let archive = try Archive(url: url, accessMode: .create) + for (path, value) in files { + let data = try JSONSerialization.data(withJSONObject: value) + try archive.addEntry(with: path, type: .file, uncompressedSize: Int64(data.count)) { offset, size in + data.subdata(in: Int(offset)..<(Int(offset) + size)) + } + } + XCTAssertThrowsError(try FlowBackup.prepare(from: url), name) + } + } + + @MainActor + func testFailedRestorePreservesExistingLibrary() async throws { + let store = try FlowStore(directory: directory.appendingPathComponent("store")) + let existing = Entry(text: "Keep me") + try store.save(existing) + let library = Library(entries: [Entry(id: "new", text: "Incoming")], attachments: [Attachment(entryId: "new", fileName: "missing.txt", mimeType: nil, size: 1)]) + let backup = PreparedBackup(library: library, createdAt: FlowDate.string(), files: [:], directory: directory.appendingPathComponent("staging")) + do { + try await store.restore(backup) + XCTFail("A backup with a missing file must not replace the library.") + } catch {} + XCTAssertFalse(store.isRestoring) + XCTAssertEqual(store.library.entries, [existing]) + XCTAssertEqual(try FlowStore(directory: store.directory).library.entries, [existing]) + } + + @MainActor + func testMissingAttachmentKeepsLibraryAndMetadataUsable() async throws { + let store = try FlowStore(directory: directory) + let entry = Entry(text: "Keep my note") + try store.save(entry) + let source = directory.appendingPathComponent("document.txt") + try Data("content".utf8).write(to: source) + try store.addAttachment(from: source, to: entry.id, mimeType: "text/plain") + let attachment = try XCTUnwrap(store.attachments(for: entry.id).first) + try FileManager.default.removeItem(at: store.attachmentURL(attachment)) + let original = try Data(contentsOf: directory.appendingPathComponent("library.json")) + let reopened = try FlowStore(directory: directory) + XCTAssertEqual(reopened.entry(entry.id), entry) + XCTAssertEqual(reopened.missingAttachments, [attachment]) + XCTAssertEqual(reopened.attachments(for: entry.id), [attachment]) + XCTAssertEqual(try Data(contentsOf: directory.appendingPathComponent("library.json")), original) + try reopened.save(Entry(text: "Still usable")) + XCTAssertEqual(reopened.library.entries.count, 2) + } + + @MainActor + func testRecoveryPreservesOriginalLibraryAndAttachmentBytes() async throws { + let file = directory.appendingPathComponent("library.json") + let corruptData = Data("corrupt library".utf8) + try corruptData.write(to: file) + let attachments = directory.appendingPathComponent("attachments") + try FileManager.default.createDirectory(at: attachments, withIntermediateDirectories: true) + try Data("irreplaceable file".utf8).write(to: attachments.appendingPathComponent("file.txt")) + let recovery = try FlowStore.startNewLibrary(at: directory) + let preserved = try XCTUnwrap(recovery.preservedDirectory) + defer { try? FileManager.default.removeItem(at: preserved) } + XCTAssertEqual(try Data(contentsOf: preserved.appendingPathComponent("library.json")), corruptData) + XCTAssertEqual(try String(contentsOf: preserved.appendingPathComponent("attachments/file.txt"), encoding: .utf8), "irreplaceable file") + XCTAssertTrue(recovery.store.library.entries.isEmpty) + try recovery.store.save(Entry(text: "Recovered")) + XCTAssertEqual(try FlowStore(directory: directory).library.entries.first?.text, "Recovered") + } + + @MainActor + func testRestoreLeavesMainActorResponsiveAndRejectsConcurrentWrites() async throws { + let fixture = try XCTUnwrap(Bundle.module.url(forResource: "android-v1", withExtension: "flow", subdirectory: "Fixtures")) + let backup = try FlowBackup.prepare(from: fixture) + let store = try FlowStore(directory: directory) + let original = store.library + let started = expectation(description: "Background restore started") + let gate = DispatchSemaphore(value: 0) + let restore = Task { + try await store.restore(backup) { backup, directory in + XCTAssertFalse(Thread.isMainThread) + started.fulfill() + guard gate.wait(timeout: .now() + 10) == .success else { + throw FlowError.invalid("The main actor did not resume during restore.") + } + return try FlowStore.writeRestore(backup, directory: directory) + } + } + await fulfillment(of: [started], timeout: 5) + XCTAssertTrue(store.isRestoring) + XCTAssertEqual(store.library, original) + XCTAssertThrowsError(try store.save(Entry(text: "Conflicting write"))) + XCTAssertThrowsError(try store.undoDeletion()) + gate.signal() + try await restore.value + XCTAssertFalse(store.isRestoring) + XCTAssertEqual(store.library.entries, backup.library.entries) + XCTAssertEqual(try FlowStore(directory: directory).library, store.library) + } + + func testLegacyImportRejectsOversizedFileBeforeDecoding() throws { + let file = directory.appendingPathComponent("oversized.json") + try Data("{".utf8).write(to: file) + let handle = try FileHandle(forWritingTo: file) + try handle.truncate(atOffset: 32 * 1024 * 1024 + 1) + try handle.close() + XCTAssertThrowsError(try FlowBackup.prepare(from: file)) { error in + XCTAssertTrue(error.localizedDescription.contains("too large")) + } + } + + func testCachedDatesPreserveBothFormatsAcrossConcurrentCalls() { + DispatchQueue.concurrentPerform(iterations: 100) { _ in + let date = Date(timeIntervalSince1970: 1_700_000_000.123) + XCTAssertEqual(FlowDate.string(date), "2023-11-14T22:13:20.123Z") + XCTAssertEqual(FlowDate.date("2023-11-14T22:13:20.123Z")!.timeIntervalSince1970, date.timeIntervalSince1970, accuracy: 0.001) + XCTAssertEqual(FlowDate.date("2023-11-14T22:13:20Z")!.timeIntervalSince1970, 1_700_000_000) + XCTAssertNil(FlowDate.date("invalid")) + } + } +} diff --git a/ios/FlowUITests/FlowUITests.swift b/ios/FlowUITests/FlowUITests.swift new file mode 100644 index 0000000..8b7f08c --- /dev/null +++ b/ios/FlowUITests/FlowUITests.swift @@ -0,0 +1,124 @@ +import XCTest + +final class FlowUITests: XCTestCase { + private var app: XCUIApplication! + + override func setUpWithError() throws { + continueAfterFailure = false + app = XCUIApplication() + app.launchArguments = ["--ui-testing", "--reset-library"] + app.launch() + } + + func testCreateEditCompleteDeleteUndoAndRelaunch() { + app.buttons["newEntry"].tap() + let editor = app.textViews["entryText"] + XCTAssertTrue(editor.waitForExistence(timeout: 5)) + editor.tap() + editor.typeText("Pack for the weekend") + app.segmentedControls.buttons["Task"].tap() + app.navigationBars.buttons["Done"].tap() + XCTAssertTrue(app.staticTexts["Pack for the weekend"].waitForExistence(timeout: 5)) + app.buttons["Complete task"].tap() + XCTAssertTrue(app.buttons["Mark incomplete"].exists) + app.staticTexts["Pack for the weekend"].tap() + editor.tap() + editor.typeText("\nBring a notebook") + let editedText = editor.value as! String + XCTAssertTrue(editedText.contains("Bring a notebook")) + app.navigationBars.buttons["Done"].tap() + let cell = app.tables["entries"].cells.firstMatch + cell.swipeLeft() + app.buttons["Delete"].tap() + XCTAssertEqual(app.tables["entries"].cells.count, 0) + app.toolbars.buttons["Undo"].tap() + XCTAssertTrue(app.tables["entries"].cells.firstMatch.waitForExistence(timeout: 5)) + app.terminate() + app.launchArguments = ["--ui-testing"] + app.launch() + XCTAssertTrue(app.tables["entries"].cells.firstMatch.waitForExistence(timeout: 5)) + XCTAssertTrue(app.buttons["Mark incomplete"].exists) + capture("Home") + app.tables["entries"].cells.firstMatch.tap() + XCTAssertEqual(editor.value as? String, editedText) + } + + func testFoldersSettingsAndEmptyDraft() { + app.buttons["newEntry"].tap() + app.navigationBars.buttons["Done"].tap() + XCTAssertEqual(app.tables["entries"].cells.count, 0) + app.buttons["Settings"].tap() + app.switches["Folders"].tap() + app.navigationBars.buttons["Done"].tap() + app.buttons["Folders"].tap() + app.buttons["New folder"].tap() + app.alerts.textFields.firstMatch.typeText("Travel") + app.alerts.buttons["Save"].tap() + app.staticTexts["Travel"].tap() + app.buttons["newEntry"].tap() + let editor = app.textViews["entryText"] + editor.tap() + editor.typeText("A quiet place to write") + app.navigationBars.buttons["Done"].tap() + app.buttons["Folders"].tap() + app.staticTexts["Master"].tap() + XCTAssertFalse(app.staticTexts["A quiet place to write"].exists) + app.buttons["Settings"].tap() + app.switches["Folders"].tap() + capture("Settings") + app.navigationBars.buttons["Done"].tap() + XCTAssertTrue(app.staticTexts["A quiet place to write"].exists) + } + + func testSearchAndTaskDefault() { + app.buttons["Settings"].tap() + app.staticTexts["Default entry type"].tap() + app.staticTexts["Task"].tap() + app.navigationBars.buttons["Done"].tap() + app.buttons["newEntry"].tap() + XCTAssertTrue(app.segmentedControls.buttons["Task"].isSelected) + app.textViews["entryText"].tap() + app.textViews["entryText"].typeText("Read the next chapter") + capture("Editor") + app.navigationBars.buttons["Done"].tap() + app.swipeDown() + let search = app.searchFields.firstMatch + search.tap() + search.typeText("missing") + XCTAssertTrue(app.staticTexts["No matching entries."].exists) + } + + func testKeyboardAndAttachmentPickerCancellation() { + app.buttons["newEntry"].tap() + XCTAssertTrue(app.textViews["entryText"].waitForExistence(timeout: 5)) + app.buttons["hideEntryKeyboard"].tap() + app.buttons["Attach file"].tap() + XCTAssertTrue(app.buttons["Cancel"].waitForExistence(timeout: 5)) + app.buttons["Cancel"].tap() + app.navigationBars.buttons["Done"].tap() + XCTAssertEqual(app.tables["entries"].cells.count, 0) + } + + func testEditorFlushesWhenGoingBackAndBackgrounding() { + app.buttons["newEntry"].tap() + let editor = app.textViews["entryText"] + editor.typeText("Saved when leaving") + app.navigationBars.buttons.element(boundBy: 0).tap() + XCTAssertTrue(app.staticTexts["Saved when leaving"].waitForExistence(timeout: 5)) + app.buttons["newEntry"].tap() + editor.typeText("Saved in the background") + XCUIDevice.shared.press(.home) + app.terminate() + app.launchArguments = ["--ui-testing"] + app.launch() + XCTAssertTrue(app.staticTexts["Saved in the background"].waitForExistence(timeout: 5)) + XCTAssertTrue(app.staticTexts["Saved when leaving"].exists) + } + + private func capture(_ name: String) { + let attachment = XCTAttachment(screenshot: app.screenshot()) + attachment.name = name + attachment.lifetime = .keepAlways + add(attachment) + } +} diff --git a/ios/README.md b/ios/README.md new file mode 100644 index 0000000..2e89d80 --- /dev/null +++ b/ios/README.md @@ -0,0 +1,64 @@ +# Flow for iOS + +A native UIKit app for iPhone and iPad, with local notes, tasks, folders, and attachments. Requires iOS 16 or later and Xcode 16 or later. The Android app stays in `app/`. + +## Run + +Open `ios/Flow.xcodeproj`, select the **Flow** scheme and an iPhone or iPad simulator, then run. Xcode resolves the local `FlowCore` package and its pinned ZIPFoundation dependency automatically. + +To install on a physical device, choose your development team in the Flow target's Signing & Capabilities settings and use a bundle identifier registered to that team. + +From the repository root: + +```sh +xcodebuild -project ios/Flow.xcodeproj -scheme Flow \ + -destination 'generic/platform=iOS Simulator' \ + -derivedDataPath ios/.build/DerivedData CODE_SIGNING_ALLOWED=NO build +``` + +## Use + +- Tap **+** to create an entry. The editor saves after a brief pause in typing and flushes pending changes when you leave or background the app. Use the Note/Task control to change its type, and the circle button to complete a task. +- Swipe an entry for quick actions, or hold it for a context menu. Tap **Edit** and drag the handles to reorder entries. +- Enable **Folders** in Settings to organize entries. The folder picker supports creation, renaming, deletion, and reordering. With folders disabled, the home screen shows all entries. +- Attach files through the paperclip button and tap a file to preview it with Quick Look. The share button shares the entry text and its attachments. +- **Undo** restores deleted entries, folders, and attachments during the current session, up to 20 deletions. Unreferenced attachment files are removed at the next launch. +- Settings includes system, monospaced, and serif fonts, preview length, default entry type, a code keyboard, and a pure black dark background. + +## Backups and storage + +Settings can export and import `.flow` ZIP backups using the same version 1 manifest, JSON metadata, and attachment layout as Android. Version 5 legacy JSON backups can also be imported. Import shows the contents and asks before replacing the library. It validates the archive and stages attachment files before updating the saved library. + +Import limits are 32 MB per metadata file, 512 MB per attachment, and 2 GB of attachments per backup. Preferences are device-specific and are not included in backups. + +Restore copies files and writes the library away from the main thread. Other library changes are blocked until it finishes. If an attachment file is missing at startup, Flow warns you and preserves its record while keeping your notes accessible. If the library itself cannot be opened, **Start a new library** moves the original directory to a timestamped sibling in Application Support before creating a fresh library, so the original files remain available for recovery. + +The app stores `library.json` and attachment files under its Application Support directory. Saves replace the JSON file atomically. The app excludes this directory from device backups and uses no network services, analytics, account, or cloud sync. Export a `.flow` backup before uninstalling the app or moving to another device. + +The interface currently uses English. It follows the system appearance and supports Dynamic Type and VoiceOver labels. Android-only features such as Material dynamic colors and Android keyboard behavior use native iOS equivalents where available. + +## Tests + +Run the persistence and backup tests on macOS: + +```sh +swift test --package-path ios/FlowCore +``` + +Run the UIKit flows on an available simulator, replacing the destination name if necessary: + +```sh +xcodebuild -project ios/Flow.xcodeproj -scheme Flow \ + -destination 'platform=iOS Simulator,name=iPhone 17 Pro' \ + -derivedDataPath ios/.build/DerivedData CODE_SIGNING_ALLOWED=NO test +``` + +UI tests use a separate library directory through Debug-only launch arguments. They cover editing, completion, deletion/undo, relaunch persistence, folders, settings, and search. The backup fixtures follow the Kotlin serializers in `app/src/main/java/dev/jvqtil/flow/data/backup/` and include Unicode text and attachment bytes. + +## Source layout + +- `Flow/`: UIKit lifecycle, controllers, assets, and preferences. +- `FlowCore/`: Foundation data model, atomic local store, backup reader/writer, and unit tests. +- `FlowUITests/`: simulator tests. + +ZIPFoundation is pinned to 0.9.20 under the MIT license. Its package includes its own privacy manifest. The app's privacy manifest declares its use of local preferences and file timestamps.