Skip to content

Commit

Permalink
Extract localized error messages from the toolkit (#477)
Browse files Browse the repository at this point in the history
  • Loading branch information
mickael-menu authored Aug 29, 2024
1 parent d0de224 commit ca396ce
Show file tree
Hide file tree
Showing 42 changed files with 479 additions and 534 deletions.
4 changes: 4 additions & 0 deletions Documentation/Migration Guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@ All migration steps necessary in reading apps to upgrade to major versions of th

## Unreleased

### Error management

The error hierarchy returned by the Readium APIs has been revamped and simplified. They are no longer `LocalizedError` instances, so you must provide your own user-friendly error messages. Refer to the `Readium.swift` file in the Test App for an example.

### Opening a `Publication`

The `Streamer` object has been deprecated in favor of components with smaller responsibilities:
Expand Down
13 changes: 0 additions & 13 deletions Sources/LCP/Content Protection/LCPContentProtection.swift
Original file line number Diff line number Diff line change
Expand Up @@ -65,19 +65,6 @@ final class LCPContentProtection: ContentProtection, Loggable {
}
}

private extension Publication.OpeningError {
static func wrap(_ error: LCPError) -> Publication.OpeningError {
switch error {
case .licenseIsBusy, .network, .licenseContainer:
return .unavailable(error)
case .licenseStatus:
return .forbidden(error)
default:
return .parsingFailed(error)
}
}
}

