finalized iOS setup
This commit is contained in:
+7
-8
@@ -1,6 +1,5 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="15400" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES" initialViewController="j1y-V4-xli">
|
||||
<device id="retina6_1" orientation="portrait" appearance="light"/>
|
||||
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="22504" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES" initialViewController="j1y-V4-xli">
|
||||
<dependencies>
|
||||
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="22504"/>
|
||||
<capability name="Safe area layout guides" minToolsVersion="9.0"/>
|
||||
@@ -10,15 +9,15 @@
|
||||
<!--Share View Controller-->
|
||||
<scene sceneID="ceB-am-kn3">
|
||||
<objects>
|
||||
<viewController id="j1y-V4-xli" customClass="ShareViewController" customModuleProvider="target">
|
||||
<view key="view" contentMode="scaleToFill" id="wbc-yd-nQP">
|
||||
<rect key="frame" x="0.0" y="0.0" width="414" height="896"/>
|
||||
<viewController id="j1y-V4-xli" customClass="ShareViewController" customModuleProvider="target" sceneMemberID="viewController">
|
||||
<view key="view" opaque="NO" contentMode="scaleToFill" id="wbc-yd-nQP">
|
||||
<rect key="frame" x="0.0" y="0.0" width="375" height="667"/>
|
||||
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
|
||||
<viewLayoutGuide key="safeArea" id="bcg-RR-FT9"/>
|
||||
<color key="backgroundColor" systemColor="systemBackgroundColor"/>
|
||||
<color key="backgroundColor" red="0.0" green="0.0" blue="0.0" alpha="0.0" colorSpace="custom" customColorSpace="sRGB"/>
|
||||
<viewLayoutGuide key="safeArea" id="1Xd-am-t49"/>
|
||||
</view>
|
||||
</viewController>
|
||||
<placeholder placeholderIdentifier="IBFirstResponder" id="CzN-xT-EUl" userLabel="First Responder" sceneMemberID="firstResponder"/>
|
||||
<placeholder placeholderIdentifier="IBFirstResponder" id="CEy-Cv-SGf" userLabel="First Responder" sceneMemberID="firstResponder"/>
|
||||
</objects>
|
||||
</scene>
|
||||
</scenes>
|
||||
@@ -1,93 +0,0 @@
|
||||
# iOS Share Extension — Xcode Setup
|
||||
|
||||
Die Quellen unter `ios/Share Extension/` müssen einmalig in Xcode als **Share Extension Target** verdrahtet werden — analog zur `TimetableWidgetExtension`. Erst danach taucht „Marianum Fulda" im System-Share-Sheet auf.
|
||||
|
||||
## Schritt 1 — Share-Extension-Target anlegen
|
||||
|
||||
1. `ios/Runner.xcworkspace` in Xcode öffnen.
|
||||
2. Projekt-Sidebar → `Runner` (Projekt-Root) → **+ Add Target** unten links.
|
||||
3. **iOS → Share Extension** wählen.
|
||||
4. Eigenschaften:
|
||||
- Product Name: `Share Extension` (mit Leerzeichen, exakt so — der Ordnername und Podfile-Eintrag matchen).
|
||||
- Bundle Identifier: `eu.mhsl.marianum.mobile.client.Share-Extension`.
|
||||
- Language: Swift.
|
||||
- Embed in: Runner.
|
||||
5. Beim Activate-Scheme-Dialog auf **Cancel** klicken.
|
||||
6. Deployment Target = mind. iOS 12.0 (Plugin-Mindestanforderung).
|
||||
|
||||
## Schritt 2 — Vorhandene Quelldateien ins Target ziehen
|
||||
|
||||
Xcode legt Dummy-Dateien an. Diese **löschen** (Move to Trash). Dann:
|
||||
|
||||
1. Sidebar → Rechtsklick auf den Ordner `Share Extension` → **Add Files to "Runner"…**
|
||||
2. Im File-Picker zu `ios/Share Extension/` navigieren und folgende Dateien selektieren:
|
||||
- `ShareViewController.swift`
|
||||
- `Info.plist`
|
||||
- `MainInterface.storyboard`
|
||||
- `Share Extension.entitlements`
|
||||
3. **Wichtig**: bei „Add to targets" nur `Share Extension` ankreuzen, **nicht** Runner.
|
||||
|
||||
## Schritt 3 — App Group aktivieren
|
||||
|
||||
Beide Targets brauchen die App-Group-Berechtigung, damit die Extension geteilte Dateien für die Hauptapp im gemeinsamen Container ablegen kann.
|
||||
|
||||
1. **Runner**-Target → **Signing & Capabilities** → **+ Capability** → **App Groups**.
|
||||
- Group-ID hinzufügen: `group.eu.mhsl.marianum.mobile.client.share` (zusätzlich zur bereits existierenden Widget-Group).
|
||||
2. Dasselbe für **Share Extension**-Target — mit derselben Group-ID `group.eu.mhsl.marianum.mobile.client.share`.
|
||||
|
||||
Im Apple-Developer-Portal muss diese App-Group bei beiden App-IDs eingetragen sein, sonst schlägt das Provisioning fehl.
|
||||
|
||||
## Schritt 4 — User-Defined Build Setting `CUSTOM_GROUP_ID`
|
||||
|
||||
Beide Targets brauchen das User-Defined Setting, das in `Runner/Info.plist` und `Share Extension/Info.plist` als `$(CUSTOM_GROUP_ID)` referenziert wird.
|
||||
|
||||
1. **Runner** → Build Settings → `+` (oben links) → **Add User-Defined Setting**.
|
||||
- Name: `CUSTOM_GROUP_ID`
|
||||
- Wert: `group.eu.mhsl.marianum.mobile.client.share`
|
||||
2. Dasselbe für **Share Extension**-Target.
|
||||
|
||||
## Schritt 5 — Entitlements verlinken
|
||||
|
||||
1. **Runner** → Build Settings → `CODE_SIGN_ENTITLEMENTS` zeigt bereits auf `Runner/Runner.entitlements` (jetzt mit beiden Groups).
|
||||
2. **Share Extension** → Build Settings → `CODE_SIGN_ENTITLEMENTS` → auf `Share Extension/Share Extension.entitlements` setzen.
|
||||
|
||||
## Schritt 6 — Info.plist-Pfad
|
||||
|
||||
**Share Extension** → Build Settings → `INFOPLIST_FILE` → auf `Share Extension/Info.plist` setzen.
|
||||
|
||||
## Schritt 7 — Build Phases reorder
|
||||
|
||||
Damit das Plugin-Modul vom Extension-Target gefunden wird:
|
||||
|
||||
1. **Runner**-Target → **Build Phases**.
|
||||
2. `Embed Foundation Extensions` per Drag-and-Drop **vor** `Thin Binary` ziehen.
|
||||
|
||||
## Schritt 8 — Pods installieren
|
||||
|
||||
```bash
|
||||
cd ios && pod install
|
||||
```
|
||||
|
||||
Der Podfile-Eintrag (`target 'Share Extension' do inherit! :search_paths end`) ist bereits vorhanden.
|
||||
|
||||
## Schritt 9 — Build & Run
|
||||
|
||||
1. Scheme `Runner` wählen → Run auf Device oder Simulator (≥ iOS 12).
|
||||
2. Foto in der Fotos-App auswählen → Teilen → „Marianum Fulda" sollte erscheinen.
|
||||
3. Auswahl → App öffnet sich, ShareTargetPage erscheint.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- **Error: No such module 'receive_sharing_intent'**
|
||||
→ Schritt 7 (Build Phases reorder) wurde übersprungen.
|
||||
- **Error: ‚Frameworks' not allowed in extension**
|
||||
→ In Build Settings der Share Extension `Other Linker Flags` und `Framework Search Paths` leeren (nur die geerbten Pod-Pfade behalten).
|
||||
- **Share-Sheet zeigt App nicht an**
|
||||
→ `NSExtensionActivationRule`-Limits in `Share Extension/Info.plist` zu klein? Werte testweise erhöhen. Außerdem: App muss **mindestens einmal nach Install** geöffnet worden sein, sonst wird die Extension von iOS nicht registriert.
|
||||
- **Files kommen mit `nil` Pfad an**
|
||||
→ App-Group nicht konsistent. Prüfen, dass `CUSTOM_GROUP_ID` in beiden Targets identisch ist und die Entitlement-Files dieselbe Group enthalten.
|
||||
|
||||
## Was am Mac noch zu tun ist
|
||||
|
||||
- Schritte 1–8 oben (~15 Min).
|
||||
- Auf physischem iPhone testen — Simulator-Share-Sheet ist eingeschränkt.
|
||||
@@ -1,8 +1,240 @@
|
||||
import UIKit
|
||||
import receive_sharing_intent
|
||||
import UniformTypeIdentifiers
|
||||
import AVFoundation
|
||||
|
||||
class ShareViewController: RSIShareViewController {
|
||||
override func shouldAutoRedirect() -> Bool {
|
||||
return true
|
||||
}
|
||||
// Datenmodell muss byte-für-byte zu dem passen, was
|
||||
// SwiftReceiveSharingIntentPlugin auf der Host-App-Seite decodiert.
|
||||
private enum SharedMediaType: String, Codable {
|
||||
case image, video, text, file, url
|
||||
}
|
||||
|
||||
private struct SharedMediaFile: Codable {
|
||||
let path: String
|
||||
let mimeType: String?
|
||||
let thumbnail: String?
|
||||
let duration: Double?
|
||||
let message: String?
|
||||
let type: SharedMediaType
|
||||
}
|
||||
|
||||
final class ShareViewController: UIViewController {
|
||||
// Schlüssel sind die, die das Plugin liest.
|
||||
private let userDefaultsKey = "ShareKey"
|
||||
private let userDefaultsMessageKey = "ShareMessageKey"
|
||||
private let urlSchemePrefix = "ShareMedia"
|
||||
|
||||
private var appGroupId = ""
|
||||
private var hostBundleId = ""
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
resolveIds()
|
||||
setupPlaceholderUI()
|
||||
}
|
||||
|
||||
override func viewDidAppear(_ animated: Bool) {
|
||||
super.viewDidAppear(animated)
|
||||
processAttachments()
|
||||
}
|
||||
|
||||
private func resolveIds() {
|
||||
let extBundleId = Bundle.main.bundleIdentifier ?? ""
|
||||
if let lastDot = extBundleId.lastIndex(of: ".") {
|
||||
hostBundleId = String(extBundleId[..<lastDot])
|
||||
}
|
||||
let custom = Bundle.main.object(forInfoDictionaryKey: "AppGroupId") as? String
|
||||
if let custom, !custom.isEmpty, !custom.contains("$(") {
|
||||
appGroupId = custom
|
||||
} else {
|
||||
appGroupId = "group.\(hostBundleId)"
|
||||
}
|
||||
}
|
||||
|
||||
private func setupPlaceholderUI() {
|
||||
view.backgroundColor = .systemBackground
|
||||
let spinner = UIActivityIndicatorView(style: .medium)
|
||||
spinner.startAnimating()
|
||||
let label = UILabel()
|
||||
label.text = "Wird geteilt …"
|
||||
label.font = .preferredFont(forTextStyle: .footnote)
|
||||
label.textColor = .secondaryLabel
|
||||
let stack = UIStackView(arrangedSubviews: [spinner, label])
|
||||
stack.axis = .vertical
|
||||
stack.alignment = .center
|
||||
stack.spacing = 8
|
||||
stack.translatesAutoresizingMaskIntoConstraints = false
|
||||
view.addSubview(stack)
|
||||
NSLayoutConstraint.activate([
|
||||
stack.centerXAnchor.constraint(equalTo: view.centerXAnchor),
|
||||
stack.centerYAnchor.constraint(equalTo: view.centerYAnchor),
|
||||
])
|
||||
}
|
||||
|
||||
private func processAttachments() {
|
||||
let attachments: [NSItemProvider] = (extensionContext?.inputItems ?? [])
|
||||
.compactMap { $0 as? NSExtensionItem }
|
||||
.flatMap { $0.attachments ?? [] }
|
||||
guard !attachments.isEmpty else {
|
||||
finish()
|
||||
return
|
||||
}
|
||||
let group = DispatchGroup()
|
||||
var collected: [SharedMediaFile] = []
|
||||
let lock = NSLock()
|
||||
for provider in attachments {
|
||||
group.enter()
|
||||
handle(provider: provider) { file in
|
||||
if let f = file {
|
||||
lock.lock(); collected.append(f); lock.unlock()
|
||||
}
|
||||
group.leave()
|
||||
}
|
||||
}
|
||||
group.notify(queue: .main) { [weak self] in
|
||||
self?.saveAndRedirect(items: collected)
|
||||
}
|
||||
}
|
||||
|
||||
// Reihenfolge entspricht SharedMediaType.allCases im Plugin —
|
||||
// spezifische Typen (image, video) vor generischen (file, url).
|
||||
private func handle(provider: NSItemProvider,
|
||||
completion: @escaping (SharedMediaFile?) -> Void) {
|
||||
let order: [(String, SharedMediaType)] = [
|
||||
(UTType.image.identifier, .image),
|
||||
(UTType.movie.identifier, .video),
|
||||
(UTType.text.identifier, .text),
|
||||
(UTType.fileURL.identifier, .file),
|
||||
(UTType.url.identifier, .url),
|
||||
(UTType.data.identifier, .file),
|
||||
]
|
||||
for (typeId, kind) in order {
|
||||
if provider.hasItemConformingToTypeIdentifier(typeId) {
|
||||
provider.loadItem(forTypeIdentifier: typeId) { [weak self] data, error in
|
||||
guard let self else { completion(nil); return }
|
||||
if error != nil { completion(nil); return }
|
||||
completion(self.toSharedFile(data: data, kind: kind))
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
completion(nil)
|
||||
}
|
||||
|
||||
private func toSharedFile(data: Any?, kind: SharedMediaType) -> SharedMediaFile? {
|
||||
switch kind {
|
||||
case .text:
|
||||
guard let s = data as? String else { return nil }
|
||||
return SharedMediaFile(path: s, mimeType: "text/plain",
|
||||
thumbnail: nil, duration: nil, message: nil, type: .text)
|
||||
case .url:
|
||||
guard let u = data as? URL else { return nil }
|
||||
return SharedMediaFile(path: u.absoluteString, mimeType: nil,
|
||||
thumbnail: nil, duration: nil, message: nil, type: .url)
|
||||
case .image:
|
||||
if let u = data as? URL, let dst = copyIntoAppGroup(src: u) {
|
||||
return SharedMediaFile(path: pathString(for: dst), mimeType: mime(for: u),
|
||||
thumbnail: nil, duration: nil, message: nil, type: .image)
|
||||
}
|
||||
if let img = data as? UIImage, let dst = writePng(image: img) {
|
||||
return SharedMediaFile(path: pathString(for: dst), mimeType: "image/png",
|
||||
thumbnail: nil, duration: nil, message: nil, type: .image)
|
||||
}
|
||||
return nil
|
||||
case .video:
|
||||
guard let u = data as? URL, let dst = copyIntoAppGroup(src: u) else { return nil }
|
||||
return SharedMediaFile(path: pathString(for: dst), mimeType: mime(for: u),
|
||||
thumbnail: nil, duration: videoDurationMs(url: u),
|
||||
message: nil, type: .video)
|
||||
case .file:
|
||||
guard let u = data as? URL, let dst = copyIntoAppGroup(src: u) else { return nil }
|
||||
return SharedMediaFile(path: pathString(for: dst), mimeType: mime(for: u),
|
||||
thumbnail: nil, duration: nil, message: nil, type: .file)
|
||||
}
|
||||
}
|
||||
|
||||
private func copyIntoAppGroup(src: URL) -> URL? {
|
||||
guard let container = FileManager.default
|
||||
.containerURL(forSecurityApplicationGroupIdentifier: appGroupId) else {
|
||||
return nil
|
||||
}
|
||||
let name = src.lastPathComponent.isEmpty ? UUID().uuidString : src.lastPathComponent
|
||||
let dst = container.appendingPathComponent(name)
|
||||
do {
|
||||
if FileManager.default.fileExists(atPath: dst.path) {
|
||||
try FileManager.default.removeItem(at: dst)
|
||||
}
|
||||
try FileManager.default.copyItem(at: src, to: dst)
|
||||
return dst
|
||||
} catch {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
private func writePng(image: UIImage) -> URL? {
|
||||
guard let container = FileManager.default
|
||||
.containerURL(forSecurityApplicationGroupIdentifier: appGroupId),
|
||||
let data = image.pngData() else { return nil }
|
||||
let dst = container.appendingPathComponent("\(UUID().uuidString).png")
|
||||
return (try? data.write(to: dst)) != nil ? dst : nil
|
||||
}
|
||||
|
||||
// Das Plugin macht `path.replacingOccurrences(of: "file://", with: "")`,
|
||||
// also liefern wir absoluteString mit Prozent-Decoding — selbes Format wie
|
||||
// im Original-RSIShareViewController.
|
||||
private func pathString(for url: URL) -> String {
|
||||
url.absoluteString.removingPercentEncoding ?? url.absoluteString
|
||||
}
|
||||
|
||||
private func mime(for url: URL) -> String? {
|
||||
UTType(filenameExtension: url.pathExtension)?.preferredMIMEType
|
||||
}
|
||||
|
||||
private func videoDurationMs(url: URL) -> Double {
|
||||
(CMTimeGetSeconds(AVURLAsset(url: url).duration) * 1000).rounded()
|
||||
}
|
||||
|
||||
private func saveAndRedirect(items: [SharedMediaFile]) {
|
||||
guard !items.isEmpty else {
|
||||
finish()
|
||||
return
|
||||
}
|
||||
let defaults = UserDefaults(suiteName: appGroupId)
|
||||
guard let encoded = try? JSONEncoder().encode(items) else {
|
||||
finish()
|
||||
return
|
||||
}
|
||||
defaults?.set(encoded, forKey: userDefaultsKey)
|
||||
defaults?.removeObject(forKey: userDefaultsMessageKey)
|
||||
|
||||
let urlStr = "\(urlSchemePrefix)-\(hostBundleId):share"
|
||||
guard let url = URL(string: urlStr) else {
|
||||
finish()
|
||||
return
|
||||
}
|
||||
|
||||
// Apple-DTS says Share Extensions are not officially allowed to open
|
||||
// URLs. Both `extensionContext.open` and the responder-chain trick
|
||||
// succeed in some iOS versions and fail silently in others, so we fire
|
||||
// both in parallel and let whichever Apple is honouring this release
|
||||
// win the race.
|
||||
extensionContext?.open(url, completionHandler: nil)
|
||||
var responder: UIResponder? = self
|
||||
while responder != nil {
|
||||
if let app = responder as? UIApplication {
|
||||
app.open(url, options: [:], completionHandler: nil)
|
||||
break
|
||||
}
|
||||
responder = responder?.next
|
||||
}
|
||||
|
||||
// Brief window so the open request reaches the system before we tear
|
||||
// the extension down.
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { [weak self] in
|
||||
self?.finish()
|
||||
}
|
||||
}
|
||||
|
||||
private func finish() {
|
||||
extensionContext?.completeRequest(returningItems: [], completionHandler: nil)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user