diff --git a/BookPlayer.xcodeproj/project.pbxproj b/BookPlayer.xcodeproj/project.pbxproj index 2c91a7dbf..20b9fa20b 100644 --- a/BookPlayer.xcodeproj/project.pbxproj +++ b/BookPlayer.xcodeproj/project.pbxproj @@ -271,6 +271,9 @@ 41F1A204254B09C00043FCF3 /* Themeable in Frameworks */ = {isa = PBXBuildFile; productRef = 41F1A203254B09C00043FCF3 /* Themeable */; }; 41F1A20D254B0A0C0043FCF3 /* Sentry in Frameworks */ = {isa = PBXBuildFile; productRef = 41F1A20C254B0A0C0043FCF3 /* Sentry */; }; 41F1A228254B0C6C0043FCF3 /* ZipArchive in Frameworks */ = {isa = PBXBuildFile; productRef = 41F1A227254B0C6C0043FCF3 /* ZipArchive */; }; + 6A91D2C3E04F1B8A7E62C803 /* ShareImportFailureStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6A91D2C3E04F1B8A7E62C804 /* ShareImportFailureStore.swift */; }; + 7B91D2C3E04F1B8A7E62C805 /* ShareDownloadSupport.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B91D2C3E04F1B8A7E62C806 /* ShareDownloadSupport.swift */; }; + 8C91D2C3E04F1B8A7E62C807 /* ShareCancelStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8C91D2C3E04F1B8A7E62C808 /* ShareCancelStore.swift */; }; 4645F9FD2D1E46AC00A04257 /* SwipeInlineTip.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4645F9FC2D1E46AC00A04257 /* SwipeInlineTip.swift */; }; 465D87522D3195D600A4AA47 /* BookmarksView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 465D87512D3195D600A4AA47 /* BookmarksView.swift */; }; 465D87542D31965100A4AA47 /* BookmarksViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 465D87532D31965100A4AA47 /* BookmarksViewModel.swift */; }; @@ -1782,6 +1785,9 @@ D367F7671FA2A6F000FEDB37 /* Constants.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Constants.swift; sourceTree = ""; }; D6BA8F152A4CA94800C2BD9A /* StorageRowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StorageRowView.swift; sourceTree = ""; }; D6BA8F172A4D66CD00C2BD9A /* StorageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StorageView.swift; sourceTree = ""; }; + 6A91D2C3E04F1B8A7E62C804 /* ShareImportFailureStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareImportFailureStore.swift; sourceTree = ""; }; + 7B91D2C3E04F1B8A7E62C806 /* ShareDownloadSupport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareDownloadSupport.swift; sourceTree = ""; }; + 8C91D2C3E04F1B8A7E62C808 /* ShareCancelStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareCancelStore.swift; sourceTree = ""; }; E1FDA0B60D7BE4CFE100A612 /* IntegrationDisconnectedView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IntegrationDisconnectedView.swift; sourceTree = ""; }; E4D25EF2A5FFF30CF12F2C21 /* PreferencesSyncService.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = PreferencesSyncService.swift; sourceTree = ""; }; E55F466D61AE62A69DE4D371 /* BPNavigation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BPNavigation.swift; sourceTree = ""; }; @@ -2574,6 +2580,9 @@ 9FDDD2E0289BFCE20020C428 /* LibraryService+Sync.swift */, 41EB071A2752FA6B00EFEE13 /* PlaybackService.swift */, 63344C012EA7097D00B90DF7 /* DatabaseBackupService.swift */, + 6A91D2C3E04F1B8A7E62C804 /* ShareImportFailureStore.swift */, + 7B91D2C3E04F1B8A7E62C806 /* ShareDownloadSupport.swift */, + 8C91D2C3E04F1B8A7E62C808 /* ShareCancelStore.swift */, 63125D102C36D96800D35533 /* Events */, 9FC1E4612814F68F00522FA8 /* Account */, 9FD8FE4C286566FF00EB2C3D /* Sync */, @@ -4927,6 +4936,9 @@ FF95F8C692908D744A32D761 /* PreferencesAPI.swift in Sources */, A13B98C548AA6BFD0E6FC0C7 /* PreferencesSyncService.swift in Sources */, 52AA441379FD94339FEECB55 /* SortPreferencesResolving.swift in Sources */, + 6A91D2C3E04F1B8A7E62C803 /* ShareImportFailureStore.swift in Sources */, + 7B91D2C3E04F1B8A7E62C805 /* ShareDownloadSupport.swift in Sources */, + 8C91D2C3E04F1B8A7E62C807 /* ShareCancelStore.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/BookPlayer/AppDelegate.swift b/BookPlayer/AppDelegate.swift index 04c77fba9..81e2da179 100644 --- a/BookPlayer/AppDelegate.swift +++ b/BookPlayer/AppDelegate.swift @@ -79,9 +79,30 @@ class AppDelegate: UIResponder, UIApplicationDelegate, BPLogger { // Setup core services AppServices.shared.setupCoreServices() + /// Hook the URLSession delegate that handles share-extension background downloads. + /// Touching the lazy session re-attaches us as delegate for any transfer iOS already + /// has in flight from a prior share — without this, completions queued before the app + /// launched would be silently dropped. + BackgroundShareDownloadDelegate.shared.ensureSessionReady() + return true } + func application( + _ application: UIApplication, + handleEventsForBackgroundURLSession identifier: String, + completionHandler: @escaping () -> Void + ) { + /// iOS calls this when a background URLSession we own has events to deliver and the app + /// was suspended/terminated. We only own the share-extension session; ignore others. + guard identifier == Constants.shareExtensionBackgroundSessionIdentifier else { + completionHandler() + return + } + BackgroundShareDownloadDelegate.shared.backgroundCompletionHandler = completionHandler + BackgroundShareDownloadDelegate.shared.ensureSessionReady() + } + func application( _ application: UIApplication, handle intent: INIntent, @@ -468,3 +489,139 @@ extension AppDelegate { queue.addOperation(backupOperation) } } + +/// Receives completion events for the background `URLSession` that the share extension +/// uses to download shared web URLs. The share extension cannot keep its own foreground +/// session alive after dismissal, so it kicks off the transfer with a background session +/// keyed by `Constants.shareExtensionBackgroundSessionIdentifier`. When the download +/// completes, iOS launches BookPlayer (in the background if needed) and routes events +/// here. The delegate moves the temp file into the app group's shared folder, where +/// BookPlayer's normal `ImportManager` pipeline picks it up the next time the user +/// foregrounds the app. +final class BackgroundShareDownloadDelegate: NSObject, URLSessionDownloadDelegate, BPLogger { + static let shared = BackgroundShareDownloadDelegate() + + /// Stored from `application(_:handleEventsForBackgroundURLSession:completionHandler:)` — + /// must be invoked once `urlSessionDidFinishEvents` fires so iOS can take a fresh app + /// snapshot. + var backgroundCompletionHandler: (() -> Void)? + + /// The session is recreated lazily with the same identifier the share extension uses, + /// which connects this delegate to any in-flight transfers iOS is already running for us. + private(set) lazy var session: URLSession = { + let config = URLSessionConfiguration.background( + withIdentifier: Constants.shareExtensionBackgroundSessionIdentifier + ) + config.sharedContainerIdentifier = Constants.ApplicationGroupIdentifier + config.sessionSendsLaunchEvents = true + config.isDiscretionary = false + return URLSession(configuration: config, delegate: self, delegateQueue: nil) + }() + + /// Touch the lazy session so it hooks up as the delegate for any pending transfers. Call + /// from `AppDelegate.didFinishLaunching` so completions don't get lost when the app cold- + /// starts after a download finished while the app was suspended. + func ensureSessionReady() { + _ = session + } + + // MARK: - URLSessionDownloadDelegate + + func urlSession( + _ session: URLSession, + downloadTask: URLSessionDownloadTask, + didFinishDownloadingTo location: URL + ) { + let originalURL = downloadTask.originalRequest?.url + let source = originalURL?.absoluteString ?? "shared file" + + // If the user canceled this share before iOS finished the download, drop the temp + // file and bail out. Important for the case where the share extension was killed + // before its in-memory `task.cancel()` could propagate — the durable cancel marker + // in ShareCancelStore is the only signal left to honor the user's intent. + if let shareID = downloadTask.taskDescription, ShareCancelStore.isCanceled(shareID) { + Self.logger.info("share download \(source) canceled by user; dropping temp file") + try? FileManager.default.removeItem(at: location) + ShareCancelStore.clear(shareID) + return + } + + /// Reject non-2xx HTTP responses — `URLSession` reports success on a 404 and the temp + /// file just contains the error body. Don't deposit garbage into the library, and + /// surface the failure to the user (via the share-import failure store) so they know + /// the share didn't actually succeed. + if let httpResponse = downloadTask.response as? HTTPURLResponse, + !(200..<300).contains(httpResponse.statusCode) + { + Self.logger.error( + "share download \(source) returned status \(httpResponse.statusCode)" + ) + ShareImportFailureStore.append( + ShareImportFailure( + source: source, + message: String( + format: "share_import_failure_http_status".localized, + httpResponse.statusCode + ) + ) + ) + return + } + + let suggested = + downloadTask.response?.suggestedFilename + ?? originalURL?.lastPathComponent + ?? "shared-\(UUID().uuidString)" + let filename = ShareDownloadSupport.sanitizedFilename(suggested) + let mime = (downloadTask.response as? HTTPURLResponse)?.mimeType?.lowercased() + + if let rejection = ShareDownloadSupport.rejectionReason(forMIME: mime, filename: filename) { + Self.logger.error("share download \(source) rejected: \(rejection)") + ShareImportFailureStore.append( + ShareImportFailure(source: source, message: rejection) + ) + try? FileManager.default.removeItem(at: location) + return + } + + // Avoid collisions from concurrent shares of the same URL by prefixing a short UUID. + let destinationURL = DataManager.getSharedFilesFolderURL() + .appendingPathComponent(ShareDownloadSupport.uniqueDestinationName(for: filename)) + do { + try FileManager.default.moveItem(at: location, to: destinationURL) + Self.logger.info("share download landed at \(destinationURL.lastPathComponent)") + } catch { + Self.logger.error("share download move failed: \(error.localizedDescription)") + ShareImportFailureStore.append( + ShareImportFailure( + source: filename, + message: String( + format: "share_import_failure_move_failed".localized, + filename, + error.localizedDescription + ) + ) + ) + } + } + + func urlSession( + _ session: URLSession, + task: URLSessionTask, + didCompleteWithError error: Error? + ) { + if let error { + Self.logger.error( + "share download task error: \(error.localizedDescription) for \(task.originalRequest?.url?.absoluteString ?? "?")" + ) + } + } + + func urlSessionDidFinishEvents(forBackgroundURLSession session: URLSession) { + DispatchQueue.main.async { [weak self] in + let handler = self?.backgroundCompletionHandler + self?.backgroundCompletionHandler = nil + handler?() + } + } +} diff --git a/BookPlayer/Base.lproj/Localizable.strings b/BookPlayer/Base.lproj/Localizable.strings index b72a17a88..f036349bf 100644 --- a/BookPlayer/Base.lproj/Localizable.strings +++ b/BookPlayer/Base.lproj/Localizable.strings @@ -347,6 +347,13 @@ We're working hard on providing a seamless experience, if possible, please conta "integration_connection_details_title" = "Connection Details"; "integration_connect_button" = "Connect"; "integration_sign_in_button" = "Sign In"; +"share_import_failure_alert_title" = "Some shared files didn’t import"; +"share_import_failure_copy_failed" = "Couldn’t copy “%1$@” to BookPlayer: %2$@"; +"share_import_failure_move_failed" = "Couldn’t save “%1$@” to BookPlayer: %2$@"; +"share_import_failure_http_status" = "Download failed with HTTP status %d."; +"share_import_failure_unsupported_type" = "Download returned content type “%@”, which isn’t an audiobook file. Try the direct download link."; +"share_import_failure_unsupported_extension" = "Download did not look like an audiobook file (no recognized extension on “%@”). Try the direct download link."; +"share_import_failure_announcement_multiple" = "%d shared imports failed."; "integration_retry_button" = "Retry"; "integration_section_server_url" = "Server URL"; "integration_section_server_url_footer" = "Connect to your %@ server"; diff --git a/BookPlayer/Library/ItemList/LibraryRootView.swift b/BookPlayer/Library/ItemList/LibraryRootView.swift index 9f7179b20..d7b117209 100644 --- a/BookPlayer/Library/ItemList/LibraryRootView.swift +++ b/BookPlayer/Library/ItemList/LibraryRootView.swift @@ -22,6 +22,11 @@ struct LibraryRootView: View { @State private var importOperationState = ImportOperationState() @State private var loadingState = LoadingOverlayState() + /// Failures recovered from `ShareImportFailureStore` on foreground — drained once per scene + /// activation, surfaced in an alert plus a VoiceOver announcement. Cleared when the user + /// dismisses the alert. + @State private var pendingShareImportFailures: [ShareImportFailure] = [] + @StateObject private var documentFolderWatcher = DirectoryWatcher.watch( DataManager.getDocumentsFolderURL(), ignoreDirectories: false @@ -93,7 +98,21 @@ struct LibraryRootView: View { .onChange(of: scenePhase) { guard scenePhase == .active else { return } showImport() + drainShareImportFailures() } + .alert( + "share_import_failure_alert_title".localized, + isPresented: Binding( + get: { !pendingShareImportFailures.isEmpty }, + set: { if !$0 { pendingShareImportFailures = [] } } + ), + actions: { + Button("ok_button".localized) { pendingShareImportFailures = [] } + }, + message: { + Text(pendingShareImportFailures.map { "• \($0.message)" }.joined(separator: "\n")) + } + ) .onReceive(syncService.downloadErrorPublisher) { (relativePath, error) in let errorMessage = "\(relativePath)\n\(error.localizedDescription)" loadingState.error = BookPlayerError.networkError(errorMessage) @@ -144,6 +163,28 @@ struct LibraryRootView: View { } } + /// Drain share-import failures that piled up while the app wasn't foregrounded — these come + /// from the share extension's synchronous copy errors and the main app's background-download + /// delegate move/HTTP errors. We surface them via the alert (visible) and a VoiceOver + /// announcement so a blind user knows the share didn't actually succeed. + func drainShareImportFailures() { + let failures = ShareImportFailureStore.drain() + guard !failures.isEmpty else { return } + pendingShareImportFailures = failures + + let announcement: String = { + if failures.count == 1 { + return failures[0].message + } + let summary = String( + format: "share_import_failure_announcement_multiple".localized, + failures.count + ) + return summary + " " + failures.map(\.message).joined(separator: ". ") + }() + UIAccessibility.post(notification: .announcement, argument: announcement) + } + func loadLastBookIfNeeded() async { guard playerManager.currentItem == nil, diff --git a/BookPlayer/ar.lproj/Localizable.strings b/BookPlayer/ar.lproj/Localizable.strings index 9ab60be25..1082b3318 100644 --- a/BookPlayer/ar.lproj/Localizable.strings +++ b/BookPlayer/ar.lproj/Localizable.strings @@ -346,6 +346,13 @@ "integration_connection_details_title" = "تفاصيل الاتصال"; "integration_connect_button" = "الاتصال"; "integration_sign_in_button" = "تسجيل الدخول"; +"share_import_failure_alert_title" = "Some shared files didn’t import"; +"share_import_failure_copy_failed" = "Couldn’t copy “%1$@” to BookPlayer: %2$@"; +"share_import_failure_move_failed" = "Couldn’t save “%1$@” to BookPlayer: %2$@"; +"share_import_failure_http_status" = "Download failed with HTTP status %d."; +"share_import_failure_unsupported_type" = "Download returned content type “%@”, which isn’t an audiobook file. Try the direct download link."; +"share_import_failure_unsupported_extension" = "Download did not look like an audiobook file (no recognized extension on “%@”). Try the direct download link."; +"share_import_failure_announcement_multiple" = "%d shared imports failed."; "integration_retry_button" = "إعادة المحاولة"; "integration_add_server_button" = "إضافة خادم"; "media_servers_title" = "خوادم الوسائط"; diff --git a/BookPlayer/ca.lproj/Localizable.strings b/BookPlayer/ca.lproj/Localizable.strings index 9ddc6915d..39c138053 100644 --- a/BookPlayer/ca.lproj/Localizable.strings +++ b/BookPlayer/ca.lproj/Localizable.strings @@ -346,6 +346,13 @@ Estem treballant dur per oferir una experiència perfecta; si és possible, pose "integration_connection_details_title" = "Detalls de connexió"; "integration_connect_button" = "Connecta't"; "integration_sign_in_button" = "Inicieu la sessió"; +"share_import_failure_alert_title" = "Some shared files didn’t import"; +"share_import_failure_copy_failed" = "Couldn’t copy “%1$@” to BookPlayer: %2$@"; +"share_import_failure_move_failed" = "Couldn’t save “%1$@” to BookPlayer: %2$@"; +"share_import_failure_http_status" = "Download failed with HTTP status %d."; +"share_import_failure_unsupported_type" = "Download returned content type “%@”, which isn’t an audiobook file. Try the direct download link."; +"share_import_failure_unsupported_extension" = "Download did not look like an audiobook file (no recognized extension on “%@”). Try the direct download link."; +"share_import_failure_announcement_multiple" = "%d shared imports failed."; "integration_retry_button" = "Torna-ho a provar"; "integration_add_server_button" = "Afegeix un servidor"; "media_servers_title" = "Servidors multimèdia"; diff --git a/BookPlayer/cs.lproj/Localizable.strings b/BookPlayer/cs.lproj/Localizable.strings index bb6dc6339..793842d84 100644 --- a/BookPlayer/cs.lproj/Localizable.strings +++ b/BookPlayer/cs.lproj/Localizable.strings @@ -345,6 +345,13 @@ "integration_connection_details_title" = "Podrobnosti o připojení"; "integration_connect_button" = "Připojit"; "integration_sign_in_button" = "Přihlaste se"; +"share_import_failure_alert_title" = "Some shared files didn’t import"; +"share_import_failure_copy_failed" = "Couldn’t copy “%1$@” to BookPlayer: %2$@"; +"share_import_failure_move_failed" = "Couldn’t save “%1$@” to BookPlayer: %2$@"; +"share_import_failure_http_status" = "Download failed with HTTP status %d."; +"share_import_failure_unsupported_type" = "Download returned content type “%@”, which isn’t an audiobook file. Try the direct download link."; +"share_import_failure_unsupported_extension" = "Download did not look like an audiobook file (no recognized extension on “%@”). Try the direct download link."; +"share_import_failure_announcement_multiple" = "%d shared imports failed."; "integration_retry_button" = "Zkusit znovu"; "integration_add_server_button" = "Přidat server"; "media_servers_title" = "Mediální servery"; diff --git a/BookPlayer/da.lproj/Localizable.strings b/BookPlayer/da.lproj/Localizable.strings index 04c1c2c89..baf61d098 100644 --- a/BookPlayer/da.lproj/Localizable.strings +++ b/BookPlayer/da.lproj/Localizable.strings @@ -345,6 +345,13 @@ "integration_connection_details_title" = "Tilslutningsdetaljer"; "integration_connect_button" = "Forbinde"; "integration_sign_in_button" = "Log ind"; +"share_import_failure_alert_title" = "Some shared files didn’t import"; +"share_import_failure_copy_failed" = "Couldn’t copy “%1$@” to BookPlayer: %2$@"; +"share_import_failure_move_failed" = "Couldn’t save “%1$@” to BookPlayer: %2$@"; +"share_import_failure_http_status" = "Download failed with HTTP status %d."; +"share_import_failure_unsupported_type" = "Download returned content type “%@”, which isn’t an audiobook file. Try the direct download link."; +"share_import_failure_unsupported_extension" = "Download did not look like an audiobook file (no recognized extension on “%@”). Try the direct download link."; +"share_import_failure_announcement_multiple" = "%d shared imports failed."; "integration_retry_button" = "Prøv igen"; "integration_add_server_button" = "Tilføj server"; "media_servers_title" = "Medieservere"; diff --git a/BookPlayer/de.lproj/Localizable.strings b/BookPlayer/de.lproj/Localizable.strings index 29d3a6b5a..d0a8836f2 100644 --- a/BookPlayer/de.lproj/Localizable.strings +++ b/BookPlayer/de.lproj/Localizable.strings @@ -345,6 +345,13 @@ "integration_connection_details_title" = "Verbindungsdetails"; "integration_connect_button" = "Verbinden"; "integration_sign_in_button" = "Anmelden"; +"share_import_failure_alert_title" = "Some shared files didn’t import"; +"share_import_failure_copy_failed" = "Couldn’t copy “%1$@” to BookPlayer: %2$@"; +"share_import_failure_move_failed" = "Couldn’t save “%1$@” to BookPlayer: %2$@"; +"share_import_failure_http_status" = "Download failed with HTTP status %d."; +"share_import_failure_unsupported_type" = "Download returned content type “%@”, which isn’t an audiobook file. Try the direct download link."; +"share_import_failure_unsupported_extension" = "Download did not look like an audiobook file (no recognized extension on “%@”). Try the direct download link."; +"share_import_failure_announcement_multiple" = "%d shared imports failed."; "integration_retry_button" = "Erneut versuchen"; "integration_add_server_button" = "Server hinzufügen"; "media_servers_title" = "Medienserver"; diff --git a/BookPlayer/el.lproj/Localizable.strings b/BookPlayer/el.lproj/Localizable.strings index 9e64993cd..dd0f3b5b9 100644 --- a/BookPlayer/el.lproj/Localizable.strings +++ b/BookPlayer/el.lproj/Localizable.strings @@ -346,6 +346,13 @@ "integration_connection_details_title" = "Στοιχεία σύνδεσης"; "integration_connect_button" = "Συνδέω"; "integration_sign_in_button" = "Είσοδος"; +"share_import_failure_alert_title" = "Some shared files didn’t import"; +"share_import_failure_copy_failed" = "Couldn’t copy “%1$@” to BookPlayer: %2$@"; +"share_import_failure_move_failed" = "Couldn’t save “%1$@” to BookPlayer: %2$@"; +"share_import_failure_http_status" = "Download failed with HTTP status %d."; +"share_import_failure_unsupported_type" = "Download returned content type “%@”, which isn’t an audiobook file. Try the direct download link."; +"share_import_failure_unsupported_extension" = "Download did not look like an audiobook file (no recognized extension on “%@”). Try the direct download link."; +"share_import_failure_announcement_multiple" = "%d shared imports failed."; "integration_retry_button" = "Επανάληψη"; "integration_add_server_button" = "Προσθήκη διακομιστή"; "media_servers_title" = "Διακομιστές πολυμέσων"; diff --git a/BookPlayer/en.lproj/Localizable.strings b/BookPlayer/en.lproj/Localizable.strings index f5ee30f3a..14c1729bc 100644 --- a/BookPlayer/en.lproj/Localizable.strings +++ b/BookPlayer/en.lproj/Localizable.strings @@ -348,6 +348,13 @@ We're working hard on providing a seamless experience, if possible, please conta "integration_connection_details_title" = "Connection Details"; "integration_connect_button" = "Connect"; "integration_sign_in_button" = "Sign In"; +"share_import_failure_alert_title" = "Some shared files didn’t import"; +"share_import_failure_copy_failed" = "Couldn’t copy “%1$@” to BookPlayer: %2$@"; +"share_import_failure_move_failed" = "Couldn’t save “%1$@” to BookPlayer: %2$@"; +"share_import_failure_http_status" = "Download failed with HTTP status %d."; +"share_import_failure_unsupported_type" = "Download returned content type “%@”, which isn’t an audiobook file. Try the direct download link."; +"share_import_failure_unsupported_extension" = "Download did not look like an audiobook file (no recognized extension on “%@”). Try the direct download link."; +"share_import_failure_announcement_multiple" = "%d shared imports failed."; "integration_retry_button" = "Retry"; "integration_section_server_url" = "Server URL"; "integration_section_server_url_footer" = "Connect to your %@ server"; diff --git a/BookPlayer/es.lproj/Localizable.strings b/BookPlayer/es.lproj/Localizable.strings index 8232a0f7e..491a3c0c1 100644 --- a/BookPlayer/es.lproj/Localizable.strings +++ b/BookPlayer/es.lproj/Localizable.strings @@ -345,6 +345,13 @@ "integration_connection_details_title" = "Detalles de la conexión"; "integration_connect_button" = "Conectar"; "integration_sign_in_button" = "Iniciar sesión"; +"share_import_failure_alert_title" = "Some shared files didn’t import"; +"share_import_failure_copy_failed" = "Couldn’t copy “%1$@” to BookPlayer: %2$@"; +"share_import_failure_move_failed" = "Couldn’t save “%1$@” to BookPlayer: %2$@"; +"share_import_failure_http_status" = "Download failed with HTTP status %d."; +"share_import_failure_unsupported_type" = "Download returned content type “%@”, which isn’t an audiobook file. Try the direct download link."; +"share_import_failure_unsupported_extension" = "Download did not look like an audiobook file (no recognized extension on “%@”). Try the direct download link."; +"share_import_failure_announcement_multiple" = "%d shared imports failed."; "integration_retry_button" = "Reintentar"; "integration_add_server_button" = "Añadir servidor"; "media_servers_title" = "Servidores multimedia"; diff --git a/BookPlayer/fi.lproj/Localizable.strings b/BookPlayer/fi.lproj/Localizable.strings index a68bb4f57..dd7d4f2be 100644 --- a/BookPlayer/fi.lproj/Localizable.strings +++ b/BookPlayer/fi.lproj/Localizable.strings @@ -345,6 +345,13 @@ "integration_connection_details_title" = "Yhteyden tiedot"; "integration_connect_button" = "Yhdistä"; "integration_sign_in_button" = "Kirjaudu sisään"; +"share_import_failure_alert_title" = "Some shared files didn’t import"; +"share_import_failure_copy_failed" = "Couldn’t copy “%1$@” to BookPlayer: %2$@"; +"share_import_failure_move_failed" = "Couldn’t save “%1$@” to BookPlayer: %2$@"; +"share_import_failure_http_status" = "Download failed with HTTP status %d."; +"share_import_failure_unsupported_type" = "Download returned content type “%@”, which isn’t an audiobook file. Try the direct download link."; +"share_import_failure_unsupported_extension" = "Download did not look like an audiobook file (no recognized extension on “%@”). Try the direct download link."; +"share_import_failure_announcement_multiple" = "%d shared imports failed."; "integration_retry_button" = "Yritä uudelleen"; "integration_add_server_button" = "Lisää palvelin"; "media_servers_title" = "Mediapalvelimet"; diff --git a/BookPlayer/fr.lproj/Localizable.strings b/BookPlayer/fr.lproj/Localizable.strings index dda5e0da9..10c55a353 100644 --- a/BookPlayer/fr.lproj/Localizable.strings +++ b/BookPlayer/fr.lproj/Localizable.strings @@ -345,6 +345,13 @@ "integration_connection_details_title" = "Détails de connexion"; "integration_connect_button" = "Connecter"; "integration_sign_in_button" = "Se connecter"; +"share_import_failure_alert_title" = "Some shared files didn’t import"; +"share_import_failure_copy_failed" = "Couldn’t copy “%1$@” to BookPlayer: %2$@"; +"share_import_failure_move_failed" = "Couldn’t save “%1$@” to BookPlayer: %2$@"; +"share_import_failure_http_status" = "Download failed with HTTP status %d."; +"share_import_failure_unsupported_type" = "Download returned content type “%@”, which isn’t an audiobook file. Try the direct download link."; +"share_import_failure_unsupported_extension" = "Download did not look like an audiobook file (no recognized extension on “%@”). Try the direct download link."; +"share_import_failure_announcement_multiple" = "%d shared imports failed."; "integration_retry_button" = "Réessayer"; "integration_add_server_button" = "Ajouter un serveur"; "media_servers_title" = "Serveurs multimédias"; diff --git a/BookPlayer/hu.lproj/Localizable.strings b/BookPlayer/hu.lproj/Localizable.strings index 4cc441baa..12f5aacd8 100644 --- a/BookPlayer/hu.lproj/Localizable.strings +++ b/BookPlayer/hu.lproj/Localizable.strings @@ -346,6 +346,13 @@ "integration_connection_details_title" = "Csatlakozás részletei"; "integration_connect_button" = "Csatlakozás"; "integration_sign_in_button" = "Bejelentkezés"; +"share_import_failure_alert_title" = "Some shared files didn’t import"; +"share_import_failure_copy_failed" = "Couldn’t copy “%1$@” to BookPlayer: %2$@"; +"share_import_failure_move_failed" = "Couldn’t save “%1$@” to BookPlayer: %2$@"; +"share_import_failure_http_status" = "Download failed with HTTP status %d."; +"share_import_failure_unsupported_type" = "Download returned content type “%@”, which isn’t an audiobook file. Try the direct download link."; +"share_import_failure_unsupported_extension" = "Download did not look like an audiobook file (no recognized extension on “%@”). Try the direct download link."; +"share_import_failure_announcement_multiple" = "%d shared imports failed."; "integration_retry_button" = "Újrapróbálkozás"; "integration_add_server_button" = "Kiszolgáló hozzáadása"; "media_servers_title" = "Médiakiszolgálók"; diff --git a/BookPlayer/it.lproj/Localizable.strings b/BookPlayer/it.lproj/Localizable.strings index c18701ee7..9b6433eef 100644 --- a/BookPlayer/it.lproj/Localizable.strings +++ b/BookPlayer/it.lproj/Localizable.strings @@ -345,6 +345,13 @@ "integration_connection_details_title" = "Dettagli di connessione"; "integration_connect_button" = "Collegare"; "integration_sign_in_button" = "Registrazione"; +"share_import_failure_alert_title" = "Some shared files didn’t import"; +"share_import_failure_copy_failed" = "Couldn’t copy “%1$@” to BookPlayer: %2$@"; +"share_import_failure_move_failed" = "Couldn’t save “%1$@” to BookPlayer: %2$@"; +"share_import_failure_http_status" = "Download failed with HTTP status %d."; +"share_import_failure_unsupported_type" = "Download returned content type “%@”, which isn’t an audiobook file. Try the direct download link."; +"share_import_failure_unsupported_extension" = "Download did not look like an audiobook file (no recognized extension on “%@”). Try the direct download link."; +"share_import_failure_announcement_multiple" = "%d shared imports failed."; "integration_retry_button" = "Riprova"; "integration_add_server_button" = "Aggiungi server"; "media_servers_title" = "Server multimediali"; diff --git a/BookPlayer/ja.lproj/Localizable.strings b/BookPlayer/ja.lproj/Localizable.strings index 86d470319..2aba8a824 100644 --- a/BookPlayer/ja.lproj/Localizable.strings +++ b/BookPlayer/ja.lproj/Localizable.strings @@ -346,6 +346,13 @@ "integration_connection_details_title" = "接続の詳細"; "integration_connect_button" = "接続"; "integration_sign_in_button" = "サインイン"; +"share_import_failure_alert_title" = "Some shared files didn’t import"; +"share_import_failure_copy_failed" = "Couldn’t copy “%1$@” to BookPlayer: %2$@"; +"share_import_failure_move_failed" = "Couldn’t save “%1$@” to BookPlayer: %2$@"; +"share_import_failure_http_status" = "Download failed with HTTP status %d."; +"share_import_failure_unsupported_type" = "Download returned content type “%@”, which isn’t an audiobook file. Try the direct download link."; +"share_import_failure_unsupported_extension" = "Download did not look like an audiobook file (no recognized extension on “%@”). Try the direct download link."; +"share_import_failure_announcement_multiple" = "%d shared imports failed."; "integration_retry_button" = "再試行"; "integration_add_server_button" = "サーバーを追加"; "media_servers_title" = "メディアサーバー"; diff --git a/BookPlayer/nb.lproj/Localizable.strings b/BookPlayer/nb.lproj/Localizable.strings index a6d0d4fba..6e6ee156f 100644 --- a/BookPlayer/nb.lproj/Localizable.strings +++ b/BookPlayer/nb.lproj/Localizable.strings @@ -345,6 +345,13 @@ Vi jobber hardt for å gi deg en sømløs opplevelse. Hvis mulig, kontakt oss p "integration_connection_details_title" = "Tilkoblingsdetaljer"; "integration_connect_button" = "Koble til"; "integration_sign_in_button" = "Logg på"; +"share_import_failure_alert_title" = "Some shared files didn’t import"; +"share_import_failure_copy_failed" = "Couldn’t copy “%1$@” to BookPlayer: %2$@"; +"share_import_failure_move_failed" = "Couldn’t save “%1$@” to BookPlayer: %2$@"; +"share_import_failure_http_status" = "Download failed with HTTP status %d."; +"share_import_failure_unsupported_type" = "Download returned content type “%@”, which isn’t an audiobook file. Try the direct download link."; +"share_import_failure_unsupported_extension" = "Download did not look like an audiobook file (no recognized extension on “%@”). Try the direct download link."; +"share_import_failure_announcement_multiple" = "%d shared imports failed."; "integration_retry_button" = "Prøv igjen"; "integration_add_server_button" = "Legg til server"; "media_servers_title" = "Medieservere"; diff --git a/BookPlayer/nl.lproj/Localizable.strings b/BookPlayer/nl.lproj/Localizable.strings index a54b740dd..17e442fda 100644 --- a/BookPlayer/nl.lproj/Localizable.strings +++ b/BookPlayer/nl.lproj/Localizable.strings @@ -345,6 +345,13 @@ "integration_connection_details_title" = "Verbindingsgegevens"; "integration_connect_button" = "Verbinden"; "integration_sign_in_button" = "Aanmelden"; +"share_import_failure_alert_title" = "Some shared files didn’t import"; +"share_import_failure_copy_failed" = "Couldn’t copy “%1$@” to BookPlayer: %2$@"; +"share_import_failure_move_failed" = "Couldn’t save “%1$@” to BookPlayer: %2$@"; +"share_import_failure_http_status" = "Download failed with HTTP status %d."; +"share_import_failure_unsupported_type" = "Download returned content type “%@”, which isn’t an audiobook file. Try the direct download link."; +"share_import_failure_unsupported_extension" = "Download did not look like an audiobook file (no recognized extension on “%@”). Try the direct download link."; +"share_import_failure_announcement_multiple" = "%d shared imports failed."; "integration_retry_button" = "Opnieuw proberen"; "integration_add_server_button" = "Server toevoegen"; "media_servers_title" = "Mediaservers"; diff --git a/BookPlayer/pl.lproj/Localizable.strings b/BookPlayer/pl.lproj/Localizable.strings index 6d027493d..011d19a43 100644 --- a/BookPlayer/pl.lproj/Localizable.strings +++ b/BookPlayer/pl.lproj/Localizable.strings @@ -345,6 +345,13 @@ "integration_connection_details_title" = "Szczegóły połączenia"; "integration_connect_button" = "Łączyć"; "integration_sign_in_button" = "Zalogować się"; +"share_import_failure_alert_title" = "Some shared files didn’t import"; +"share_import_failure_copy_failed" = "Couldn’t copy “%1$@” to BookPlayer: %2$@"; +"share_import_failure_move_failed" = "Couldn’t save “%1$@” to BookPlayer: %2$@"; +"share_import_failure_http_status" = "Download failed with HTTP status %d."; +"share_import_failure_unsupported_type" = "Download returned content type “%@”, which isn’t an audiobook file. Try the direct download link."; +"share_import_failure_unsupported_extension" = "Download did not look like an audiobook file (no recognized extension on “%@”). Try the direct download link."; +"share_import_failure_announcement_multiple" = "%d shared imports failed."; "integration_retry_button" = "Spróbuj ponownie"; "integration_add_server_button" = "Dodaj serwer"; "media_servers_title" = "Serwery multimediów"; diff --git a/BookPlayer/pt-BR.lproj/Localizable.strings b/BookPlayer/pt-BR.lproj/Localizable.strings index a670f5ebe..7cae94dcf 100644 --- a/BookPlayer/pt-BR.lproj/Localizable.strings +++ b/BookPlayer/pt-BR.lproj/Localizable.strings @@ -345,6 +345,13 @@ "integration_connection_details_title" = "Detalhes da conexão"; "integration_connect_button" = "Conectar"; "integration_sign_in_button" = "Entrar"; +"share_import_failure_alert_title" = "Some shared files didn’t import"; +"share_import_failure_copy_failed" = "Couldn’t copy “%1$@” to BookPlayer: %2$@"; +"share_import_failure_move_failed" = "Couldn’t save “%1$@” to BookPlayer: %2$@"; +"share_import_failure_http_status" = "Download failed with HTTP status %d."; +"share_import_failure_unsupported_type" = "Download returned content type “%@”, which isn’t an audiobook file. Try the direct download link."; +"share_import_failure_unsupported_extension" = "Download did not look like an audiobook file (no recognized extension on “%@”). Try the direct download link."; +"share_import_failure_announcement_multiple" = "%d shared imports failed."; "integration_retry_button" = "Tentar novamente"; "integration_add_server_button" = "Adicionar servidor"; "media_servers_title" = "Servidores de mídia"; diff --git a/BookPlayer/pt-PT.lproj/Localizable.strings b/BookPlayer/pt-PT.lproj/Localizable.strings index efc0e2979..90abb665b 100644 --- a/BookPlayer/pt-PT.lproj/Localizable.strings +++ b/BookPlayer/pt-PT.lproj/Localizable.strings @@ -345,6 +345,13 @@ "integration_connection_details_title" = "Detalhes da conexão"; "integration_connect_button" = "Conectar"; "integration_sign_in_button" = "Entrar"; +"share_import_failure_alert_title" = "Some shared files didn’t import"; +"share_import_failure_copy_failed" = "Couldn’t copy “%1$@” to BookPlayer: %2$@"; +"share_import_failure_move_failed" = "Couldn’t save “%1$@” to BookPlayer: %2$@"; +"share_import_failure_http_status" = "Download failed with HTTP status %d."; +"share_import_failure_unsupported_type" = "Download returned content type “%@”, which isn’t an audiobook file. Try the direct download link."; +"share_import_failure_unsupported_extension" = "Download did not look like an audiobook file (no recognized extension on “%@”). Try the direct download link."; +"share_import_failure_announcement_multiple" = "%d shared imports failed."; "integration_retry_button" = "Tentar novamente"; "integration_add_server_button" = "Adicionar servidor"; "media_servers_title" = "Servidores de multimédia"; diff --git a/BookPlayer/ro.lproj/Localizable.strings b/BookPlayer/ro.lproj/Localizable.strings index 9c1263cbb..9d0a4e8c3 100644 --- a/BookPlayer/ro.lproj/Localizable.strings +++ b/BookPlayer/ro.lproj/Localizable.strings @@ -345,6 +345,13 @@ "integration_connection_details_title" = "Detalii de conectare"; "integration_connect_button" = "Conectați-vă"; "integration_sign_in_button" = "Conectare"; +"share_import_failure_alert_title" = "Some shared files didn’t import"; +"share_import_failure_copy_failed" = "Couldn’t copy “%1$@” to BookPlayer: %2$@"; +"share_import_failure_move_failed" = "Couldn’t save “%1$@” to BookPlayer: %2$@"; +"share_import_failure_http_status" = "Download failed with HTTP status %d."; +"share_import_failure_unsupported_type" = "Download returned content type “%@”, which isn’t an audiobook file. Try the direct download link."; +"share_import_failure_unsupported_extension" = "Download did not look like an audiobook file (no recognized extension on “%@”). Try the direct download link."; +"share_import_failure_announcement_multiple" = "%d shared imports failed."; "integration_retry_button" = "Reîncearcă"; "integration_add_server_button" = "Adaugă server"; "media_servers_title" = "Servere media"; diff --git a/BookPlayer/ru.lproj/Localizable.strings b/BookPlayer/ru.lproj/Localizable.strings index d4efb0a42..c581d18d4 100644 --- a/BookPlayer/ru.lproj/Localizable.strings +++ b/BookPlayer/ru.lproj/Localizable.strings @@ -345,6 +345,13 @@ "integration_connection_details_title" = "Подробности подключения"; "integration_connect_button" = "Подключиться"; "integration_sign_in_button" = "Войти"; +"share_import_failure_alert_title" = "Some shared files didn’t import"; +"share_import_failure_copy_failed" = "Couldn’t copy “%1$@” to BookPlayer: %2$@"; +"share_import_failure_move_failed" = "Couldn’t save “%1$@” to BookPlayer: %2$@"; +"share_import_failure_http_status" = "Download failed with HTTP status %d."; +"share_import_failure_unsupported_type" = "Download returned content type “%@”, which isn’t an audiobook file. Try the direct download link."; +"share_import_failure_unsupported_extension" = "Download did not look like an audiobook file (no recognized extension on “%@”). Try the direct download link."; +"share_import_failure_announcement_multiple" = "%d shared imports failed."; "integration_retry_button" = "Повторить"; "integration_add_server_button" = "Добавить сервер"; "media_servers_title" = "Медиасерверы"; diff --git a/BookPlayer/sk-SK.lproj/Localizable.strings b/BookPlayer/sk-SK.lproj/Localizable.strings index 91cac9226..71e27346f 100644 --- a/BookPlayer/sk-SK.lproj/Localizable.strings +++ b/BookPlayer/sk-SK.lproj/Localizable.strings @@ -346,6 +346,13 @@ Usilovne pracujeme na poskytovaní bezproblémového zážitku, ak je to možné "integration_connection_details_title" = "Podrobnosti pripojenia"; "integration_connect_button" = "Pripojiť sa"; "integration_sign_in_button" = "Prihlásiť sa"; +"share_import_failure_alert_title" = "Some shared files didn’t import"; +"share_import_failure_copy_failed" = "Couldn’t copy “%1$@” to BookPlayer: %2$@"; +"share_import_failure_move_failed" = "Couldn’t save “%1$@” to BookPlayer: %2$@"; +"share_import_failure_http_status" = "Download failed with HTTP status %d."; +"share_import_failure_unsupported_type" = "Download returned content type “%@”, which isn’t an audiobook file. Try the direct download link."; +"share_import_failure_unsupported_extension" = "Download did not look like an audiobook file (no recognized extension on “%@”). Try the direct download link."; +"share_import_failure_announcement_multiple" = "%d shared imports failed."; "integration_retry_button" = "Skúsiť znova"; "integration_add_server_button" = "Pridať server"; "media_servers_title" = "Mediálne servery"; diff --git a/BookPlayer/sv.lproj/Localizable.strings b/BookPlayer/sv.lproj/Localizable.strings index 8c91a1e74..0c49a2b71 100644 --- a/BookPlayer/sv.lproj/Localizable.strings +++ b/BookPlayer/sv.lproj/Localizable.strings @@ -345,6 +345,13 @@ "integration_connection_details_title" = "Anslutningsdetaljer"; "integration_connect_button" = "Ansluta"; "integration_sign_in_button" = "Logga in"; +"share_import_failure_alert_title" = "Some shared files didn’t import"; +"share_import_failure_copy_failed" = "Couldn’t copy “%1$@” to BookPlayer: %2$@"; +"share_import_failure_move_failed" = "Couldn’t save “%1$@” to BookPlayer: %2$@"; +"share_import_failure_http_status" = "Download failed with HTTP status %d."; +"share_import_failure_unsupported_type" = "Download returned content type “%@”, which isn’t an audiobook file. Try the direct download link."; +"share_import_failure_unsupported_extension" = "Download did not look like an audiobook file (no recognized extension on “%@”). Try the direct download link."; +"share_import_failure_announcement_multiple" = "%d shared imports failed."; "integration_retry_button" = "Försök igen"; "integration_add_server_button" = "Lägg till server"; "media_servers_title" = "Medieservrar"; diff --git a/BookPlayer/tr.lproj/Localizable.strings b/BookPlayer/tr.lproj/Localizable.strings index 68c4d71da..7fcd294ff 100644 --- a/BookPlayer/tr.lproj/Localizable.strings +++ b/BookPlayer/tr.lproj/Localizable.strings @@ -345,6 +345,13 @@ "integration_connection_details_title" = "Bağlantı Ayrıntıları"; "integration_connect_button" = "Bağlamak"; "integration_sign_in_button" = "Kayıt olmak"; +"share_import_failure_alert_title" = "Some shared files didn’t import"; +"share_import_failure_copy_failed" = "Couldn’t copy “%1$@” to BookPlayer: %2$@"; +"share_import_failure_move_failed" = "Couldn’t save “%1$@” to BookPlayer: %2$@"; +"share_import_failure_http_status" = "Download failed with HTTP status %d."; +"share_import_failure_unsupported_type" = "Download returned content type “%@”, which isn’t an audiobook file. Try the direct download link."; +"share_import_failure_unsupported_extension" = "Download did not look like an audiobook file (no recognized extension on “%@”). Try the direct download link."; +"share_import_failure_announcement_multiple" = "%d shared imports failed."; "integration_retry_button" = "Yeniden dene"; "integration_add_server_button" = "Sunucu ekle"; "media_servers_title" = "Medya sunucuları"; diff --git a/BookPlayer/uk.lproj/Localizable.strings b/BookPlayer/uk.lproj/Localizable.strings index 93d3bb6dc..a3e93b6c9 100644 --- a/BookPlayer/uk.lproj/Localizable.strings +++ b/BookPlayer/uk.lproj/Localizable.strings @@ -345,6 +345,13 @@ "integration_connection_details_title" = "Деталі підключення"; "integration_connect_button" = "Підключитися"; "integration_sign_in_button" = "Увійти"; +"share_import_failure_alert_title" = "Some shared files didn’t import"; +"share_import_failure_copy_failed" = "Couldn’t copy “%1$@” to BookPlayer: %2$@"; +"share_import_failure_move_failed" = "Couldn’t save “%1$@” to BookPlayer: %2$@"; +"share_import_failure_http_status" = "Download failed with HTTP status %d."; +"share_import_failure_unsupported_type" = "Download returned content type “%@”, which isn’t an audiobook file. Try the direct download link."; +"share_import_failure_unsupported_extension" = "Download did not look like an audiobook file (no recognized extension on “%@”). Try the direct download link."; +"share_import_failure_announcement_multiple" = "%d shared imports failed."; "integration_retry_button" = "Повторити"; "integration_add_server_button" = "Додати сервер"; "media_servers_title" = "Медіасервери"; diff --git a/BookPlayer/zh-Hans.lproj/Localizable.strings b/BookPlayer/zh-Hans.lproj/Localizable.strings index a91e076a7..8ff68a340 100644 --- a/BookPlayer/zh-Hans.lproj/Localizable.strings +++ b/BookPlayer/zh-Hans.lproj/Localizable.strings @@ -345,6 +345,13 @@ "integration_connection_details_title" = "连接详细信息"; "integration_connect_button" = "连接"; "integration_sign_in_button" = "登入"; +"share_import_failure_alert_title" = "Some shared files didn’t import"; +"share_import_failure_copy_failed" = "Couldn’t copy “%1$@” to BookPlayer: %2$@"; +"share_import_failure_move_failed" = "Couldn’t save “%1$@” to BookPlayer: %2$@"; +"share_import_failure_http_status" = "Download failed with HTTP status %d."; +"share_import_failure_unsupported_type" = "Download returned content type “%@”, which isn’t an audiobook file. Try the direct download link."; +"share_import_failure_unsupported_extension" = "Download did not look like an audiobook file (no recognized extension on “%@”). Try the direct download link."; +"share_import_failure_announcement_multiple" = "%d shared imports failed."; "integration_retry_button" = "重试"; "integration_add_server_button" = "添加服务器"; "media_servers_title" = "媒体服务器"; diff --git a/BookPlayerShareExtension/Info.plist b/BookPlayerShareExtension/Info.plist index d3001c53b..cf70421a2 100644 --- a/BookPlayerShareExtension/Info.plist +++ b/BookPlayerShareExtension/Info.plist @@ -20,6 +20,7 @@ || ANY $attachment.registeredTypeIdentifiers UTI-CONFORMS-TO "public.folder" || ANY $attachment.registeredTypeIdentifiers UTI-CONFORMS-TO "public.movie" || ANY $attachment.registeredTypeIdentifiers UTI-CONFORMS-TO "com.pkware.zip-archive" + || ANY $attachment.registeredTypeIdentifiers UTI-CONFORMS-TO "public.url" ) ).@count > 0 ).@count > 0 diff --git a/BookPlayerShareExtension/ShareViewController.swift b/BookPlayerShareExtension/ShareViewController.swift index 71eb10f1f..23e66832a 100644 --- a/BookPlayerShareExtension/ShareViewController.swift +++ b/BookPlayerShareExtension/ShareViewController.swift @@ -90,6 +90,31 @@ class ShareViewController: UIViewController { /// In-memory array of shared items var sharedItems = [URL]() + /// Retained URLSession delegate. Held so iOS can deliver the completion to *this* + /// process if the extension survives long enough — see `kickOffBackgroundDownloads`. + private var backgroundDownloadCoordinator: BackgroundDownloadCoordinator? + + /// Tracks the in-flight background download tasks plus the share IDs we tagged them with. + /// Two paths consume this on cancel: + /// 1. Best-effort `task.cancel()` to abort while the extension still owns control. + /// 2. Write the share IDs into `ShareCancelStore` so the main app's download delegate + /// can still suppress the completion if iOS killed the extension before step 1 took + /// effect — the durable belt to step 1's suspenders. + private var activeDownloadTasks: [(shareID: String, task: URLSessionDownloadTask)] = [] + + /// File extensions BookPlayer can fetch from a remote `http(s)` URL. + /// + /// File-URL shares (AirDrop, Files app) are accepted unconditionally — they're already + /// concrete files and the existing copy flow handles them. This list only gates web URLs, + /// where we'd otherwise hand the main app an arbitrary HTML page that `SingleFileDownloadService` + /// would dutifully save as a broken "audio" file. + static let supportedRemoteFileExtensions: Set = [ + "mp3", "m4a", "m4b", "aac", "flac", "ogg", "opus", "wav", "wma", + "aiff", "aif", "caf", + "mp4", "m4v", "mov", + "zip" + ] + override func viewDidLoad() { super.viewDidLoad() @@ -156,7 +181,9 @@ class ShareViewController: UIViewController { guard error == nil else { return } - if let url = data as? URL { + if let url = data as? URL, + ShareViewController.isSupportedShareURL(url) + { self?.sharedItems.append(url) } } @@ -177,29 +204,140 @@ class ShareViewController: UIViewController { } @objc func didPressCancel() { + cancelInFlightDownloads() extensionContext?.cancelRequest(withError: ShareExtensionError.cancelled) } + /// Best-effort cancellation of any background downloads kicked off via the share sheet. + /// + /// `task.cancel()` only works while the extension's process is still alive and still holds + /// the task reference — which is unreliable once iOS starts tearing the extension down. + /// Persist the share IDs in the App Group too, so the main app's download delegate can + /// still drop the completion event if iOS kills us before the cancel propagates to + /// `nsurlsessiond`. + private func cancelInFlightDownloads() { + guard !activeDownloadTasks.isEmpty else { return } + let ids = activeDownloadTasks.map(\.shareID) + ShareCancelStore.markCanceled(ids) + for (_, task) in activeDownloadTasks { task.cancel() } + activeDownloadTasks.removeAll() + } + @objc func didPressDone() { LoadingUtils.loadAndBlock(in: self) saveSharedItems(sharedItems) } func saveSharedItems(_ items: [URL]) { - var mutableItems = items - guard !mutableItems.isEmpty else { - DispatchQueue.main.async { [weak self] in - self?.extensionContext?.completeRequest(returningItems: nil) + /// File URLs (AirDrop, Files, document picker) are concrete on-disk items: copy them + /// into the app group's shared folder synchronously where the main app's + /// `ImportManager` will pick them up on next foreground — same code path AirDropped + /// audio uses. + /// + /// Web URLs are handed to a background `URLSession` keyed by + /// `Constants.shareExtensionBackgroundSessionIdentifier`, with + /// `sharedContainerIdentifier` set to the app group. The transfer is owned by iOS's + /// `nsurlsessiond` daemon, not by this extension's process, so we can immediately call + /// `completeRequest` and let the share UI dismiss without waiting for the bytes. When + /// the download finishes, iOS launches the BookPlayer main app (in the background if + /// needed) and `BackgroundShareDownloadDelegate` moves the temp file into the same + /// shared folder, where the standard import flow takes over. + let fileItems = items.filter { $0.isFileURL } + let webItems = items.filter { !$0.isFileURL } + + for item in fileItems { + let destinationURL = sharedFolder.appendingPathComponent(item.lastPathComponent) + do { + try FileManager.default.copyItem(at: item, to: destinationURL) + } catch { + // Don't swallow disk-full / permission / collision errors. The user is blind + // and the share sheet is already gone by the time the main app foregrounds, so + // we persist the failure into the App Group for the host to surface. + ShareImportFailureStore.append( + ShareImportFailure( + source: item.lastPathComponent, + message: String( + format: "share_import_failure_copy_failed".localized, + item.lastPathComponent, + error.localizedDescription + ) + ) + ) } - return } - let item = mutableItems.removeFirst() + if !webItems.isEmpty { + kickOffBackgroundDownloads(for: webItems) + } + + completeRequestOnMain() + } - let destinationURL = sharedFolder.appendingPathComponent(item.lastPathComponent) - try? FileManager.default.copyItem(at: item, to: destinationURL) + /// Schedules a background `URLSession` download for each shared web URL. + /// + /// Two delivery paths cover the lifecycle of the transfer: + /// + /// 1. If the extension's process is still alive when the download completes (typical for + /// small files that finish in seconds), iOS routes the completion to *this session's* + /// `URLSessionDownloadDelegate` — `BackgroundDownloadCoordinator` below — which moves + /// the temp file into the app group's shared folder. + /// 2. If iOS suspends/terminates the extension before the download completes, the + /// transfer continues via `nsurlsessiond` and iOS routes completion to the main app + /// via `application(_:handleEventsForBackgroundURLSession:completionHandler:)`. Main + /// app recreates the same session identifier and `BackgroundShareDownloadDelegate` + /// handles the move there. + /// + /// We previously created the session with `delegate: nil` assuming iOS would always + /// route to the main app, but in practice downloads small enough to finish before the + /// extension dies got their events delivered to a nil delegate and the temp file was + /// silently discarded. The first path above plugs that gap. + private func kickOffBackgroundDownloads(for urls: [URL]) { + let config = URLSessionConfiguration.background( + withIdentifier: Constants.shareExtensionBackgroundSessionIdentifier + ) + config.sharedContainerIdentifier = Constants.ApplicationGroupIdentifier + config.sessionSendsLaunchEvents = true + config.isDiscretionary = false + + /// Retain the coordinator on the running view controller so its delegate methods can + /// fire if iOS keeps this process alive past the share-sheet dismissal — typical for + /// downloads that complete in a few seconds. + let coordinator = BackgroundDownloadCoordinator() + self.backgroundDownloadCoordinator = coordinator + let session = URLSession(configuration: config, delegate: coordinator, delegateQueue: nil) + for url in urls { + // Tag each task with a stable share id (via `taskDescription`, which iOS preserves + // across extension teardown). The cancel path writes these ids into + // ShareCancelStore so the main app's download delegate can suppress completions + // even after the extension's process is gone. + let task = session.downloadTask(with: url) + let shareID = UUID().uuidString + task.taskDescription = shareID + activeDownloadTasks.append((shareID: shareID, task: task)) + task.resume() + } + session.finishTasksAndInvalidate() + } + + private func completeRequestOnMain() { + DispatchQueue.main.async { [weak self] in + self?.extensionContext?.completeRequest(returningItems: nil) + } + } - saveSharedItems(mutableItems) + /// Returns `true` for share items BookPlayer can usefully import. + /// + /// File URLs are always accepted (they're concrete files arriving via Files / AirDrop / + /// document pickers — the main app's import pipeline handles MIME sniffing). Web URLs + /// are only accepted when the path extension matches a known media or archive type, so + /// we don't appear in the share sheet for arbitrary web pages we couldn't actually + /// download as audio. + static func isSupportedShareURL(_ url: URL) -> Bool { + if url.isFileURL { return true } + guard let scheme = url.scheme?.lowercased(), + scheme == "http" || scheme == "https" + else { return false } + return Self.supportedRemoteFileExtensions.contains(url.pathExtension.lowercased()) } func applyDefaultThemeColors() { @@ -246,3 +384,82 @@ extension ShareViewController: UITableViewDelegate {} enum ShareExtensionError: Error { case cancelled } + +/// `URLSessionDownloadDelegate` for the share extension's background download. +/// +/// Used when the extension's process is still alive when iOS finishes the download — +/// typically the case for small files that complete in a few seconds. Without a delegate +/// here, iOS would silently discard the temp file because the alive-extension's session +/// is the active one (iOS only escalates to the main app's matching-identifier session +/// when the extension's process is gone). +/// +/// Moves the temp file into the app group's shared folder, where the main app's +/// `ImportManager` picks it up on next foreground. +final class BackgroundDownloadCoordinator: NSObject, URLSessionDownloadDelegate { + func urlSession( + _ session: URLSession, + downloadTask: URLSessionDownloadTask, + didFinishDownloadingTo location: URL + ) { + let originalURL = downloadTask.originalRequest?.url + let source = originalURL?.absoluteString ?? "shared file" + + // If the user canceled this share before the download finished, drop the temp file + // and bail out — don't import or persist a failure (the cancel is intentional). + if let shareID = downloadTask.taskDescription, ShareCancelStore.isCanceled(shareID) { + try? FileManager.default.removeItem(at: location) + ShareCancelStore.clear(shareID) + return + } + + /// Reject non-2xx HTTP responses — `URLSession` reports success on a 404 and the temp + /// file just contains the error body. Record the failure so the host app can surface it. + if let httpResponse = downloadTask.response as? HTTPURLResponse, + !(200..<300).contains(httpResponse.statusCode) + { + ShareImportFailureStore.append( + ShareImportFailure( + source: source, + message: String( + format: "share_import_failure_http_status".localized, + httpResponse.statusCode + ) + ) + ) + return + } + + let suggested = + downloadTask.response?.suggestedFilename + ?? originalURL?.lastPathComponent + ?? "shared-\(UUID().uuidString)" + let filename = ShareDownloadSupport.sanitizedFilename(suggested) + let mime = (downloadTask.response as? HTTPURLResponse)?.mimeType?.lowercased() + + if let rejection = ShareDownloadSupport.rejectionReason(forMIME: mime, filename: filename) { + ShareImportFailureStore.append( + ShareImportFailure(source: source, message: rejection) + ) + try? FileManager.default.removeItem(at: location) + return + } + + // Avoid collisions from concurrent shares of the same URL by prefixing a short UUID. + let destinationURL = DataManager.getSharedFilesFolderURL() + .appendingPathComponent(ShareDownloadSupport.uniqueDestinationName(for: filename)) + do { + try FileManager.default.moveItem(at: location, to: destinationURL) + } catch { + ShareImportFailureStore.append( + ShareImportFailure( + source: filename, + message: String( + format: "share_import_failure_move_failed".localized, + filename, + error.localizedDescription + ) + ) + ) + } + } +} diff --git a/Shared/Constants.swift b/Shared/Constants.swift index 661be37e2..1ea51a55d 100644 --- a/Shared/Constants.swift +++ b/Shared/Constants.swift @@ -137,6 +137,14 @@ public enum Constants { public static let UserActivityPlayback = Bundle.main.bundleIdentifier! + ".activity.playback" public static let ApplicationGroupIdentifier = "group.\(Bundle.main.configurationString(for: .bundleIdentifier)).files" + /// `URLSessionConfiguration.background` identifier used by the share extension to download + /// shared web URLs. The main app re-creates a session with the same identifier in + /// `application(_:handleEventsForBackgroundURLSession:completionHandler:)` so the + /// `BackgroundShareDownloadDelegate` receives completion and can move the resulting file + /// into the app group's shared folder. + public static let shareExtensionBackgroundSessionIdentifier = + "\(Bundle.main.configurationString(for: .bundleIdentifier)).shareext.background" + public enum Widgets: String { case sharedNowPlayingWidget = "com.bookplayer.shared.widget" case sharedIconWidget = "com.bookplayer.shared.icon.widget" diff --git a/Shared/Services/ShareCancelStore.swift b/Shared/Services/ShareCancelStore.swift new file mode 100644 index 000000000..80d1d243a --- /dev/null +++ b/Shared/Services/ShareCancelStore.swift @@ -0,0 +1,80 @@ +// +// ShareCancelStore.swift +// BookPlayerKit +// +// Created by Matthew Alvernaz on 2026-05-17. +// Copyright © 2026 BookPlayer LLC. All rights reserved. +// + +import Foundation + +/// File-backed set of "canceled share IDs" stored in the App Group container. +/// +/// The share extension lives for seconds — once the user taps Cancel, iOS is free to tear it +/// down before any in-flight `URLSessionDownloadTask.cancel()` propagates to `nsurlsessiond`. +/// Best-effort task cancellation alone isn't enough; the main app might still receive a +/// completion event for a download the user thought they aborted, and would happily move the +/// resulting file into the library. +/// +/// `ShareCancelStore` plugs that gap: the share extension marks the `shareID` here just before +/// dismissing, and the host app's `BackgroundShareDownloadDelegate` checks the set when a +/// completion arrives. If the id is in the canceled set, the temp file gets removed and the +/// import doesn't happen. +/// +/// Implementation notes mirror `ShareImportFailureStore`: a small JSON file in the App Group, +/// atomic writes, capped size to bound the worst case if cleanup ever fails. +public enum ShareCancelStore { + /// Hard cap so a runaway loop can't fill the App Group container. + private static let maxStored = 100 + + private static var storeURL: URL? { + FileManager.default + .containerURL(forSecurityApplicationGroupIdentifier: Constants.ApplicationGroupIdentifier)? + .appendingPathComponent("share-canceled.json") + } + + /// Mark one or more share IDs as canceled. Idempotent — adding an id that's already in the + /// set is a no-op. Returns silently on App Group / encoding failures because surfacing them + /// would be worse UX than the marginal risk of a "ghost" download landing. + public static func markCanceled(_ ids: [String]) { + guard let url = storeURL, !ids.isEmpty else { return } + var canceled = load() + canceled.formUnion(ids) + // Cap by trimming the oldest insertion order. Because `Set` doesn't preserve insertion + // order, we just trim arbitrarily — the cap exists as a hygiene guard, not a precise FIFO. + if canceled.count > maxStored { + canceled = Set(canceled.prefix(maxStored)) + } + guard let data = try? JSONEncoder().encode(canceled) else { return } + try? data.write(to: url, options: .atomic) + } + + /// Check whether a given share id was canceled. Cheap — reads the file each call rather than + /// caching, so two near-simultaneous completions from different shares don't race a stale cache. + public static func isCanceled(_ id: String) -> Bool { + return load().contains(id) + } + + /// Remove an id from the canceled set, e.g. after we've successfully suppressed its + /// completion handler. Keeps the file from growing unbounded across many cancel/share cycles. + public static func clear(_ id: String) { + guard let url = storeURL else { return } + var canceled = load() + guard canceled.contains(id) else { return } + canceled.remove(id) + if canceled.isEmpty { + try? FileManager.default.removeItem(at: url) + return + } + guard let data = try? JSONEncoder().encode(canceled) else { return } + try? data.write(to: url, options: .atomic) + } + + private static func load() -> Set { + guard let url = storeURL, + let data = try? Data(contentsOf: url), + let set = try? JSONDecoder().decode(Set.self, from: data) + else { return [] } + return set + } +} diff --git a/Shared/Services/ShareDownloadSupport.swift b/Shared/Services/ShareDownloadSupport.swift new file mode 100644 index 000000000..100839fda --- /dev/null +++ b/Shared/Services/ShareDownloadSupport.swift @@ -0,0 +1,109 @@ +// +// ShareDownloadSupport.swift +// BookPlayerKit +// +// Created by Matthew Alvernaz on 2026-05-17. +// Copyright © 2026 BookPlayer LLC. All rights reserved. +// + +import Foundation + +/// Helpers shared between the share extension's in-process download coordinator and the main +/// app's `BackgroundShareDownloadDelegate`. Both write into the App Group's shared folder and +/// have to defend against the same three failure shapes: filename traversal, collisions when +/// the same URL is shared twice, and servers that return HTML / JSON error bodies with a 200. +public enum ShareDownloadSupport { + /// File extensions BookPlayer can usefully import. Mirrors the share extension's activation + /// list — kept here because both download paths fall back to filename-extension matching + /// when the server returns an ambiguous MIME type (e.g. `application/octet-stream`, or none). + public static let supportedRemoteFileExtensions: Set = [ + "mp3", "m4a", "m4b", "aac", "flac", "ogg", "opus", "wav", "wma", + "aiff", "aif", "caf", + "mp4", "m4v", "mov", + "zip" + ] + + /// MIME types we hard-reject because they are almost always Cloudflare interstitials, captive + /// portals, login walls, or API error envelopes — never an audiobook file. + private static let rejectedMIMEs: Set = [ + "text/html", "text/plain", "application/json", + "application/xml", "application/problem+json", + ] + + /// MIME types we always accept without filename-extension validation. + private static let acceptedMIMEs: Set = [ + "application/zip", "application/x-zip-compressed", + "application/x-mpegurl", "application/vnd.apple.mpegurl", + ] + + /// MIME types that are ambiguous — sane servers send them for binary downloads, broken servers + /// send them for everything. Accept only when the filename's extension is in our supported set. + private static let ambiguousMIMEs: Set = [ + "application/octet-stream", "binary/octet-stream", + "application/download", "application/force-download", + ] + + /// Strip path components, `..`, leading dots, and control characters from a server-suggested + /// filename so it can't traverse out of the shared folder or otherwise corrupt the import + /// destination. + public static func sanitizedFilename(_ raw: String) -> String { + // `lastPathComponent` collapses any `dir/file` shape down to `file`, and on Apple platforms + // it also normalizes some control characters, but we still need to defend against `..`, + // empty strings, and stray dots. + var name = (raw as NSString).lastPathComponent + while name.hasPrefix(".") { name.removeFirst() } + name = name.replacingOccurrences(of: "/", with: "_") + .replacingOccurrences(of: "\\", with: "_") + .replacingOccurrences(of: "\0", with: "_") + if name.isEmpty || name == "." || name == ".." { + return "shared-\(UUID().uuidString)" + } + // Cap length so a pathological server can't produce a name iOS will refuse to write. + if name.count > 240 { + let ext = (name as NSString).pathExtension + let baseLimit = ext.isEmpty ? 240 : (240 - ext.count - 1) + let base = (name as NSString).deletingPathExtension + let trimmed = String(base.prefix(baseLimit)) + name = ext.isEmpty ? trimmed : "\(trimmed).\(ext)" + } + return name + } + + /// Generate a collision-free destination filename by prefixing a short UUID. Used by both + /// delegates so two simultaneous shares of the same URL don't overwrite each other. + public static func uniqueDestinationName(for filename: String) -> String { + let prefix = UUID().uuidString.prefix(8) + return "\(prefix)-\(filename)" + } + + /// Returns a localized rejection message when the downloaded bytes shouldn't be imported. + /// `nil` means "accept this download". Layered: hard-reject known web/error MIMEs, accept + /// known-good audio/archive MIMEs, fall back to filename-extension matching for ambiguous + /// or missing MIME types. + public static func rejectionReason(forMIME mime: String?, filename: String) -> String? { + let pathExt = (filename as NSString).pathExtension.lowercased() + let extensionMatches = !pathExt.isEmpty && supportedRemoteFileExtensions.contains(pathExt) + + if let mime, rejectedMIMEs.contains(mime) { + return String(format: "share_import_failure_unsupported_type".localized, mime) + } + if let mime { + if mime.hasPrefix("audio/") || mime.hasPrefix("video/") || acceptedMIMEs.contains(mime) { + return nil + } + if ambiguousMIMEs.contains(mime) || mime.isEmpty { + return extensionMatches + ? nil + : String(format: "share_import_failure_unsupported_extension".localized, filename) + } + // Unknown MIME: be generous if the filename extension is recognized, otherwise reject. + return extensionMatches + ? nil + : String(format: "share_import_failure_unsupported_type".localized, mime) + } + // No MIME header at all — defer to filename extension. + return extensionMatches + ? nil + : String(format: "share_import_failure_unsupported_extension".localized, filename) + } +} diff --git a/Shared/Services/ShareImportFailureStore.swift b/Shared/Services/ShareImportFailureStore.swift new file mode 100644 index 000000000..4cb3f2cef --- /dev/null +++ b/Shared/Services/ShareImportFailureStore.swift @@ -0,0 +1,88 @@ +// +// ShareImportFailureStore.swift +// BookPlayerKit +// +// Created by Matthew Alvernaz on 2026-05-17. +// Copyright © 2026 BookPlayer LLC. All rights reserved. +// + +import Foundation + +/// A persisted record of an import that failed in the share-handoff path. We need to surface +/// these on the main app's next foreground because the share extension's UI is gone by the time +/// the bytes finish moving (the download itself is owned by `nsurlsessiond`), so failures that +/// happen after the share sheet dismisses would otherwise be invisible. +/// +/// `source` should identify what the user tried to import (filename, or the share URL) and +/// `message` should be specific and actionable — never a generic "download failed". A VoiceOver +/// user announcing these has no visual context to fall back on. +public struct ShareImportFailure: Codable, Identifiable, Equatable { + public let id: UUID + public let date: Date + public let source: String + public let message: String + + public init(id: UUID = UUID(), date: Date = Date(), source: String, message: String) { + self.id = id + self.date = date + self.source = source + self.message = message + } +} + +/// File-backed queue of share-import failures. Stored as a JSON file in the App Group container +/// rather than via App Group `UserDefaults` because cross-process `UserDefaults` synchronization +/// between an extension and its host app has historically been flaky (the OS doesn't fire KVO +/// notifications across processes, and barrier syncs are best-effort). Atomic file writes give +/// us a deterministic point-in-time hand-off. +/// +/// Concurrency: every operation reads the file fresh and writes atomically. The queue is small +/// (capped at `maxStored`) and the contention window is narrow — extension writes once on +/// failure, main app reads-and-clears once on foreground — so a simple atomic-write strategy +/// suffices. +public enum ShareImportFailureStore { + /// Hard cap on stored failures so a runaway loop in the extension can't fill the container. + private static let maxStored = 50 + + private static var storeURL: URL? { + FileManager.default + .containerURL(forSecurityApplicationGroupIdentifier: Constants.ApplicationGroupIdentifier)? + .appendingPathComponent("share-import-failures.json") + } + + /// Append a failure record. Safe to call from either the share extension or the main app. + /// Best-effort — failures here (e.g. App Group container temporarily unreachable) are + /// swallowed because surfacing a "couldn't record a failure" error would only confuse the user. + public static func append(_ failure: ShareImportFailure) { + guard let url = storeURL else { return } + var failures = load() + failures.append(failure) + failures = Array(failures.suffix(maxStored)) + + guard let data = try? JSONEncoder().encode(failures) else { return } + try? data.write(to: url, options: .atomic) + } + + /// Returns all queued failures and clears the store atomically. + /// + /// "Atomically" here means we re-read just before deleting, so a failure appended between + /// our load and the delete won't be lost — it'll be returned by this drain call. We use + /// `replaceItemAt` semantics via `write([], atomic:)` rather than `removeItem` because the + /// file simply existing-or-not is itself the primary state signal. + public static func drain() -> [ShareImportFailure] { + guard let url = storeURL else { return [] } + let failures = load() + if !failures.isEmpty { + try? FileManager.default.removeItem(at: url) + } + return failures + } + + private static func load() -> [ShareImportFailure] { + guard let url = storeURL, + let data = try? Data(contentsOf: url), + let failures = try? JSONDecoder().decode([ShareImportFailure].self, from: data) + else { return [] } + return failures + } +}