refactor: Simplify Share Extension to open main app directly

- Refactor ShareViewController to extract URL and open main app instead of direct API calls
- Add robust URL extraction from multiple content types (URL, text, property lists)
- Implement comprehensive debugging for Share Extension content processing
- Add URL scheme handling in main app (readeck://add-bookmark)
- Add notification-based communication between Share Extension and main app
- Extend BookmarksViewModel with share notification handling
- Support automatic AddBookmarkView opening with prefilled URL and title from shares

Technical changes:
- Remove Core Data dependency from Share Extension
- Add extensionContext.open() for launching main app with custom URL scheme
- Process all registered type identifiers for robust content extraction
- Add NSDataDetector for URL extraction from plain text
- Handle Safari property list sharing format
- Add share state management in BookmarksViewModel (@Observable pattern)
- Implement NotificationCenter publisher pattern with Combine

URL scheme format: readeck://add-bookmark?url=...&title=...
Notification: 'AddBookmarkFromShare' with url and title in userInfo
This commit is contained in:
Ilyas Hallak 2025-06-13 15:02:46 +02:00
parent 82f9d8a5a9
commit 8882a402ef
6 changed files with 236 additions and 214 deletions

View File

@ -8,188 +8,59 @@
import UIKit
import Social
import UniformTypeIdentifiers
import CoreData
class ShareViewController: SLComposeServiceViewController {
private var extractedURL: String?
private var extractedTitle: String?
private var isProcessing = false
override func viewDidLoad() {
super.viewDidLoad()
extractSharedContent()
setupUI()
// Automatisch die Haupt-App öffnen, sobald URL extrahiert wurde
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
self.openParentApp()
}
}
override func isContentValid() -> Bool {
guard let url = extractedURL,
!url.isEmpty,
!isProcessing,
URL(string: url) != nil else {
return false
}
return true
return true // Immer true, damit der Button funktioniert
}
override func didSelectPost() {
guard let url = extractedURL else {
completeRequest()
return
}
isProcessing = true
let title = textView.text != extractedTitle ? textView.text : extractedTitle
// UI Feedback zeigen
let loadingAlert = UIAlertController(title: "Speichere Bookmark", message: "Bitte warten...", preferredStyle: .alert)
present(loadingAlert, animated: true)
Task {
do {
let response = try await createBookmark(url: url, title: title)
await MainActor.run {
loadingAlert.dismiss(animated: true)
if response.status == 0 {
// Erfolg
let successAlert = UIAlertController(title: "Erfolg", message: "Bookmark wurde hinzugefügt!", preferredStyle: .alert)
successAlert.addAction(UIAlertAction(title: "OK", style: .default) { [weak self] _ in
self?.completeRequest()
})
present(successAlert, animated: true)
} else {
// Fehler vom Server
let errorAlert = UIAlertController(title: "Fehler", message: response.message, preferredStyle: .alert)
errorAlert.addAction(UIAlertAction(title: "OK", style: .default) { [weak self] _ in
self?.completeRequest()
})
present(errorAlert, animated: true)
}
}
} catch {
await MainActor.run {
loadingAlert.dismiss(animated: true)
let errorAlert = UIAlertController(title: "Fehler", message: error.localizedDescription, preferredStyle: .alert)
errorAlert.addAction(UIAlertAction(title: "OK", style: .default) { [weak self] _ in
self?.completeRequest()
})
present(errorAlert, animated: true)
}
}
}
openMainApp()
}
// MARK: - Core Data Token Retrieval
private func getCurrentToken() -> String? {
let context = CoreDataManager.shared.context
let request: NSFetchRequest<SettingEntity> = SettingEntity.fetchRequest() // Anpassen an deine Entity
do {
let tokens = try context.fetch(request)
return tokens.first?.token // Anpassen an dein Token-Attribut
} catch {
print("Failed to fetch token from Core Data: \(error)")
return nil
}
}
// MARK: - API Implementation
private func createBookmark(url: String, title: String?) async throws -> CreateBookmarkResponseDto {
let requestDto = CreateBookmarkRequestDto(labels: nil, title: title, url: url)
// Die Server-URL
let baseURL = "https://keep.mnk.any64.de"
let endpoint = "/api/bookmarks"
guard let requestURL = URL(string: "\(baseURL)\(endpoint)") else {
throw RequestError.invalidURL
}
var request = URLRequest(url: requestURL)
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.setValue("application/json", forHTTPHeaderField: "Accept")
// Token aus Core Data holen
guard let token = getCurrentToken() else {
throw RequestError.noToken
}
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
let encoder = JSONEncoder()
request.httpBody = try encoder.encode(requestDto)
// Request durchführen
let (data, response) = try await URLSession.shared.data(for: request)
guard let httpResponse = response as? HTTPURLResponse else {
throw RequestError.invalidResponse
}
guard 200...299 ~= httpResponse.statusCode else {
throw RequestError.serverError(statusCode: httpResponse.statusCode)
}
// Response dekodieren und zurückgeben
let decoder = JSONDecoder()
return try decoder.decode(CreateBookmarkResponseDto.self, from: data)
}
// MARK: - Support Methods
private func setupUI() {
self.title = "Zu readeck hinzufügen"
self.placeholder = "Optional: Titel anpassen..."
// Zeige URL oder Titel als Kontext
if let title = extractedTitle {
self.textView.text = title
} else if let url = extractedURL {
self.textView.text = URL(string: url)?.host ?? url
}
}
// MARK: - Private Methods
private func extractSharedContent() {
guard let extensionContext = extensionContext else { return }
for item in extensionContext.inputItems {
print("=== DEBUG: Starting content extraction ===")
print("Input items count: \(extensionContext.inputItems.count)")
for (itemIndex, item) in extensionContext.inputItems.enumerated() {
guard let inputItem = item as? NSExtensionItem else { continue }
for provider in inputItem.attachments ?? [] {
// URL direkt
if provider.hasItemConformingToTypeIdentifier(UTType.url.identifier) {
provider.loadItem(forTypeIdentifier: UTType.url.identifier, options: nil) { [weak self] item, error in
if let url = item as? URL {
DispatchQueue.main.async {
self?.extractedURL = url.absoluteString
self?.extractedTitle = inputItem.attributedTitle?.string ?? inputItem.attributedContentText?.string
self?.setupUI()
}
print("Item \(itemIndex) - attachments: \(inputItem.attachments?.count ?? 0)")
// Versuche alle verfügbaren Type Identifiers
for (attachmentIndex, provider) in (inputItem.attachments ?? []).enumerated() {
print("Attachment \(attachmentIndex) - registered types: \(provider.registeredTypeIdentifiers)")
// Iteriere durch alle registrierten Type Identifiers
for typeIdentifier in provider.registeredTypeIdentifiers {
print("Trying type identifier: \(typeIdentifier)")
provider.loadItem(forTypeIdentifier: typeIdentifier, options: nil) { [weak self] item, error in
if let error = error {
print("Error loading \(typeIdentifier): \(error)")
return
}
}
}
// Text (könnte URL enthalten)
else if provider.hasItemConformingToTypeIdentifier(UTType.plainText.identifier) {
provider.loadItem(forTypeIdentifier: UTType.plainText.identifier, options: nil) { [weak self] item, error in
if let text = item as? String {
DispatchQueue.main.async {
self?.handleTextContent(text)
}
}
}
}
// Property List (Safari teilt so)
else if provider.hasItemConformingToTypeIdentifier(UTType.propertyList.identifier) {
provider.loadItem(forTypeIdentifier: UTType.propertyList.identifier, options: nil) { [weak self] item, error in
if let dictionary = item as? [String: Any] {
DispatchQueue.main.async {
self?.handlePropertyList(dictionary)
}
DispatchQueue.main.async {
self?.processLoadedItem(item, typeIdentifier: typeIdentifier, inputItem: inputItem)
}
}
}
@ -197,72 +68,156 @@ class ShareViewController: SLComposeServiceViewController {
}
}
private func handleTextContent(_ text: String) {
private func processLoadedItem(_ item: Any?, typeIdentifier: String, inputItem: NSExtensionItem) {
print("Processing item of type \(typeIdentifier): \(type(of: item))")
// URL direkt
if let url = item as? URL {
print("Found URL: \(url.absoluteString)")
extractedURL = url.absoluteString
extractedTitle = inputItem.attributedTitle?.string ?? inputItem.attributedContentText?.string
return
}
// NSURL
if let nsurl = item as? NSURL {
print("Found NSURL: \(nsurl.absoluteString ?? "nil")")
extractedURL = nsurl.absoluteString
extractedTitle = inputItem.attributedTitle?.string ?? inputItem.attributedContentText?.string
return
}
// String (könnte URL sein)
if let text = item as? String {
print("Found String: \(text)")
if URL(string: text) != nil {
extractedURL = text
extractedTitle = inputItem.attributedTitle?.string ?? inputItem.attributedContentText?.string
return
}
// Versuche URL aus Text zu extrahieren
if let extractedURL = extractURLFromText(text) {
self.extractedURL = extractedURL
self.extractedTitle = text != extractedURL ? text : nil
return
}
}
// Dictionary (Property List)
if let dictionary = item as? [String: Any] {
print("Found Dictionary: \(dictionary)")
handlePropertyList(dictionary)
return
}
// NSData - versuche als String zu interpretieren
if let data = item as? Data {
if let text = String(data: data, encoding: .utf8) {
print("Found Data as String: \(text)")
if URL(string: text) != nil {
extractedURL = text
return
}
}
}
print("Could not process item of type: \(type(of: item))")
}
private func extractURLFromText(_ text: String) -> String? {
let detector = try? NSDataDetector(types: NSTextCheckingResult.CheckingType.link.rawValue)
let range = NSRange(text.startIndex..<text.endIndex, in: text)
if let match = detector?.firstMatch(in: text, options: [], range: range),
let url = match.url {
extractedURL = url.absoluteString
extractedTitle = text != url.absoluteString ? text : nil
} else if URL(string: text) != nil {
extractedURL = text
return url.absoluteString
}
setupUI()
return nil
}
private func handlePropertyList(_ dictionary: [String: Any]) {
if let urlString = dictionary[NSExtensionJavaScriptPreprocessingResultsKey] as? [String: Any],
let url = urlString["URL"] as? String {
extractedURL = url
extractedTitle = urlString["title"] as? String
} else if let url = dictionary["URL"] as? String {
extractedURL = url
extractedTitle = dictionary["title"] as? String
// Safari und andere Browser verwenden oft Property Lists
if let results = dictionary[NSExtensionJavaScriptPreprocessingResultsKey] as? [String: Any] {
if let url = results["URL"] as? String {
extractedURL = url
extractedTitle = results["title"] as? String
return
}
}
setupUI()
// Direkte URL im Dictionary
if let url = dictionary["URL"] as? String {
extractedURL = url
extractedTitle = dictionary["title"] as? String
return
}
// Andere mögliche Keys
for key in dictionary.keys {
if let value = dictionary[key] as? String, URL(string: value) != nil {
extractedURL = value
return
}
}
}
private func openMainApp() {
let url = extractedURL ?? "https://example.com"
let title = extractedTitle ?? ""
print("Opening main app with URL: \(url)")
// Verwende NSUserActivity anstatt URL-Schema
let userActivity = NSUserActivity(activityType: "de.ilyas.readeck")
userActivity.userInfo = [
"url": url,
"title": title
]
userActivity.webpageURL = URL(string: url)
// Extension schließen und Activity übergeben
extensionContext?.completeRequest(returningItems: [userActivity], completionHandler: nil)
}
private func completeRequest() {
isProcessing = false
extensionContext?.completeRequest(returningItems: [], completionHandler: nil)
}
}
// MARK: - DTOs und Error Handling
struct CreateBookmarkRequestDto: Codable {
let labels: [String]?
let title: String?
let url: String
}
struct CreateBookmarkResponseDto: Codable {
let message: String
let status: Int
}
enum RequestError: Error, LocalizedError {
case invalidURL
case invalidResponse
case serverError(statusCode: Int)
case decodingError
case noToken
var errorDescription: String? {
switch self {
case .invalidURL:
return "Ungültige URL"
case .invalidResponse:
return "Ungültige Server-Antwort"
case .serverError(let code):
return "Server-Fehler: \(code)"
case .decodingError:
return "Fehler beim Dekodieren der Antwort"
case .noToken:
return "Kein Authentifizierungs-Token gefunden"
func openParentApp() {
guard let extensionContext = extensionContext else { return }
let url = extractedURL ?? "https://example.com"
let title = extractedTitle ?? ""
print("Opening parent app with URL: \(url)")
// URL für die Haupt-App erstellen mit Parametern
var urlComponents = URLComponents(string: "readeck://add-bookmark")
urlComponents?.queryItems = [
URLQueryItem(name: "url", value: url)
]
if !title.isEmpty {
urlComponents?.queryItems?.append(URLQueryItem(name: "title", value: title))
}
guard let finalURL = urlComponents?.url else {
print("Failed to create final URL")
return
}
print("Final URL: \(finalURL)")
var responder: UIResponder? = self
while responder != nil {
if let application = responder as? UIApplication {
application.open(finalURL)
break
}
responder = responder?.next
}
}
}

View File

@ -4,6 +4,11 @@ struct AddBookmarkView: View {
@State private var viewModel = AddBookmarkViewModel()
@Environment(\.dismiss) private var dismiss
init(prefilledURL: String? = nil, prefilledTitle: String? = nil) {
viewModel.title = prefilledTitle ?? ""
viewModel.url = prefilledURL ?? ""
}
var body: some View {
NavigationView {
Form {
@ -78,6 +83,7 @@ struct AddBookmarkView: View {
ToolbarItem(placement: .navigationBarLeading) {
Button("Abbrechen") {
dismiss()
viewModel.clearForm()
}
}
@ -85,6 +91,7 @@ struct AddBookmarkView: View {
Button("Speichern") {
Task {
await viewModel.createBookmark()
dismiss()
}
}
.disabled(!viewModel.isValid || viewModel.isLoading)
@ -126,9 +133,12 @@ struct AddBookmarkView: View {
.onAppear {
viewModel.checkClipboard()
}
.onDisappear {
viewModel.clearForm()
}
}
}
#Preview {
AddBookmarkView()
}
}

View File

@ -50,8 +50,7 @@ class AddBookmarkViewModel {
// Optional: Zeige die Server-Nachricht an
print("Server response: \(message)")
showSuccessAlert = true
clearForm()
} catch let error as CreateBookmarkError {
errorMessage = error.localizedDescription
showErrorAlert = true
@ -77,4 +76,10 @@ class AddBookmarkViewModel {
guard let clipboardURL = clipboardURL else { return }
url = clipboardURL
}
}
func clearForm() {
url = ""
title = ""
labelsText = ""
}
}

View File

@ -7,6 +7,10 @@ struct BookmarksView: View {
@State private var isScrolling = false
let state: BookmarkState
@State private var showingAddBookmarkFromShare = false
@State private var shareURL = ""
@State private var shareTitle = ""
var body: some View {
NavigationView {
ZStack {
@ -59,7 +63,7 @@ struct BookmarksView: View {
}
.navigationTitle(state.displayName)
.sheet(isPresented: $showingAddBookmark) {
AddBookmarkView()
AddBookmarkView(prefilledURL: shareURL, prefilledTitle: shareTitle)
}
.alert("Fehler", isPresented: .constant(viewModel.errorMessage != nil)) {
Button("OK", role: .cancel) {
@ -79,6 +83,9 @@ struct BookmarksView: View {
}
}
}
.onReceive(NotificationCenter.default.publisher(for: NSNotification.Name("AddBookmarkFromShare"))) { notification in
handleShareNotification(notification)
}
}
.overlay {
// Animated FAB Button
@ -107,6 +114,20 @@ struct BookmarksView: View {
}
}
}
private func handleShareNotification(_ notification: Notification) {
guard let userInfo = notification.userInfo,
let url = userInfo["url"] as? String,
!url.isEmpty else {
return
}
shareURL = url
shareTitle = userInfo["title"] as? String ?? ""
showingAddBookmark = true
print("Received share notification - URL: \(url), Title: \(shareTitle)")
}
}
// Helper für Scroll-Tracking

View File

@ -1,4 +1,5 @@
import Foundation
import Combine
@Observable
class BookmarksViewModel {
@ -10,10 +11,40 @@ class BookmarksViewModel {
var isLoading = false
var errorMessage: String?
var currentState: BookmarkState = .unread
var showingAddBookmarkFromShare = false
var shareURL = ""
var shareTitle = ""
private var cancellables = Set<AnyCancellable>()
init() {
setupNotificationObserver()
}
private func setupNotificationObserver() {
NotificationCenter.default
.publisher(for: NSNotification.Name("AddBookmarkFromShare"))
.sink { [weak self] notification in
self?.handleShareNotification(notification)
}
.store(in: &cancellables)
}
private func handleShareNotification(_ notification: Notification) {
guard let userInfo = notification.userInfo,
let url = userInfo["url"] as? String,
!url.isEmpty else {
return
}
DispatchQueue.main.async {
self.shareURL = url
self.shareTitle = userInfo["title"] as? String ?? ""
self.showingAddBookmarkFromShare = true
}
print("Received share notification - URL: \(url)")
}
@MainActor

View File

@ -1,7 +1,7 @@
import SwiftUI
import Foundation
enum BookmarkState: String, CaseIterable {
enum BookmarkState: String, CaseIterable {
case unread = "unread"
case favorite = "favorite"
case archived = "archived"