private final class LCPContentProtectionService: ContentProtectionService {
let license: LCPLicense?
let error: Error?
Expand Down
129 changes: 21 additions & 108 deletions Sources/LCP/LCPError.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
import Foundation
import ReadiumShared

public enum LCPError: LocalizedError {
public enum LCPError: Error {
/// The license could not be retrieved because the passphrase is unknown.
case missingPassphrase

Expand Down Expand Up @@ -52,138 +52,51 @@ public enum LCPError: LocalizedError {

/// An unknown low-level error was reported.
case unknown(Error?)

public var errorDescription: String? {
switch self {
case .missingPassphrase: return nil
case .notALicenseDocument: return nil
case .licenseIsBusy:
return ReadiumLCPLocalizedString("LCPError.licenseIsBusy")
case let .licenseIntegrity(error):
let description: String = {
switch error {
case .licenseOutOfDate:
return ReadiumLCPLocalizedString("LCPClientError.licenseOutOfDate")
case .certificateRevoked:
return ReadiumLCPLocalizedString("LCPClientError.certificateRevoked")
case .certificateSignatureInvalid:
return ReadiumLCPLocalizedString("LCPClientError.certificateSignatureInvalid")
case .licenseSignatureDateInvalid:
return ReadiumLCPLocalizedString("LCPClientError.licenseSignatureDateInvalid")
case .licenseSignatureInvalid:
return ReadiumLCPLocalizedString("LCPClientError.licenseSignatureInvalid")
case .contextInvalid:
return ReadiumLCPLocalizedString("LCPClientError.contextInvalid")
case .contentKeyDecryptError:
return ReadiumLCPLocalizedString("LCPClientError.contentKeyDecryptError")
case .userKeyCheckInvalid:
return ReadiumLCPLocalizedString("LCPClientError.userKeyCheckInvalid")
case .contentDecryptError:
return ReadiumLCPLocalizedString("LCPClientError.contentDecryptError")
case .unknown:
return ReadiumLCPLocalizedString("LCPClientError.unknown")
}
}()
return ReadiumLCPLocalizedString("LCPError.licenseIntegrity", description)
case let .licenseStatus(error):
return error.localizedDescription
case .licenseContainer:
return ReadiumLCPLocalizedString("LCPError.licenseContainer")
case .licenseInteractionNotAvailable:
return ReadiumLCPLocalizedString("LCPError.licenseInteractionNotAvailable")
case .licenseProfileNotSupported:
return ReadiumLCPLocalizedString("LCPError.licenseProfileNotSupported")
case .crlFetching:
return ReadiumLCPLocalizedString("LCPError.crlFetching")
case let .licenseRenew(error):
return error.localizedDescription
case let .licenseReturn(error):
return error.localizedDescription
case .parsing:
return ReadiumLCPLocalizedString("LCPError.parsing")
case let .network(error):
return error?.localizedDescription ?? ReadiumLCPLocalizedString("LCPError.network")
case let .runtime(error):
return error
case let .unknown(error):
return error?.localizedDescription
}
}
}

/// Errors while checking the status of the License, using the Status Document.
public enum StatusError: LocalizedError {
// For the case (revoked, returned, cancelled, expired), app should notify the user and stop there. The message to the user must be clear about the status of the license: don't display "expired" if the status is "revoked". The date and time corresponding to the new status should be displayed (e.g. "The license expired on 01 January 2018").
///
/// For the case (revoked, returned, cancelled, expired), app should notify the
/// user and stop there. The message to the user must be clear about the status
/// of the license: don't display "expired" if the status is "revoked". The
/// date and time corresponding to the new status should be displayed (e.g.
/// "The license expired on 01 January 2018").
///
/// If the license has been revoked, the user message should display the number
/// of devices which registered to the server. This count can be calculated
/// from the number of "register" events in the status document. If no event is
/// logged in the status document, no such message should appear (certainly not
/// "The license was registered by 0 devices").
public enum StatusError: Error {
/// This license was cancelled on the given date.
case cancelled(Date)
/// This license has been returned on the given date.
case returned(Date)
/// This license started and expired on the given dates.
case expired(start: Date, end: Date)
// If the license has been revoked, the user message should display the number of devices which registered to the server. This count can be calculated from the number of "register" events in the status document. If no event is logged in the status document, no such message should appear (certainly not "The license was registered by 0 devices").
/// This license was revoked on the given date, after being activated on
/// `devicesCount` devices.
case revoked(Date, devicesCount: Int)

public var errorDescription: String? {
let dateFormatter = DateFormatter()
dateFormatter.dateStyle = .medium

switch self {
case let .cancelled(date):
return ReadiumLCPLocalizedString("StatusError.cancelled", dateFormatter.string(from: date))

case let .returned(date):
return ReadiumLCPLocalizedString("StatusError.returned", dateFormatter.string(from: date))

case let .expired(start: start, end: end):
if start > Date() {
return ReadiumLCPLocalizedString("StatusError.expired.start", dateFormatter.string(from: start))
} else {
return ReadiumLCPLocalizedString("StatusError.expired.end", dateFormatter.string(from: end))
}

case let .revoked(date, devicesCount):
return ReadiumLCPLocalizedString("StatusError.revoked", dateFormatter.string(from: date), devicesCount)
}
}
}

/// Errors while renewing a loan.
public enum RenewError: LocalizedError {
public enum RenewError: Error {
// Your publication could not be renewed properly.
case renewFailed
// Incorrect renewal period, your publication could not be renewed.
case invalidRenewalPeriod(maxRenewDate: Date?)
// An unexpected error has occurred on the licensing server.
case unexpectedServerError

public var errorDescription: String? {
switch self {
case .renewFailed:
return ReadiumLCPLocalizedString("RenewError.renewFailed")
case .invalidRenewalPeriod(maxRenewDate: _):
return ReadiumLCPLocalizedString("RenewError.invalidRenewalPeriod")
case .unexpectedServerError:
return ReadiumLCPLocalizedString("RenewError.unexpectedServerError")
}
}
}

/// Errors while returning a loan.
public enum ReturnError: LocalizedError {
public enum ReturnError: Error {
// Your publication could not be returned properly.
case returnFailed
// Your publication has already been returned before or is expired.
case alreadyReturnedOrExpired
// An unexpected error has occurred on the licensing server.
case unexpectedServerError

public var errorDescription: String? {
switch self {
case .returnFailed:
return ReadiumLCPLocalizedString("ReturnError.returnFailed")
case .alreadyReturnedOrExpired:
return ReadiumLCPLocalizedString("ReturnError.alreadyReturnedOrExpired")
case .unexpectedServerError:
return ReadiumLCPLocalizedString("ReturnError.unexpectedServerError")
}
}
}

/// Errors while parsing the License or Status JSON Documents.
Expand Down
38 changes: 0 additions & 38 deletions Sources/LCP/Resources/en.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -30,41 +30,3 @@
"ReadiumLCP.dialog.support.phone" = "Phone";
"ReadiumLCP.dialog.support.mail" = "Mail";

/* LCPError: General error messages */
"ReadiumLCP.LCPError.licenseIsBusy" = "Can't perform this operation at the moment.";
"ReadiumLCP.LCPError.licenseIntegrity" = "License integrity: %@";
"ReadiumLCP.LCPError.licenseContainer" = "Can't access the License Document.";
"ReadiumLCP.LCPError.licenseInteractionNotAvailable" = "This interaction is not available.";
"ReadiumLCP.LCPError.licenseProfileNotSupported" = "This License has a profile identifier that this app cannot handle, the publication cannot be processed.";
"ReadiumLCP.LCPError.crlFetching" = "Can't retrieve the Certificate Revocation List.";
"ReadiumLCP.LCPError.parsing" = "Failed to parse the License Document.";
"ReadiumLCP.LCPError.network" = "Network error.";

/* LCPClientError: Errors while checking the integrity of the License */
"ReadiumLCP.LCPClientError.licenseOutOfDate" = "License is out of date (check start and end date).";
"ReadiumLCP.LCPClientError.certificateRevoked" = "Certificate has been revoked in the CRL.";
"ReadiumLCP.LCPClientError.certificateSignatureInvalid" = "Certificate has not been signed by CA.";
"ReadiumLCP.LCPClientError.licenseSignatureDateInvalid" = "License has been issued by an expired certificate.";
"ReadiumLCP.LCPClientError.licenseSignatureInvalid" = "License signature does not match.";
"ReadiumLCP.LCPClientError.contextInvalid" = "The DRM context is invalid.";
"ReadiumLCP.LCPClientError.contentKeyDecryptError" = "Unable to decrypt encrypted content key from user key.";
"ReadiumLCP.LCPClientError.userKeyCheckInvalid" = "User key check invalid.";
"ReadiumLCP.LCPClientError.contentDecryptError" = "Unable to decrypt encrypted content from content key.";
"ReadiumLCP.LCPClientError.unknown" = "Unknown error.";

/* StatusError: Errors while checking the status of the License, using the Status Document. */
"ReadiumLCP.StatusError.cancelled" = "This license was cancelled on %@.";
"ReadiumLCP.StatusError.returned" = "This license has been returned on %@.";
"ReadiumLCP.StatusError.expired.start" = "This license starts on %@.";
"ReadiumLCP.StatusError.expired.end" = "This license expired on %@.";
"ReadiumLCP.StatusError.revoked" = "This license was revoked by its provider on %1$@.\nIt was registered by %2$d device(s)";

/* RenewError: Errors while renewing a loan. */
"ReadiumLCP.RenewError.renewFailed" = "Your publication could not be renewed properly.";
"ReadiumLCP.RenewError.invalidRenewalPeriod" = "Incorrect renewal period, your publication could not be renewed.";
"ReadiumLCP.RenewError.unexpectedServerError" = "An unexpected error has occurred on the server.";

/* ReturnError: Errors while returning a loan. */
"ReadiumLCP.ReturnError.returnFailed" = "Your publication could not be returned properly.";
"ReadiumLCP.ReturnError.alreadyReturnedOrExpired" = "Your publication has already been returned before or is expired.";
"ReadiumLCP.ReturnError.unexpectedServerError" = "An unexpected error has occurred on the server.";
23 changes: 0 additions & 23 deletions Sources/LCP/Toolkit/Deferred.swift

This file was deleted.

10 changes: 2 additions & 8 deletions Sources/Navigator/Audiobook/PublicationMediaLoader.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,15 +13,9 @@ import ReadiumShared
///
/// Useful for local resources or when you need to customize the way HTTP requests are sent.
final class PublicationMediaLoader: NSObject, AVAssetResourceLoaderDelegate, Loggable {
public enum AssetError: LocalizedError {
public enum AssetError: Error {
/// Can't produce an URL to create an AVAsset for the given HREF.
case invalidHREF(String)

public var errorDescription: String? {
switch self {
case let .invalidHREF(href):
return "Can't produce an URL to create an AVAsset for HREF \(href)"
}
}
}

private let publication: Publication
Expand Down
9 changes: 1 addition & 8 deletions Sources/Navigator/Navigator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -157,14 +157,7 @@ public extension NavigatorDelegate {
func navigator(_ navigator: Navigator, didFailToLoadResourceAt href: String, withError error: ReadError) {}
}

public enum NavigatorError: LocalizedError {
public enum NavigatorError: Error {
/// The user tried to copy the text selection but the DRM License doesn't allow it.
case copyForbidden

public var errorDescription: String? {
switch self {
case .copyForbidden:
return ReadiumNavigatorLocalizedString("NavigatorError.copyForbidden")
}
}
}
1 change: 0 additions & 1 deletion Sources/Navigator/Resources/en.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -4,5 +4,4 @@
available in the top-level LICENSE file of the project.
*/

"ReadiumNavigator.NavigatorError.copyForbidden" = "You exceeded the amount of characters allowed to be copied.";
"ReadiumNavigator.EditingAction.share" = "Share…";
14 changes: 3 additions & 11 deletions Sources/Shared/Publication/Protection/ContentProtection.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,17 +14,9 @@ import Foundation
public protocol ContentProtection {
/// Attempts to unlock a potentially protected publication asset.
///
/// The Streamer will create a leaf `fetcher` for the low-level `asset` access (e.g.
/// `ArchiveFetcher` for a ZIP archive), to avoid having each Content Protection open the asset
/// to check if it's protected or not.
///
/// A publication might be protected in such a way that the asset format can't be recognized,
/// in which case the Content Protection will have the responsibility of creating a new leaf
/// `Fetcher`.
///
/// - Returns: A `ProtectedAsset` in case of success, nil if the asset is not protected by this
/// technology or a `Publication.OpeningError` if the asset can't be successfully opened, even
/// in restricted mode.
/// - Returns: An ``Asset`` in case of success or an
/// ``ContentProtectionOpenError`` if the asset can't be successfully
/// opened even in restricted mode.
func open(
asset: Asset,
credentials: String?,
Expand Down
20 changes: 2 additions & 18 deletions Sources/Shared/Publication/Publication.swift
Original file line number Diff line number Diff line change
Expand Up @@ -169,7 +169,8 @@ public class Publication: Closeable, Loggable {
}

/// Errors occurring while opening a Publication.
public enum OpeningError: LocalizedError {
@available(*, unavailable, message: "Not used anymore")
public enum OpeningError: Error {
/// The file format could not be recognized by any parser.
case unsupportedFormat
/// The publication file was not found on the file system.
Expand All @@ -184,23 +185,6 @@ public class Publication: Closeable, Loggable {
/// The provided credentials are incorrect and we can't open the publication in a
/// `restricted` state (e.g. for a password-protected ZIP).
case incorrectCredentials

public var errorDescription: String? {
switch self {
case .unsupportedFormat:
return ReadiumSharedLocalizedString("Publication.OpeningError.unsupportedFormat")
case .notFound:
return ReadiumSharedLocalizedString("Publication.OpeningError.notFound")
case .parsingFailed:
return ReadiumSharedLocalizedString("Publication.OpeningError.parsingFailed")
case .forbidden:
return ReadiumSharedLocalizedString("Publication.OpeningError.forbidden")
case .unavailable:
return ReadiumSharedLocalizedString("Publication.OpeningError.unavailable")
case .incorrectCredentials:
return ReadiumSharedLocalizedString("Publication.OpeningError.incorrectCredentials")
}
}
}

/// Holds the components of a `Publication` to build it.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -122,12 +122,12 @@ public struct SearchOptions: Hashable {
public typealias SearchResult<Success> = Result<Success, SearchError>

/// Represents an error which might occur during a search activity.
public enum SearchError: LocalizedError {
public enum SearchError: Error {
/// The publication is not searchable.
case publicationNotSearchable

/// The provided search query cannot be handled by the service.
case badQuery(LocalizedError)
case badQuery(Error)

/// An error occurred while accessing one of the publication's resources.
case reading(ReadError)
Expand Down
Loading

0 comments on commit ca396ce

Please sign in to comment.