iOS: Share Extension (share to Noo) via an App Group inbox
ShareExtension target copies shared files into group.dev.ayushya.noo and leaves a manifest; the app consumes it on launch/activation through the existing share_intent channel into the destination picker. Extension is embedded before Flutter's script phases; versions follow FLUTTER_BUILD_*. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5.5
parent
8b86aa8f34
commit
525504bc47
@@ -0,0 +1,43 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>Noo</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>$(EXECUTABLE_NAME)</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>$(PRODUCT_NAME)</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>$(MARKETING_VERSION)</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>$(CURRENT_PROJECT_VERSION)</string>
|
||||
<key>NSExtension</key>
|
||||
<dict>
|
||||
<key>NSExtensionAttributes</key>
|
||||
<dict>
|
||||
<key>NSExtensionActivationRule</key>
|
||||
<dict>
|
||||
<key>NSExtensionActivationSupportsFileWithMaxCount</key>
|
||||
<integer>50</integer>
|
||||
<key>NSExtensionActivationSupportsImageWithMaxCount</key>
|
||||
<integer>50</integer>
|
||||
<key>NSExtensionActivationSupportsMovieWithMaxCount</key>
|
||||
<integer>50</integer>
|
||||
</dict>
|
||||
</dict>
|
||||
<key>NSExtensionPointIdentifier</key>
|
||||
<string>com.apple.share-services</string>
|
||||
<key>NSExtensionPrincipalClass</key>
|
||||
<string>$(PRODUCT_MODULE_NAME).ShareViewController</string>
|
||||
</dict>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>com.apple.security.application-groups</key>
|
||||
<array>
|
||||
<string>group.dev.ayushya.noo</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,122 @@
|
||||
import UIKit
|
||||
import UniformTypeIdentifiers
|
||||
import UserNotifications
|
||||
|
||||
/// "Share to Noo": copies whatever was shared into the App Group container
|
||||
/// (`SharedInbox`) and finishes. The extension can't pick a destination or
|
||||
/// upload - it has no session and a tight memory/time budget - so the app
|
||||
/// does that when it next opens, through its existing destination picker. A
|
||||
/// notification tells the user to open it, because an extension can't launch
|
||||
/// its host app.
|
||||
final class ShareViewController: UIViewController {
|
||||
private let label = UILabel()
|
||||
private let spinner = UIActivityIndicatorView(style: .large)
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
view.backgroundColor = .systemBackground
|
||||
label.text = "Saving to Noo…"
|
||||
label.font = .preferredFont(forTextStyle: .headline)
|
||||
label.textAlignment = .center
|
||||
label.numberOfLines = 0
|
||||
spinner.startAnimating()
|
||||
|
||||
let stack = UIStackView(arrangedSubviews: [spinner, label])
|
||||
stack.axis = .vertical
|
||||
stack.spacing = 16
|
||||
stack.alignment = .center
|
||||
stack.translatesAutoresizingMaskIntoConstraints = false
|
||||
view.addSubview(stack)
|
||||
NSLayoutConstraint.activate([
|
||||
stack.centerXAnchor.constraint(equalTo: view.centerXAnchor),
|
||||
stack.centerYAnchor.constraint(equalTo: view.centerYAnchor),
|
||||
stack.leadingAnchor.constraint(greaterThanOrEqualTo: view.leadingAnchor, constant: 24),
|
||||
stack.trailingAnchor.constraint(lessThanOrEqualTo: view.trailingAnchor, constant: -24),
|
||||
])
|
||||
|
||||
Task { await importSharedItems() }
|
||||
}
|
||||
|
||||
private func importSharedItems() async {
|
||||
guard let root = AppGroup.containerURL else {
|
||||
return finish(message: "Noo isn't set up to receive files yet.", saved: 0)
|
||||
}
|
||||
let providers = (extensionContext?.inputItems as? [NSExtensionItem])?
|
||||
.flatMap { $0.attachments ?? [] } ?? []
|
||||
|
||||
var saved: [SharedItem] = []
|
||||
do {
|
||||
let batch = try SharedInbox.makeBatchDirectory(in: root)
|
||||
for provider in providers where provider.hasItemConformingToTypeIdentifier(UTType.item.identifier) {
|
||||
if let item = try? await copy(provider, into: batch) { saved.append(item) }
|
||||
}
|
||||
try SharedInbox.append(saved, in: root)
|
||||
} catch {
|
||||
return finish(message: "Couldn't save to Noo: \(error.localizedDescription)", saved: 0)
|
||||
}
|
||||
|
||||
if saved.isEmpty {
|
||||
finish(message: "Noo can only receive files and photos.", saved: 0)
|
||||
} else {
|
||||
notify(count: saved.count)
|
||||
finish(message: saved.count == 1 ? "Saved 1 file to Noo" : "Saved \(saved.count) files to Noo", saved: saved.count)
|
||||
}
|
||||
}
|
||||
|
||||
/// `loadFileRepresentation`'s URL only lives for the duration of its
|
||||
/// callback, so the copy happens inside it.
|
||||
private func copy(_ provider: NSItemProvider, into directory: URL) async throws -> SharedItem {
|
||||
try await withCheckedThrowingContinuation { continuation in
|
||||
provider.loadFileRepresentation(forTypeIdentifier: UTType.item.identifier) { url, error in
|
||||
guard let url else {
|
||||
return continuation.resume(throwing: error ?? CocoaError(.fileReadUnknown))
|
||||
}
|
||||
do {
|
||||
let destination = Self.uniqueURL(in: directory, name: url.lastPathComponent)
|
||||
try FileManager.default.copyItem(at: url, to: destination)
|
||||
let size = (try? FileManager.default.attributesOfItem(atPath: destination.path))?[.size] as? Int64 ?? 0
|
||||
let mime = UTType(filenameExtension: destination.pathExtension)?.preferredMIMEType
|
||||
continuation.resume(
|
||||
returning: SharedItem(
|
||||
path: destination.path, name: destination.lastPathComponent, mimeType: mime, size: size))
|
||||
} catch {
|
||||
continuation.resume(throwing: error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static func uniqueURL(in directory: URL, name: String) -> URL {
|
||||
var candidate = directory.appendingPathComponent(name)
|
||||
var index = 1
|
||||
while FileManager.default.fileExists(atPath: candidate.path) {
|
||||
let ext = (name as NSString).pathExtension
|
||||
let stem = (name as NSString).deletingPathExtension
|
||||
candidate = directory.appendingPathComponent(ext.isEmpty ? "\(stem) (\(index))" : "\(stem) (\(index)).\(ext)")
|
||||
index += 1
|
||||
}
|
||||
return candidate
|
||||
}
|
||||
|
||||
private func notify(count: Int) {
|
||||
let content = UNMutableNotificationContent()
|
||||
content.title = "Noo"
|
||||
content.body =
|
||||
count == 1
|
||||
? "Open Noo to choose where to upload 1 file."
|
||||
: "Open Noo to choose where to upload \(count) files."
|
||||
content.sound = .default
|
||||
UNUserNotificationCenter.current().add(
|
||||
UNNotificationRequest(identifier: UUID().uuidString, content: content, trigger: nil))
|
||||
}
|
||||
|
||||
private func finish(message: String, saved: Int) {
|
||||
DispatchQueue.main.async {
|
||||
self.spinner.stopAnimating()
|
||||
self.label.text = message
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + (saved > 0 ? 1.2 : 2.5)) {
|
||||
self.extensionContext?.completeRequest(returningItems: nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user