iOS transfers: 'Uploading...' then 'Uploaded' notification, and count share uploads
The Share Extension's session had no delegate, so a fast upload finished unseen and the invalidated session was discarded before the app could hear of it - no summary notification. Results are now handled by a shared delegate in whichever process is alive, started/finished notifications replace each other in place, and the app refreshes the folder the extension uploaded to. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5.5
parent
afc9b90963
commit
0c063c1fb4
@@ -863,10 +863,13 @@ iOS. `OfflineController` and `localSyncedFilePath` both go through it.
|
|||||||
`Documents/Downloads`, with `UIFileSharingEnabled` +
|
`Documents/Downloads`, with `UIFileSharingEnabled` +
|
||||||
`LSSupportsOpeningDocumentsInPlace` set so it shows in the Files app under
|
`LSSupportsOpeningDocumentsInPlace` set so it shows in the Files app under
|
||||||
"On My iPhone > Noo"; a name clash becomes `a (1).txt`.
|
"On My iPhone > Noo"; a name clash becomes `a (1).txt`.
|
||||||
- No progress notification (iOS can't update one from a background session
|
- Notifications (`ios/Shared/TransferNotifications.swift`): "Uploading 3
|
||||||
the way Android's foreground service does) and no cancel yet - only the
|
files..." when a batch starts, replaced in place (same request id =
|
||||||
final summary. The Dart snackbar text still says "see the notification for
|
`transfer.<batch>`) by "Uploaded 3 files" when it ends. iOS can't show live
|
||||||
progress".
|
byte progress from a background session the way Android's foreground
|
||||||
|
service does (a Live Activity would, but needs a widget extension), and
|
||||||
|
there's no cancel yet. The Dart snackbar text still says "see the
|
||||||
|
notification for progress".
|
||||||
- `WebDAV.swift` (URL building) and `LocalFiles.uniqueURL` are pure and
|
- `WebDAV.swift` (URL building) and `LocalFiles.uniqueURL` are pure and
|
||||||
covered by `ios/RunnerTests` (`xcodebuild test -workspace
|
covered by `ios/RunnerTests` (`xcodebuild test -workspace
|
||||||
ios/Runner.xcworkspace -scheme Runner -destination 'platform=iOS
|
ios/Runner.xcworkspace -scheme Runner -destination 'platform=iOS
|
||||||
@@ -891,12 +894,20 @@ Reminders/Notes - the destination is picked *inside* the sheet:
|
|||||||
channel - both wired where `SessionController` is created in `main.dart`.
|
channel - both wired where `SessionController` is created in `main.dart`.
|
||||||
3. **Upload** calls `ShareUpload.enqueue`: one `PUT` per file on a *background*
|
3. **Upload** calls `ShareUpload.enqueue`: one `PUT` per file on a *background*
|
||||||
`URLSession` (`dev.ayushya.noo.transfers.share`, with
|
`URLSession` (`dev.ayushya.noo.transfers.share`, with
|
||||||
`sharedContainerIdentifier`) that outlives the extension. The app recreates
|
`sharedContainerIdentifier`) that outlives the extension, and posts
|
||||||
|
"Uploading N files...". Results go to whichever process is alive:
|
||||||
|
files that finish while the sheet is still open are counted by the
|
||||||
|
extension's `ShareUploadDelegate`; the rest by the app, which recreates
|
||||||
that session at launch (`TransferManager.reconnect`) and is relaunched by
|
that session at launch (`TransferManager.reconnect`) and is relaunched by
|
||||||
the system to receive the results, post the "Uploaded N files" summary and
|
the system. Either way `UploadResults.handle` deletes the staged copy,
|
||||||
delete the copies; `TransferBatchStore` keeps batch totals in the App
|
counts the file in `TransferBatchStore` (batch totals live in the App
|
||||||
Group's `UserDefaults` because the extension and the app are different
|
Group's `UserDefaults` because the two are different processes) and
|
||||||
processes.
|
posts the "Uploaded N files" summary on the last one; an upload the
|
||||||
|
extension finished leaves a note (`noteFinishedUpload`) so the app
|
||||||
|
refreshes that folder on its next activation. **Don't give the
|
||||||
|
extension's session no delegate** - a fast upload then finishes unseen and
|
||||||
|
the invalidated session is discarded before the app can ever hear of it
|
||||||
|
(found the hard way: no notification, no count).
|
||||||
4. Fallbacks: with no account (or the user taps "Choose a folder later in
|
4. Fallbacks: with no account (or the user taps "Choose a folder later in
|
||||||
Noo") the files go into `SharedInbox/pending.json` instead and a local
|
Noo") the files go into `SharedInbox/pending.json` instead and a local
|
||||||
notification asks the user to open Noo; the app consumes that through
|
notification asks the user to open Noo; the app consumes that through
|
||||||
|
|||||||
@@ -17,6 +17,7 @@
|
|||||||
3C1A0DAA5F9B37FEA4C604BE /* DavFolders.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51604DEE43E937A5FDCA7A58 /* DavFolders.swift */; };
|
3C1A0DAA5F9B37FEA4C604BE /* DavFolders.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51604DEE43E937A5FDCA7A58 /* DavFolders.swift */; };
|
||||||
3DEDE94F2A4C921407EF4382 /* ShareUpload.swift in Sources */ = {isa = PBXBuildFile; fileRef = B93DB1F595F44E2F2057BB2A /* ShareUpload.swift */; };
|
3DEDE94F2A4C921407EF4382 /* ShareUpload.swift in Sources */ = {isa = PBXBuildFile; fileRef = B93DB1F595F44E2F2057BB2A /* ShareUpload.swift */; };
|
||||||
43DB9AB631DD359AEC613A1D /* WebDAV.swift in Sources */ = {isa = PBXBuildFile; fileRef = 871EDB5791D5B3DA0ECF3D4E /* WebDAV.swift */; };
|
43DB9AB631DD359AEC613A1D /* WebDAV.swift in Sources */ = {isa = PBXBuildFile; fileRef = 871EDB5791D5B3DA0ECF3D4E /* WebDAV.swift */; };
|
||||||
|
44078FC9474337A0E102B524 /* TransferNotifications.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3247AAA4B65FE0F1A7279FA1 /* TransferNotifications.swift */; };
|
||||||
5694337ECF8D3ADD3695CBCD /* NativeServices.swift in Sources */ = {isa = PBXBuildFile; fileRef = 901C0CDB4FB819302E2A7563 /* NativeServices.swift */; };
|
5694337ECF8D3ADD3695CBCD /* NativeServices.swift in Sources */ = {isa = PBXBuildFile; fileRef = 901C0CDB4FB819302E2A7563 /* NativeServices.swift */; };
|
||||||
62028E64976F15B4FDEDA9F1 /* ShareUpload.swift in Sources */ = {isa = PBXBuildFile; fileRef = B93DB1F595F44E2F2057BB2A /* ShareUpload.swift */; };
|
62028E64976F15B4FDEDA9F1 /* ShareUpload.swift in Sources */ = {isa = PBXBuildFile; fileRef = B93DB1F595F44E2F2057BB2A /* ShareUpload.swift */; };
|
||||||
63229CACE4281880037D33E6 /* ShareViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 27BDF61F5197F9B8F648E8AE /* ShareViewController.swift */; };
|
63229CACE4281880037D33E6 /* ShareViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 27BDF61F5197F9B8F648E8AE /* ShareViewController.swift */; };
|
||||||
@@ -29,6 +30,7 @@
|
|||||||
97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; };
|
97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; };
|
||||||
97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; };
|
97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; };
|
||||||
97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; };
|
97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; };
|
||||||
|
A16680889B3A9D8672FA29C1 /* TransferNotifications.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3247AAA4B65FE0F1A7279FA1 /* TransferNotifications.swift */; };
|
||||||
A3B3AB501C29516E20455F23 /* DavFolders.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51604DEE43E937A5FDCA7A58 /* DavFolders.swift */; };
|
A3B3AB501C29516E20455F23 /* DavFolders.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51604DEE43E937A5FDCA7A58 /* DavFolders.swift */; };
|
||||||
E295E45196E3973A5F7E1D8E /* SharedAccount.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F2C956C98F58A97B5595307 /* SharedAccount.swift */; };
|
E295E45196E3973A5F7E1D8E /* SharedAccount.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F2C956C98F58A97B5595307 /* SharedAccount.swift */; };
|
||||||
F598B112F4D33953984FEFF4 /* WebDAV.swift in Sources */ = {isa = PBXBuildFile; fileRef = 871EDB5791D5B3DA0ECF3D4E /* WebDAV.swift */; };
|
F598B112F4D33953984FEFF4 /* WebDAV.swift in Sources */ = {isa = PBXBuildFile; fileRef = 871EDB5791D5B3DA0ECF3D4E /* WebDAV.swift */; };
|
||||||
@@ -84,6 +86,7 @@
|
|||||||
1F2C956C98F58A97B5595307 /* SharedAccount.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SharedAccount.swift; sourceTree = "<group>"; };
|
1F2C956C98F58A97B5595307 /* SharedAccount.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SharedAccount.swift; sourceTree = "<group>"; };
|
||||||
20B0C3085FA6467C1A4A2947 /* TransferTypes.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TransferTypes.swift; sourceTree = "<group>"; };
|
20B0C3085FA6467C1A4A2947 /* TransferTypes.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TransferTypes.swift; sourceTree = "<group>"; };
|
||||||
27BDF61F5197F9B8F648E8AE /* ShareViewController.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ShareViewController.swift; sourceTree = "<group>"; };
|
27BDF61F5197F9B8F648E8AE /* ShareViewController.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ShareViewController.swift; sourceTree = "<group>"; };
|
||||||
|
3247AAA4B65FE0F1A7279FA1 /* TransferNotifications.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TransferNotifications.swift; sourceTree = "<group>"; };
|
||||||
331C807B294A618700263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = "<group>"; };
|
331C807B294A618700263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = "<group>"; };
|
||||||
331C8081294A63A400263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
331C8081294A63A400263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = "<group>"; };
|
3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = "<group>"; };
|
||||||
@@ -179,6 +182,7 @@
|
|||||||
1F2C956C98F58A97B5595307 /* SharedAccount.swift */,
|
1F2C956C98F58A97B5595307 /* SharedAccount.swift */,
|
||||||
B93DB1F595F44E2F2057BB2A /* ShareUpload.swift */,
|
B93DB1F595F44E2F2057BB2A /* ShareUpload.swift */,
|
||||||
51604DEE43E937A5FDCA7A58 /* DavFolders.swift */,
|
51604DEE43E937A5FDCA7A58 /* DavFolders.swift */,
|
||||||
|
3247AAA4B65FE0F1A7279FA1 /* TransferNotifications.swift */,
|
||||||
);
|
);
|
||||||
name = Shared;
|
name = Shared;
|
||||||
path = Shared;
|
path = Shared;
|
||||||
@@ -433,6 +437,7 @@
|
|||||||
3DEDE94F2A4C921407EF4382 /* ShareUpload.swift in Sources */,
|
3DEDE94F2A4C921407EF4382 /* ShareUpload.swift in Sources */,
|
||||||
3C1A0DAA5F9B37FEA4C604BE /* DavFolders.swift in Sources */,
|
3C1A0DAA5F9B37FEA4C604BE /* DavFolders.swift in Sources */,
|
||||||
66AFC246E251554A80B2476F /* SharePickerView.swift in Sources */,
|
66AFC246E251554A80B2476F /* SharePickerView.swift in Sources */,
|
||||||
|
A16680889B3A9D8672FA29C1 /* TransferNotifications.swift in Sources */,
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
@@ -451,6 +456,7 @@
|
|||||||
E295E45196E3973A5F7E1D8E /* SharedAccount.swift in Sources */,
|
E295E45196E3973A5F7E1D8E /* SharedAccount.swift in Sources */,
|
||||||
62028E64976F15B4FDEDA9F1 /* ShareUpload.swift in Sources */,
|
62028E64976F15B4FDEDA9F1 /* ShareUpload.swift in Sources */,
|
||||||
A3B3AB501C29516E20455F23 /* DavFolders.swift in Sources */,
|
A3B3AB501C29516E20455F23 /* DavFolders.swift in Sources */,
|
||||||
|
44078FC9474337A0E102B524 /* TransferNotifications.swift in Sources */,
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -125,6 +125,7 @@ enum NativeServices {
|
|||||||
NotificationCenter.default.addObserver(
|
NotificationCenter.default.addObserver(
|
||||||
forName: UIApplication.didBecomeActiveNotification, object: nil, queue: .main
|
forName: UIApplication.didBecomeActiveNotification, object: nil, queue: .main
|
||||||
) { _ in
|
) { _ in
|
||||||
|
TransferManager.shared.deliverExtensionUploads()
|
||||||
guard let sink = shareEvents.sink else { return }
|
guard let sink = shareEvents.sink else { return }
|
||||||
let items = consumeShared()
|
let items = consumeShared()
|
||||||
if !items.isEmpty { sink(items.map(encode)) }
|
if !items.isEmpty { sink(items.map(encode)) }
|
||||||
|
|||||||
@@ -106,6 +106,7 @@ final class TransferManager: NSObject {
|
|||||||
TransferBatch(kind: .upload, remoteFolder: remoteFolder, total: tasks.count, succeeded: 0, failed: 0),
|
TransferBatch(kind: .upload, remoteFolder: remoteFolder, total: tasks.count, succeeded: 0, failed: 0),
|
||||||
id: batch
|
id: batch
|
||||||
)
|
)
|
||||||
|
TransferNotifications.started(kind: .upload, batch: batch, count: tasks.count)
|
||||||
tasks.forEach { $0.resume() }
|
tasks.forEach { $0.resume() }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -134,6 +135,7 @@ final class TransferManager: NSObject {
|
|||||||
TransferBatch(kind: .download, remoteFolder: nil, total: tasks.count, succeeded: 0, failed: 0),
|
TransferBatch(kind: .download, remoteFolder: nil, total: tasks.count, succeeded: 0, failed: 0),
|
||||||
id: batch
|
id: batch
|
||||||
)
|
)
|
||||||
|
TransferNotifications.started(kind: .download, batch: batch, count: tasks.count)
|
||||||
tasks.forEach { $0.resume() }
|
tasks.forEach { $0.resume() }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -169,38 +171,28 @@ final class TransferManager: NSObject {
|
|||||||
|
|
||||||
/// Counts one file's outcome; fires the batch summary when it was the last.
|
/// Counts one file's outcome; fires the batch summary when it was the last.
|
||||||
private func record(_ info: TransferTaskInfo, success: Bool) {
|
private func record(_ info: TransferTaskInfo, success: Bool) {
|
||||||
if let batch = TransferBatchStore.record(info, success: success) { finish(batch) }
|
if let batch = TransferBatchStore.record(info, success: success) { finish(batch, id: info.batch) }
|
||||||
}
|
}
|
||||||
|
|
||||||
private func finish(_ batch: TransferBatch) {
|
private func finish(_ batch: TransferBatch, id: String) {
|
||||||
let verb = batch.kind == .upload ? "upload" : "download"
|
TransferNotifications.finished(batch, id: id)
|
||||||
let past = batch.kind == .upload ? "Uploaded" : "Downloaded"
|
reportToDart(batch)
|
||||||
let body: String
|
}
|
||||||
if batch.failed == 0 {
|
|
||||||
body = "\(past) \(Self.files(batch.succeeded))"
|
/// Tells Dart (so `FilesController` can refresh the folder) once an upload
|
||||||
} else if batch.succeeded == 0 {
|
/// batch has landed at least one file.
|
||||||
body = "Couldn't \(verb) \(Self.files(batch.failed))"
|
private func reportToDart(_ batch: TransferBatch) {
|
||||||
} else {
|
|
||||||
body = "\(past) \(Self.files(batch.succeeded)), \(batch.failed) failed"
|
|
||||||
}
|
|
||||||
notify(body)
|
|
||||||
if batch.kind == .upload, batch.succeeded > 0, let folder = batch.remoteFolder {
|
if batch.kind == .upload, batch.succeeded > 0, let folder = batch.remoteFolder {
|
||||||
DispatchQueue.main.async { self.onUploadBatchFinished?(folder, batch.succeeded, batch.failed) }
|
DispatchQueue.main.async { self.onUploadBatchFinished?(folder, batch.succeeded, batch.failed) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func files(_ count: Int) -> String {
|
/// Folders the Share Extension finished uploading into while the app wasn't
|
||||||
count == 1 ? "1 file" : "\(count) files"
|
/// running - call when the app becomes active.
|
||||||
}
|
func deliverExtensionUploads() {
|
||||||
|
for folder in TransferBatchStore.consumeFinishedUploadFolders() {
|
||||||
private func notify(_ body: String) {
|
DispatchQueue.main.async { self.onUploadBatchFinished?(folder, 1, 0) }
|
||||||
let content = UNMutableNotificationContent()
|
}
|
||||||
content.title = "Noo"
|
|
||||||
content.body = body
|
|
||||||
content.sound = .default
|
|
||||||
UNUserNotificationCenter.current().add(
|
|
||||||
UNNotificationRequest(identifier: UUID().uuidString, content: content, trigger: nil)
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -229,9 +221,9 @@ extension TransferManager: URLSessionDownloadDelegate {
|
|||||||
guard let info = Self.info(for: task) else { return }
|
guard let info = Self.info(for: task) else { return }
|
||||||
switch info.kind {
|
switch info.kind {
|
||||||
case .upload:
|
case .upload:
|
||||||
if let staged = info.stagedPath { try? FileManager.default.removeItem(atPath: staged) }
|
if let result = UploadResults.handle(task, error: error), let batch = result.batch {
|
||||||
let status = (task.response as? HTTPURLResponse)?.statusCode ?? 0
|
reportToDart(batch)
|
||||||
record(info, success: error == nil && (200..<300).contains(status))
|
}
|
||||||
case .download:
|
case .download:
|
||||||
// A finished download was already counted in didFinishDownloadingTo;
|
// A finished download was already counted in didFinishDownloadingTo;
|
||||||
// only a transport failure arrives here.
|
// only a transport failure arrives here.
|
||||||
|
|||||||
@@ -185,4 +185,19 @@ class RunnerTests: XCTestCase {
|
|||||||
XCTAssertEqual(finished?.failed, 1)
|
XCTAssertEqual(finished?.failed, 1)
|
||||||
XCTAssertEqual(finished?.remoteFolder, "/Docs")
|
XCTAssertEqual(finished?.remoteFolder, "/Docs")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func testFinishedUploadFoldersAreNotedOnceAndConsumed() {
|
||||||
|
_ = TransferBatchStore.consumeFinishedUploadFolders() // start clean
|
||||||
|
TransferBatchStore.noteFinishedUpload(folder: "/Docs")
|
||||||
|
TransferBatchStore.noteFinishedUpload(folder: "/Docs")
|
||||||
|
TransferBatchStore.noteFinishedUpload(folder: "/Photos")
|
||||||
|
|
||||||
|
XCTAssertEqual(TransferBatchStore.consumeFinishedUploadFolders(), ["/Docs", "/Photos"])
|
||||||
|
XCTAssertEqual(TransferBatchStore.consumeFinishedUploadFolders(), [])
|
||||||
|
}
|
||||||
|
|
||||||
|
func testNotificationFileCountWording() {
|
||||||
|
XCTAssertEqual(TransferNotifications.files(1), "1 file")
|
||||||
|
XCTAssertEqual(TransferNotifications.files(3), "3 files")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,8 +43,10 @@ enum ShareUpload {
|
|||||||
static func enqueue(items: [SharedItem], account: SharedAccount, remoteFolder: String) throws {
|
static func enqueue(items: [SharedItem], account: SharedAccount, remoteFolder: String) throws {
|
||||||
guard !items.isEmpty else { return }
|
guard !items.isEmpty else { return }
|
||||||
let batch = UUID().uuidString
|
let batch = UUID().uuidString
|
||||||
// No delegate here: the extension is gone before the results arrive.
|
// Small files can finish while the extension is still on screen; for
|
||||||
let session = URLSession(configuration: configuration())
|
// those this delegate counts them. Whatever outlives the extension is
|
||||||
|
// reported to the app instead (see `TransferManager`).
|
||||||
|
let session = URLSession(configuration: configuration(), delegate: ShareUploadDelegate(), delegateQueue: nil)
|
||||||
|
|
||||||
var tasks: [URLSessionUploadTask] = []
|
var tasks: [URLSessionUploadTask] = []
|
||||||
for item in items {
|
for item in items {
|
||||||
@@ -62,6 +64,7 @@ enum ShareUpload {
|
|||||||
TransferBatchStore.save(
|
TransferBatchStore.save(
|
||||||
TransferBatch(kind: .upload, remoteFolder: remoteFolder, total: tasks.count, succeeded: 0, failed: 0),
|
TransferBatch(kind: .upload, remoteFolder: remoteFolder, total: tasks.count, succeeded: 0, failed: 0),
|
||||||
id: batch)
|
id: batch)
|
||||||
|
TransferNotifications.started(kind: .upload, batch: batch, count: tasks.count)
|
||||||
tasks.forEach { $0.resume() }
|
tasks.forEach { $0.resume() }
|
||||||
session.finishTasksAndInvalidate()
|
session.finishTasksAndInvalidate()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import Foundation
|
||||||
|
import UserNotifications
|
||||||
|
|
||||||
|
/// The local notifications for an upload/download batch - one "Uploading 3
|
||||||
|
/// files…" when it starts, replaced in place by "Uploaded 3 files" when it
|
||||||
|
/// ends (both use the batch's id as the request identifier, so the second
|
||||||
|
/// *replaces* the first instead of stacking). Posted from whichever process
|
||||||
|
/// sees the event - the app, or the Share Extension while it's still alive.
|
||||||
|
/// iOS can't update a notification with live byte progress from a background
|
||||||
|
/// session, so start/finish is as much as a notification can say.
|
||||||
|
/// Compiled into both targets.
|
||||||
|
enum TransferNotifications {
|
||||||
|
static func started(kind: TransferTaskInfo.Kind, batch: String, count: Int) {
|
||||||
|
let verb = kind == .upload ? "Uploading" : "Downloading"
|
||||||
|
post(id: batch, body: "\(verb) \(files(count))…", sound: false)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func finished(_ batch: TransferBatch, id: String) {
|
||||||
|
let verb = batch.kind == .upload ? "upload" : "download"
|
||||||
|
let past = batch.kind == .upload ? "Uploaded" : "Downloaded"
|
||||||
|
let body: String
|
||||||
|
if batch.failed == 0 {
|
||||||
|
body = "\(past) \(files(batch.succeeded))"
|
||||||
|
} else if batch.succeeded == 0 {
|
||||||
|
body = "Couldn't \(verb) \(files(batch.failed))"
|
||||||
|
} else {
|
||||||
|
body = "\(past) \(files(batch.succeeded)), \(batch.failed) failed"
|
||||||
|
}
|
||||||
|
post(id: id, body: body, sound: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func files(_ count: Int) -> String {
|
||||||
|
count == 1 ? "1 file" : "\(count) files"
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func post(id: String, body: String, sound: Bool) {
|
||||||
|
let content = UNMutableNotificationContent()
|
||||||
|
content.title = "Noo"
|
||||||
|
content.body = body
|
||||||
|
if sound { content.sound = .default }
|
||||||
|
UNUserNotificationCenter.current().add(
|
||||||
|
UNNotificationRequest(identifier: "transfer.\(id)", content: content, trigger: nil))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Counting one finished upload task - shared so the result is handled the
|
||||||
|
/// same way whether the Share Extension (still alive) or the app (relaunched
|
||||||
|
/// later) receives it.
|
||||||
|
enum UploadResults {
|
||||||
|
/// Deletes the task's staged copy, records its outcome, and - if it was the
|
||||||
|
/// last file of its batch - posts the summary notification and returns the
|
||||||
|
/// finished batch.
|
||||||
|
static func handle(_ task: URLSessionTask, error: Error?) -> (info: TransferTaskInfo, batch: TransferBatch?)? {
|
||||||
|
guard let info = task.taskDescription
|
||||||
|
.flatMap({ $0.data(using: .utf8) })
|
||||||
|
.flatMap({ try? JSONDecoder().decode(TransferTaskInfo.self, from: $0) }),
|
||||||
|
info.kind == .upload
|
||||||
|
else { return nil }
|
||||||
|
if let staged = info.stagedPath { try? FileManager.default.removeItem(atPath: staged) }
|
||||||
|
let status = (task.response as? HTTPURLResponse)?.statusCode ?? 0
|
||||||
|
let ok = error == nil && (200..<300).contains(status)
|
||||||
|
let batch = TransferBatchStore.record(info, success: ok)
|
||||||
|
if let batch { TransferNotifications.finished(batch, id: info.batch) }
|
||||||
|
return (info, batch)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Delegate for the Share Extension's upload session while the extension is
|
||||||
|
/// still alive: files that finish before it closes are counted here, the
|
||||||
|
/// rest by the app (`TransferManager`) once the system relaunches it.
|
||||||
|
final class ShareUploadDelegate: NSObject, URLSessionTaskDelegate {
|
||||||
|
func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) {
|
||||||
|
guard let result = UploadResults.handle(task, error: error) else { return }
|
||||||
|
// The app wasn't running to hear about this, so leave a note for it to
|
||||||
|
// refresh that folder next time it's active.
|
||||||
|
if let batch = result.batch, batch.succeeded > 0, let folder = batch.remoteFolder {
|
||||||
|
TransferBatchStore.noteFinishedUpload(folder: folder)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -47,6 +47,27 @@ enum TransferBatchStore {
|
|||||||
if let data = try? JSONEncoder().encode(batch) { defaults.set(data, forKey: key(id)) }
|
if let data = try? JSONEncoder().encode(batch) { defaults.set(data, forKey: key(id)) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static let finishedFoldersKey = "transfer.finishedUploadFolders"
|
||||||
|
|
||||||
|
/// The Share Extension finished an upload into [folder] while the app
|
||||||
|
/// wasn't running; the app reads this on its next activation so its file
|
||||||
|
/// list can refresh.
|
||||||
|
static func noteFinishedUpload(folder: String) {
|
||||||
|
lock.lock()
|
||||||
|
defer { lock.unlock() }
|
||||||
|
var folders = defaults.stringArray(forKey: finishedFoldersKey) ?? []
|
||||||
|
if !folders.contains(folder) { folders.append(folder) }
|
||||||
|
defaults.set(folders, forKey: finishedFoldersKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func consumeFinishedUploadFolders() -> [String] {
|
||||||
|
lock.lock()
|
||||||
|
defer { lock.unlock() }
|
||||||
|
let folders = defaults.stringArray(forKey: finishedFoldersKey) ?? []
|
||||||
|
defaults.removeObject(forKey: finishedFoldersKey)
|
||||||
|
return folders
|
||||||
|
}
|
||||||
|
|
||||||
/// Counts one file's outcome. Returns the batch when that was its last
|
/// Counts one file's outcome. Returns the batch when that was its last
|
||||||
/// file (and forgets it), nil while others are still running.
|
/// file (and forgets it), nil while others are still running.
|
||||||
static func record(_ info: TransferTaskInfo, success: Bool) -> TransferBatch? {
|
static func record(_ info: TransferTaskInfo, success: Bool) -> TransferBatch? {
|
||||||
|
|||||||
Reference in New Issue
Block a user