Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions BookPlayer.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -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 */; };
Expand Down Expand Up @@ -1782,6 +1785,9 @@
D367F7671FA2A6F000FEDB37 /* Constants.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Constants.swift; sourceTree = "<group>"; };
D6BA8F152A4CA94800C2BD9A /* StorageRowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StorageRowView.swift; sourceTree = "<group>"; };
D6BA8F172A4D66CD00C2BD9A /* StorageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StorageView.swift; sourceTree = "<group>"; };
6A91D2C3E04F1B8A7E62C804 /* ShareImportFailureStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareImportFailureStore.swift; sourceTree = "<group>"; };
7B91D2C3E04F1B8A7E62C806 /* ShareDownloadSupport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareDownloadSupport.swift; sourceTree = "<group>"; };
8C91D2C3E04F1B8A7E62C808 /* ShareCancelStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareCancelStore.swift; sourceTree = "<group>"; };
E1FDA0B60D7BE4CFE100A612 /* IntegrationDisconnectedView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IntegrationDisconnectedView.swift; sourceTree = "<group>"; };
E4D25EF2A5FFF30CF12F2C21 /* PreferencesSyncService.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = PreferencesSyncService.swift; sourceTree = "<group>"; };
E55F466D61AE62A69DE4D371 /* BPNavigation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BPNavigation.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -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 */,
Expand Down Expand Up @@ -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;
};
Expand Down
157 changes: 157 additions & 0 deletions BookPlayer/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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?()
}
}
}
7 changes: 7 additions & 0 deletions BookPlayer/Base.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
41 changes: 41 additions & 0 deletions BookPlayer/Library/ItemList/LibraryRootView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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,
Expand Down
7 changes: 7 additions & 0 deletions BookPlayer/ar.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -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" = "خوادم الوسائط";
Expand Down
7 changes: 7 additions & 0 deletions BookPlayer/ca.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
7 changes: 7 additions & 0 deletions BookPlayer/cs.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
7 changes: 7 additions & 0 deletions BookPlayer/da.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
Loading
Loading