From a21ee42f8d7bd012c85d5426e158753e668d4d02 Mon Sep 17 00:00:00 2001 From: Mark Raynsford Date: Wed, 31 Jul 2024 20:16:21 +0000 Subject: [PATCH 1/2] Fully implement LCP streaming This implements the mountain of changes required to implement LCP audiobook streaming in a somewhat backwards-compatible manner. * Switch to the 14.0.0-SNAPSHOT branch of the audiobooks components. * The app is no longer responsible for downloading the contents of LCP audiobooks: This is handled by the audiobooks components, and old book files are copied into the audiobooks components storage as a compatibility measure (otherwise users would have to download books all over again). * Numerous adjustments were made to the taskrecorder API to better capture error information. Affects: https://ebce-lyrasis.atlassian.net/browse/PP-256 Affects: https://ebce-lyrasis.atlassian.net/browse/PP-405 Affects: https://ebce-lyrasis.atlassian.net/browse/PP-902 --- org.thepalaceproject.android.platform | 2 +- .../registry/AccountProviderRegistry.kt | 18 +- .../nyplregistry/AccountProviderResolution.kt | 59 +- simplified-app-palace/build.gradle.kts | 18 +- .../books/api/BookDRMInformation.kt | 9 +- .../nypl/simplified/books/api/BookFormat.kt | 3 +- simplified-books-audio/build.gradle.kts | 6 + .../AbstractAudioBookManifestStrategy.kt | 282 -------- .../books/audio/AudioBookCredentials.kt | 43 -- .../simplified/books/audio/AudioBookLink.kt | 16 + .../books/audio/AudioBookManifestData.kt | 6 + .../AudioBookManifestFulfillmentAdapter.kt | 47 -- .../AudioBookManifestFulfillmentError.kt | 15 - .../books/audio/AudioBookManifestRequest.kt | 39 +- .../audio/AudioBookManifestStrategiesType.kt | 2 +- .../books/audio/AudioBookManifests.kt | 8 +- .../books/audio/AudioBookStrategy.kt | 604 ++++++++++++++++++ ...rategyType.kt => AudioBookStrategyType.kt} | 9 +- .../PackagedAudioBookManifestStrategy.kt | 121 ---- .../UnpackagedAudioBookManifestStrategy.kt | 187 ------ simplified-books-borrowing/build.gradle.kts | 12 +- .../books/borrowing/BorrowContextType.kt | 6 +- .../simplified/books/borrowing/BorrowTask.kt | 64 +- .../books/borrowing/internal/BorrowACSM.kt | 30 +- .../borrowing/internal/BorrowAudioBook.kt | 66 +- .../books/borrowing/internal/BorrowAxisNow.kt | 9 +- .../borrowing/internal/BorrowBearerToken.kt | 3 +- .../books/borrowing/internal/BorrowCopy.kt | 6 +- .../internal/BorrowDirectDownload.kt | 3 +- .../books/borrowing/internal/BorrowHTTP.kt | 10 +- .../books/borrowing/internal/BorrowLCP.kt | 473 -------------- .../borrowing/internal/BorrowLCPAudiobook.kt | 209 ++++++ .../books/borrowing/internal/BorrowLCPEpub.kt | 256 ++++++++ .../borrowing/internal/BorrowLCPSupport.kt | 269 ++++++++ .../borrowing/internal/BorrowLoanCreate.kt | 25 +- .../borrowing/internal/BorrowSAMLDownload.kt | 5 +- .../internal/BorrowSubtaskDirectory.kt | 3 +- .../subtasks/BorrowSubtaskDirectoryType.kt | 5 +- .../subtasks/BorrowSubtaskFactoryType.kt | 3 +- .../books/controller/AbstractBookTask.kt | 30 +- .../books/controller/BookDeleteTask.kt | 3 +- .../books/controller/BookRevokeTask.kt | 134 +++- .../books/controller/BookSyncTask.kt | 3 +- .../books/controller/PatronUserProfiles.kt | 56 +- .../ProfileAccountCreateCustomOPDSTask.kt | 36 +- ...rofileAccountCreateOrReturnExistingTask.kt | 7 +- .../controller/ProfileAccountCreateTask.kt | 20 +- .../controller/ProfileAccountDeleteTask.kt | 14 +- .../controller/ProfileAccountLoginTask.kt | 53 +- .../controller/ProfileAccountLogoutTask.kt | 79 ++- .../api/BookDRMInformationHandle.kt | 8 +- .../api/BookDatabaseEntryType.kt | 29 +- .../BookDRMInformationHandleLCP.kt | 30 +- .../books/book_database/BookDatabaseEntry.kt | 2 +- .../DatabaseFormatHandleAudioBook.kt | 99 +-- .../book_database/DatabaseFormatHandleEPUB.kt | 3 +- .../book_database/DatabaseFormatHandlePDF.kt | 3 +- .../books/preview/BookPreviewHttp.kt | 3 +- .../books/preview/BookPreviewTask.kt | 26 +- .../nypl/simplified/feeds/api/FeedLoading.kt | 26 +- .../librarysimplified/main/MainMigrations.kt | 150 ----- .../librarysimplified/main/MainServices.kt | 7 - .../taskrecorder/api/TaskRecorder.kt | 12 +- .../taskrecorder/api/TaskRecorderType.kt | 6 +- .../simplified/taskrecorder/api/TaskResult.kt | 3 +- .../taskrecorder/api/TaskStepResolution.kt | 3 +- simplified-tests/build.gradle.kts | 17 - ...ountProviderDescriptionRegistryContract.kt | 12 - .../audio/AudioBookManifestStrategyTest.kt | 134 ++-- .../PackagedAudioBookManifestStrategyTest.kt | 129 ---- .../BookDRMInformationHandleLCPTest.kt | 13 +- .../book_database/BookDatabaseContract.kt | 6 +- .../books/borrowing/BorrowAudioBookTest.kt | 7 +- ...{BorrowLCPTest.kt => BorrowLCPEpubTest.kt} | 22 +- .../tests/books/borrowing/BorrowTaskTest.kt | 2 + .../errorpage/ErrorPageParametersTest.kt | 12 +- .../borrow/BorrowBookRefreshTokenTest.kt | 4 +- .../mocking/MockAccountProviderRegistry.kt | 6 +- .../MockAudioBookManifestStrategies.kt | 6 +- ...stStrategy.kt => MockAudioBookStrategy.kt} | 9 +- ...kBookDatabaseEntryFormatHandleAudioBook.kt | 27 +- .../MockBookDatabaseEntryFormatHandleEPUB.kt | 3 +- .../MockBookDatabaseEntryFormatHandlePDF.kt | 3 +- .../mocking/MockedAudioEngineProvider.kt | 14 +- .../accounts/saml20/AccountSAML20Fragment.kt | 8 +- .../ui/catalog/CatalogFeedViewModel.kt | 20 +- .../catalog/saml20/CatalogSAML20ViewModel.kt | 7 +- simplified-ui-errorpage/build.gradle.kts | 1 + .../ui/errorpage/ErrorPageStepsListAdapter.kt | 14 +- .../ui/splash/MigrationProgressFragment.kt | 54 -- .../ui/splash/MigrationReportEmail.kt | 70 -- .../ui/splash/MigrationReportFragment.kt | 83 --- .../ui/splash/MigrationReportListAdapter.kt | 89 --- .../ui/splash/MigrationViewModel.kt | 68 -- .../ui/splash/SplashFragment.kt | 59 +- .../audiobook/AudioBookPlayerActivity2.kt | 49 +- .../audiobook/AudioBookPlayerParameters.kt | 113 +--- .../viewer/audiobook/AudioBookViewer.kt | 155 +++-- 98 files changed, 2406 insertions(+), 2606 deletions(-) delete mode 100644 simplified-books-audio/src/main/java/org/nypl/simplified/books/audio/AbstractAudioBookManifestStrategy.kt delete mode 100644 simplified-books-audio/src/main/java/org/nypl/simplified/books/audio/AudioBookCredentials.kt create mode 100644 simplified-books-audio/src/main/java/org/nypl/simplified/books/audio/AudioBookLink.kt delete mode 100644 simplified-books-audio/src/main/java/org/nypl/simplified/books/audio/AudioBookManifestFulfillmentAdapter.kt delete mode 100644 simplified-books-audio/src/main/java/org/nypl/simplified/books/audio/AudioBookManifestFulfillmentError.kt create mode 100644 simplified-books-audio/src/main/java/org/nypl/simplified/books/audio/AudioBookStrategy.kt rename simplified-books-audio/src/main/java/org/nypl/simplified/books/audio/{AudioBookManifestStrategyType.kt => AudioBookStrategyType.kt} (68%) delete mode 100644 simplified-books-audio/src/main/java/org/nypl/simplified/books/audio/PackagedAudioBookManifestStrategy.kt delete mode 100644 simplified-books-audio/src/main/java/org/nypl/simplified/books/audio/UnpackagedAudioBookManifestStrategy.kt delete mode 100644 simplified-books-borrowing/src/main/java/org/nypl/simplified/books/borrowing/internal/BorrowLCP.kt create mode 100644 simplified-books-borrowing/src/main/java/org/nypl/simplified/books/borrowing/internal/BorrowLCPAudiobook.kt create mode 100644 simplified-books-borrowing/src/main/java/org/nypl/simplified/books/borrowing/internal/BorrowLCPEpub.kt create mode 100644 simplified-books-borrowing/src/main/java/org/nypl/simplified/books/borrowing/internal/BorrowLCPSupport.kt delete mode 100644 simplified-main/src/main/java/org/librarysimplified/main/MainMigrations.kt delete mode 100644 simplified-tests/src/test/java/org/nypl/simplified/tests/books/audio/PackagedAudioBookManifestStrategyTest.kt rename simplified-tests/src/test/java/org/nypl/simplified/tests/books/borrowing/{BorrowLCPTest.kt => BorrowLCPEpubTest.kt} (98%) rename simplified-tests/src/test/java/org/nypl/simplified/tests/mocking/{MockAudioBookManifestStrategy.kt => MockAudioBookStrategy.kt} (65%) delete mode 100644 simplified-ui-splash/src/main/java/org/librarysimplified/ui/splash/MigrationProgressFragment.kt delete mode 100644 simplified-ui-splash/src/main/java/org/librarysimplified/ui/splash/MigrationReportEmail.kt delete mode 100644 simplified-ui-splash/src/main/java/org/librarysimplified/ui/splash/MigrationReportFragment.kt delete mode 100644 simplified-ui-splash/src/main/java/org/librarysimplified/ui/splash/MigrationReportListAdapter.kt delete mode 100644 simplified-ui-splash/src/main/java/org/librarysimplified/ui/splash/MigrationViewModel.kt diff --git a/org.thepalaceproject.android.platform b/org.thepalaceproject.android.platform index 437314e85..5ab9bfb36 160000 --- a/org.thepalaceproject.android.platform +++ b/org.thepalaceproject.android.platform @@ -1 +1 @@ -Subproject commit 437314e85d01475a73dce5f21b2dd7e26d6a8666 +Subproject commit 5ab9bfb3694e2aab138e3eee8595957179a998c3 diff --git a/simplified-accounts-registry/src/main/java/org/nypl/simplified/accounts/registry/AccountProviderRegistry.kt b/simplified-accounts-registry/src/main/java/org/nypl/simplified/accounts/registry/AccountProviderRegistry.kt index d3d4c67e8..570fe6a45 100644 --- a/simplified-accounts-registry/src/main/java/org/nypl/simplified/accounts/registry/AccountProviderRegistry.kt +++ b/simplified-accounts-registry/src/main/java/org/nypl/simplified/accounts/registry/AccountProviderRegistry.kt @@ -44,7 +44,8 @@ class AccountProviderRegistry private constructor( @Volatile private var statusRef: AccountProviderRegistryStatus = Idle - private val descriptions = Collections.synchronizedMap(LinkedHashMap()) + private val descriptions = + Collections.synchronizedMap(LinkedHashMap()) private val descriptionsReadOnly = Collections.unmodifiableMap(this.descriptions) private val resolved = ConcurrentHashMap() private val resolvedReadOnly = Collections.unmodifiableMap(this.resolved) @@ -87,6 +88,7 @@ class AccountProviderRegistry private constructor( this.updateDescription(newDescriptions[key]!!) } } + is AccountProviderSourceType.SourceResult.SourceFailed -> { this.eventsActual.onNext(SourceFailed(source.javaClass, result.exception)) } @@ -118,6 +120,7 @@ class AccountProviderRegistry private constructor( this.updateDescription(newDescriptions[key]!!) } } + is AccountProviderSourceType.SourceResult.SourceFailed -> { this.eventsActual.onNext(SourceFailed(source.javaClass, result.exception)) } @@ -202,20 +205,27 @@ class AccountProviderRegistry private constructor( this.updateDescription(result.result.toDescription()) taskRecorder.finishSuccess(result.result) } + is TaskResult.Failure -> taskRecorder.finishFailure() } } } taskRecorder.currentStepFailed( - "No sources can resolve the given description.", - "noApplicableSource ${description.id} ${description.title}" + message = "No sources can resolve the given description.", + errorCode = "noApplicableSource ${description.id} ${description.title}", + extraMessages = listOf() ) return taskRecorder.finishFailure() } catch (e: Exception) { this.logger.error("resolution exception: ", e) val message = e.message ?: e.javaClass.canonicalName ?: "unknown" - taskRecorder.currentStepFailedAppending(message, "unexpectedException", e) + taskRecorder.currentStepFailedAppending( + message = message, + errorCode = "unexpectedException", + exception = e, + extraMessages = listOf() + ) return taskRecorder.finishFailure() } } diff --git a/simplified-accounts-source-nyplregistry/src/main/java/org/nypl/simplified/accounts/source/nyplregistry/AccountProviderResolution.kt b/simplified-accounts-source-nyplregistry/src/main/java/org/nypl/simplified/accounts/source/nyplregistry/AccountProviderResolution.kt index 68d60ebf7..17bfbc34d 100644 --- a/simplified-accounts-source-nyplregistry/src/main/java/org/nypl/simplified/accounts/source/nyplregistry/AccountProviderResolution.kt +++ b/simplified-accounts-source-nyplregistry/src/main/java/org/nypl/simplified/accounts/source/nyplregistry/AccountProviderResolution.kt @@ -143,7 +143,8 @@ class AccountProviderResolution( taskRecorder.currentStepFailedAppending( message = this.stringResources.resolvingUnexpectedException, errorCode = unexpectedException(this.description), - exception = e + exception = e, + extraMessages = listOf() ) taskRecorder.finishFailure() } @@ -201,7 +202,11 @@ class AccountProviderResolution( } val message = this.stringResources.resolvingAuthDocumentNoStartURI - taskRecorder.currentStepFailed(message, authDocumentUnusable(this.description)) + taskRecorder.currentStepFailed( + message = message, + errorCode = authDocumentUnusable(this.description), + extraMessages = listOf() + ) onProgress.invoke(this.description.id, message) throw IOException() } @@ -252,6 +257,7 @@ class AccountProviderResolution( this.extractAuthenticationDescriptionSAML20(taskRecorder, authObject) ) } + BASIC_TOKEN_TYPE -> { authObjects.add( extractAuthenticationDescriptionBasicToken(taskRecorder, authObject) @@ -279,7 +285,11 @@ class AccountProviderResolution( } val message = this.stringResources.resolvingAuthDocumentNoUsableAuthenticationTypes - taskRecorder.currentStepFailed(message, authDocumentUnusable(this.description)) + taskRecorder.currentStepFailed( + message = message, + errorCode = authDocumentUnusable(this.description), + extraMessages = listOf() + ) throw IOException(message) } @@ -295,7 +305,11 @@ class AccountProviderResolution( val authenticateURI = authenticate?.hrefURI if (authenticateURI == null) { val message = this.stringResources.resolvingAuthDocumentSAML20Malformed - taskRecorder.currentStepFailed(message, authDocumentUnusable(this.description)) + taskRecorder.currentStepFailed( + message = message, + errorCode = authDocumentUnusable(this.description), + extraMessages = listOf() + ) throw IOException(message) } @@ -318,7 +332,11 @@ class AccountProviderResolution( val authenticateURI = authenticate?.hrefURI if (authenticateURI == null) { val message = this.stringResources.resolvingAuthDocumentOAuthMalformed - taskRecorder.currentStepFailed(message, authDocumentUnusable(this.description)) + taskRecorder.currentStepFailed( + message = message, + errorCode = authDocumentUnusable(this.description), + extraMessages = listOf() + ) throw IOException(message) } @@ -361,7 +379,11 @@ class AccountProviderResolution( val authenticateURI = authenticate?.hrefURI if (authenticateURI == null) { val message = this.stringResources.resolvingAuthDocumentBasicTokenMalformed - taskRecorder.currentStepFailed(message, authDocumentUnusable(this.description)) + taskRecorder.currentStepFailed( + message = message, + errorCode = authDocumentUnusable(this.description), + extraMessages = listOf() + ) throw IOException(message) } @@ -420,7 +442,11 @@ class AccountProviderResolution( ) } else { val message = this.stringResources.resolvingAuthDocumentCOPPAAgeGateMalformed - taskRecorder.currentStepFailed(message, authDocumentUnusable(this.description)) + taskRecorder.currentStepFailed( + message = message, + errorCode = authDocumentUnusable(this.description), + extraMessages = listOf() + ) throw IOException(message) } } @@ -472,12 +498,13 @@ class AccountProviderResolution( } else { val message = this.stringResources.resolvingAuthDocumentRetrievalFailed taskRecorder.currentStepFailed( - message, - httpRequestFailed( + message = message, + errorCode = httpRequestFailed( targetLink.hrefURI, status.properties.originalStatus, status.properties.message - ) + ), + extraMessages = listOf() ) throw IOException(message) } @@ -491,7 +518,11 @@ class AccountProviderResolution( is Link.LinkTemplated -> { val message = this.stringResources.resolvingAuthDocumentUnusableLink - taskRecorder.currentStepFailed(message, authDocumentUnusableLink(this.description)) + taskRecorder.currentStepFailed( + message = message, + errorCode = authDocumentUnusableLink(this.description), + extraMessages = listOf() + ) throw IOException(message) } } @@ -516,7 +547,11 @@ class AccountProviderResolution( parseResult.warnings.forEach { warning -> this.logger.warn("{}", warning.message) } parseResult.errors.forEach { error -> this.logger.error("{}", error.message) } val message = this.stringResources.resolvingAuthDocumentParseFailed - taskRecorder.currentStepFailed(message, authDocumentParseFailed(this.description)) + taskRecorder.currentStepFailed( + message = message, + errorCode = authDocumentParseFailed(this.description), + extraMessages = listOf() + ) throw IOException(message) } } diff --git a/simplified-app-palace/build.gradle.kts b/simplified-app-palace/build.gradle.kts index 68e1aad82..1eaef6550 100644 --- a/simplified-app-palace/build.gradle.kts +++ b/simplified-app-palace/build.gradle.kts @@ -497,7 +497,6 @@ dependencies { implementation(libs.androidx.webkit) implementation(libs.azam.ulidj) - implementation(libs.commons.compress) implementation(libs.firebase.analytics) implementation(libs.firebase.annotations) implementation(libs.firebase.common) @@ -562,6 +561,7 @@ dependencies { implementation(libs.palace.audiobook.http) implementation(libs.palace.audiobook.json.canon) implementation(libs.palace.audiobook.json.web.token) + implementation(libs.palace.audiobook.lcp.downloads) implementation(libs.palace.audiobook.lcp.license.status) implementation(libs.palace.audiobook.license.check.api) implementation(libs.palace.audiobook.license.check.spi) @@ -613,25 +613,9 @@ dependencies { implementation(libs.rxjava) implementation(libs.rxjava2) implementation(libs.rxjava2.extensions) - implementation(libs.service.wight.annotation) - implementation(libs.service.wight.core) implementation(libs.slf4j) implementation(libs.timber) implementation(libs.transport.api) implementation(libs.transport.backend.cct) implementation(libs.transport.runtime) - implementation(libs.truecommons.cio) - implementation(libs.truecommons.io) - implementation(libs.truecommons.key.disable) - implementation(libs.truecommons.key.spec) - implementation(libs.truecommons.logging) - implementation(libs.truecommons.services) - implementation(libs.truecommons.shed) - implementation(libs.truevfs.access) - implementation(libs.truevfs.comp.zip) - implementation(libs.truevfs.comp.zipdriver) - implementation(libs.truevfs.driver.file) - implementation(libs.truevfs.driver.zip) - implementation(libs.truevfs.kernel.impl) - implementation(libs.truevfs.kernel.spec) } diff --git a/simplified-books-api/src/main/java/org/nypl/simplified/books/api/BookDRMInformation.kt b/simplified-books-api/src/main/java/org/nypl/simplified/books/api/BookDRMInformation.kt index b70043f91..f550544dd 100644 --- a/simplified-books-api/src/main/java/org/nypl/simplified/books/api/BookDRMInformation.kt +++ b/simplified-books-api/src/main/java/org/nypl/simplified/books/api/BookDRMInformation.kt @@ -61,8 +61,15 @@ sealed class BookDRMInformation : Serializable { * The hashed LCP passphrase for the book. */ - val hashedPassphrase: String? + val hashedPassphrase: String?, + + /** + * The bytes of the LCP license. + */ + + val licenseBytes: ByteArray? ) : BookDRMInformation() { + override fun playerCredentials(): PlayerBookCredentialsType { return if (this.hashedPassphrase != null) { PlayerBookCredentialsLCP(this.hashedPassphrase) diff --git a/simplified-books-api/src/main/java/org/nypl/simplified/books/api/BookFormat.kt b/simplified-books-api/src/main/java/org/nypl/simplified/books/api/BookFormat.kt index b94a851f0..9af67dc78 100644 --- a/simplified-books-api/src/main/java/org/nypl/simplified/books/api/BookFormat.kt +++ b/simplified-books-api/src/main/java/org/nypl/simplified/books/api/BookFormat.kt @@ -84,7 +84,7 @@ sealed class BookFormat { * The URI that can be used to fetch a more recent copy of the manifest. */ - val manifestURI: URI, + val manifestURI: URI?, /** * The most recent copy of the audio book manifest, if any has been fetched. @@ -112,6 +112,7 @@ sealed class BookFormat { * only the manifest is downloaded, this will always be null. */ + @Deprecated("Packaged audiobooks are no longer handled by the application directly.") val file: File?, /** diff --git a/simplified-books-audio/build.gradle.kts b/simplified-books-audio/build.gradle.kts index 5ce2944ce..b0a331acc 100644 --- a/simplified-books-audio/build.gradle.kts +++ b/simplified-books-audio/build.gradle.kts @@ -1,9 +1,12 @@ dependencies { + implementation(project(":simplified-accounts-api")) implementation(project(":simplified-books-database-api")) implementation(project(":simplified-presentableerror-api")) implementation(project(":simplified-services-api")) implementation(project(":simplified-taskrecorder-api")) + implementation(libs.androidx.constraintlayout) + implementation(libs.androidx.constraintlayout.core) implementation(libs.google.guava) implementation(libs.io7m.junreachable) implementation(libs.irradia.mime.api) @@ -13,6 +16,7 @@ dependencies { implementation(libs.palace.audiobook.api) implementation(libs.palace.audiobook.downloads) implementation(libs.palace.audiobook.feedbooks) + implementation(libs.palace.audiobook.lcp.downloads) implementation(libs.palace.audiobook.license.check.api) implementation(libs.palace.audiobook.license.check.spi) implementation(libs.palace.audiobook.manifest.api) @@ -25,6 +29,8 @@ dependencies { implementation(libs.palace.audiobook.manifest.parser.webpub) implementation(libs.palace.audiobook.parser.api) implementation(libs.palace.http.api) + implementation(libs.palace.http.downloads) + implementation(libs.r2.lcp) implementation(libs.r2.shared) implementation(libs.rxjava2) implementation(libs.slf4j) diff --git a/simplified-books-audio/src/main/java/org/nypl/simplified/books/audio/AbstractAudioBookManifestStrategy.kt b/simplified-books-audio/src/main/java/org/nypl/simplified/books/audio/AbstractAudioBookManifestStrategy.kt deleted file mode 100644 index 3b2a6be6c..000000000 --- a/simplified-books-audio/src/main/java/org/nypl/simplified/books/audio/AbstractAudioBookManifestStrategy.kt +++ /dev/null @@ -1,282 +0,0 @@ -package org.nypl.simplified.books.audio - -import android.app.Application -import io.reactivex.Observable -import io.reactivex.subjects.PublishSubject -import one.irradia.mime.api.MIMEType -import org.librarysimplified.audiobook.api.PlayerResult -import org.librarysimplified.audiobook.license_check.api.LicenseCheckParameters -import org.librarysimplified.audiobook.license_check.api.LicenseCheckResult -import org.librarysimplified.audiobook.license_check.api.LicenseChecks -import org.librarysimplified.audiobook.license_check.spi.SingleLicenseCheckResult -import org.librarysimplified.audiobook.license_check.spi.SingleLicenseCheckStatus -import org.librarysimplified.audiobook.manifest.api.PlayerManifest -import org.librarysimplified.audiobook.manifest_fulfill.spi.ManifestFulfilled -import org.librarysimplified.audiobook.manifest_fulfill.spi.ManifestFulfillmentErrorType -import org.librarysimplified.audiobook.parser.api.ParseError -import org.librarysimplified.audiobook.parser.api.ParseResult -import org.librarysimplified.audiobook.parser.api.ParseWarning -import org.librarysimplified.http.api.LSHTTPAuthorizationType -import org.nypl.simplified.taskrecorder.api.TaskRecorder -import org.nypl.simplified.taskrecorder.api.TaskRecorderType -import org.nypl.simplified.taskrecorder.api.TaskResult -import org.slf4j.LoggerFactory -import java.net.URI - -/** - * An audio book manifest strategy that: - * 1. Fulfills the manifest, and fails if the manifest cannot be fulfilled. - * 2. Parses the fulfilled manifest, and fails if the manifest cannot be parsed. - * 3. Performs any requested license checks on the fulfilled manifest, and fails if any license - * checks fail. - */ - -abstract class AbstractAudioBookManifestStrategy( - private val context: Application, - private val request: AudioBookManifestRequest -) : AudioBookManifestStrategyType { - - private val logger = - LoggerFactory.getLogger(AbstractAudioBookManifestStrategy::class.java) - - protected val eventSubject = - PublishSubject.create() - - override val events: Observable = - this.eventSubject - - /** - * Fulfill the manifest. Subclasses must implement this method to obtain the manifest. - */ - - abstract fun fulfill( - taskRecorder: TaskRecorderType - ): PlayerResult - - override fun execute(): TaskResult { - val taskRecorder = TaskRecorder.create() - - try { - val fulfillResult = this.fulfill(taskRecorder) - - if (fulfillResult is PlayerResult.Failure) { - taskRecorder.currentStepFailed( - message = fulfillResult.failure.message, - errorCode = formatServerData( - message = fulfillResult.failure.message, - serverData = fulfillResult.failure.serverData - ) - ) - return taskRecorder.finishFailure() - } - - taskRecorder.beginNewStep("Parsing manifest…") - val (contentType, authorization, downloadBytes) = (fulfillResult as PlayerResult.Success).result - val parseResult = this.parseManifest(this.request.targetURI, downloadBytes) - if (parseResult is ParseResult.Failure) { - taskRecorder.currentStepFailed( - formatParseErrorsAndWarnings(parseResult.warnings, parseResult.errors), - "" - ) - return taskRecorder.finishFailure() - } - - taskRecorder.beginNewStep("Checking license…") - val (_, parsedManifest) = parseResult as ParseResult.Success - val checkResult = this.checkManifest(parsedManifest) - if (!checkResult.checkSucceeded()) { - taskRecorder.currentStepFailed( - formatLicenseCheckStatuses(checkResult.checkStatuses), - "" - ) - return taskRecorder.finishFailure() - } - - return this.finish( - parsedManifest = parsedManifest, - downloadBytes = downloadBytes, - contentType = contentType, - authorization = authorization, - taskRecorder = taskRecorder - ) - } catch (e: Exception) { - this.logger.error("error during fulfillment: ", e) - val message = e.message ?: e.javaClass.name - taskRecorder.currentStepFailedAppending(message, message, e) - return taskRecorder.finishFailure() - } - } - - private fun formatLicenseCheckStatuses(statuses: List): String { - return buildString { - this.append("One or more license checks failed.") - this.append('\n') - - for (status in statuses) { - this.append(status.shortName) - this.append(": ") - this.append(status.message) - this.append('\n') - } - } - } - - private fun formatParseErrorsAndWarnings( - warnings: List, - errors: List - ): String { - return buildString { - this.append("Manifest parsing failed.") - this.append('\n') - - for (warning in warnings) { - this.append("Warning: ") - this.append(warning.source) - this.append(": ") - this.append(warning.line) - this.append(':') - this.append(warning.column) - this.append(": ") - this.append(warning.message) - this.append('\n') - } - - for (error in errors) { - this.append("Error: ") - this.append(error.source) - this.append(": ") - this.append(error.line) - this.append(':') - this.append(error.column) - this.append(": ") - this.append(error.message) - this.append('\n') - } - } - } - - private fun formatServerData( - message: String, - serverData: ManifestFulfillmentErrorType.ServerData? - ): String { - return buildString { - this.append(message) - this.append('\n') - - if (serverData != null) { - this.append("Server URI: ") - this.append(serverData.uri) - this.append('\n') - - this.append("Server status: ") - this.append(serverData.code) - this.append('\n') - - if (serverData.receivedContentType == "application/api-problem+json") { - val parser = - request.problemReportParsers.createParser( - uri = serverData.uri.toString(), - stream = serverData.receivedBody.inputStream() - ) - - try { - val report = parser.execute() - this.append("Status: ") - this.append(report.status) - this.append('\n') - - this.append("Type: ") - this.append(report.type) - this.append('\n') - - this.append("Title: ") - this.append(report.title) - this.append('\n') - - this.append("Detail: ") - this.append(report.detail) - this.append('\n') - - logger.error("status: {}", report.status) - logger.error("type: {}", report.type) - logger.error("title: {}", report.title) - logger.error("detail: {}", report.detail) - } catch (e: Exception) { - logger.error("unparseable problem report: ", e) - } - } - } - } - } - - private fun finish( - parsedManifest: PlayerManifest, - downloadBytes: ByteArray, - contentType: MIMEType, - authorization: LSHTTPAuthorizationType?, - taskRecorder: TaskRecorderType - ): TaskResult { - return taskRecorder.finishSuccess( - AudioBookManifestData( - manifest = parsedManifest, - fulfilled = ManifestFulfilled( - contentType = contentType, - authorization = authorization, - data = downloadBytes - ) - ) - ) - } - - /** - * Attempt to parse a manifest file. - */ - - private fun parseManifest( - source: URI, - data: ByteArray - ): ParseResult { - this.logger.debug("parseManifest") - return this.request.manifestParsers.parse( - uri = source, - streams = data, - extensions = this.request.extensions - ) - } - - /** - * Attempt to perform any required license checks on the manifest. - */ - - private fun checkManifest( - manifest: PlayerManifest - ): LicenseCheckResult { - this.logger.debug("checkManifest") - - val check = - LicenseChecks.createLicenseCheck( - LicenseCheckParameters( - manifest = manifest, - userAgent = this.request.userAgent, - checks = this.request.licenseChecks, - cacheDirectory = this.request.cacheDirectory - ) - ) - - val checkSubscription = - check.events.subscribe { event -> - this.onLicenseCheckEvent(event) - } - - try { - return check.execute() - } finally { - checkSubscription.dispose() - } - } - - private fun onLicenseCheckEvent(event: SingleLicenseCheckStatus) { - this.logger.debug("onLicenseCheckEvent: {}: {}", event.source, event.message) - this.eventSubject.onNext(event.message) - } -} diff --git a/simplified-books-audio/src/main/java/org/nypl/simplified/books/audio/AudioBookCredentials.kt b/simplified-books-audio/src/main/java/org/nypl/simplified/books/audio/AudioBookCredentials.kt deleted file mode 100644 index b9694e9bc..000000000 --- a/simplified-books-audio/src/main/java/org/nypl/simplified/books/audio/AudioBookCredentials.kt +++ /dev/null @@ -1,43 +0,0 @@ -package org.nypl.simplified.books.audio - -import com.google.common.base.Preconditions - -/** - * Credentials used when fulfilling audio books. - */ - -sealed class AudioBookCredentials { - - /** - * Credentials represented by a username and password. - */ - - data class UsernamePassword( - val userName: String, - val password: String - ) : AudioBookCredentials() { - init { - Preconditions.checkArgument( - password.isNotBlank(), - "Password cannot be empty/blank (Use the UsernameOnly) class." - ) - } - } - - /** - * Credentials represented by a username. Some audio book backends require an explicit - * indication that there is no password. This class must be used to provide that indication. - */ - - data class UsernameOnly( - val userName: String - ) : AudioBookCredentials() - - /** - * Credentials represented by a bearer token. - */ - - data class BearerToken( - val accessToken: String - ) : AudioBookCredentials() -} diff --git a/simplified-books-audio/src/main/java/org/nypl/simplified/books/audio/AudioBookLink.kt b/simplified-books-audio/src/main/java/org/nypl/simplified/books/audio/AudioBookLink.kt new file mode 100644 index 000000000..401ce91ed --- /dev/null +++ b/simplified-books-audio/src/main/java/org/nypl/simplified/books/audio/AudioBookLink.kt @@ -0,0 +1,16 @@ +package org.nypl.simplified.books.audio + +import java.net.URI + +sealed class AudioBookLink { + + abstract val target: URI + + data class Manifest( + override val target: URI + ) : AudioBookLink() + + data class License( + override val target: URI + ) : AudioBookLink() +} diff --git a/simplified-books-audio/src/main/java/org/nypl/simplified/books/audio/AudioBookManifestData.kt b/simplified-books-audio/src/main/java/org/nypl/simplified/books/audio/AudioBookManifestData.kt index 5e43723fa..30980568d 100644 --- a/simplified-books-audio/src/main/java/org/nypl/simplified/books/audio/AudioBookManifestData.kt +++ b/simplified-books-audio/src/main/java/org/nypl/simplified/books/audio/AudioBookManifestData.kt @@ -15,6 +15,12 @@ data class AudioBookManifestData( val manifest: PlayerManifest, + /** + * The bytes of the audiobook license, if present. + */ + + val licenseBytes: ByteArray?, + /** * The original fulfilled manifest, suitable for writing to external storage. */ diff --git a/simplified-books-audio/src/main/java/org/nypl/simplified/books/audio/AudioBookManifestFulfillmentAdapter.kt b/simplified-books-audio/src/main/java/org/nypl/simplified/books/audio/AudioBookManifestFulfillmentAdapter.kt deleted file mode 100644 index fb3ac96b2..000000000 --- a/simplified-books-audio/src/main/java/org/nypl/simplified/books/audio/AudioBookManifestFulfillmentAdapter.kt +++ /dev/null @@ -1,47 +0,0 @@ -package org.nypl.simplified.books.audio - -import io.reactivex.Observable -import org.librarysimplified.audiobook.api.PlayerResult -import org.librarysimplified.audiobook.manifest_fulfill.spi.ManifestFulfilled -import org.librarysimplified.audiobook.manifest_fulfill.spi.ManifestFulfillmentErrorType -import org.librarysimplified.audiobook.manifest_fulfill.spi.ManifestFulfillmentEvent -import org.librarysimplified.audiobook.manifest_fulfill.spi.ManifestFulfillmentStrategyType -import org.nypl.simplified.taskrecorder.api.TaskResult - -class AudioBookManifestFulfillmentAdapter( - val strategy: AudioBookManifestStrategyType -) : ManifestFulfillmentStrategyType { - - private var resultField: TaskResult = - TaskResult.fail( - "Not yet executed.", - "Not yet executed.", - "error-not-executed" - ) - - val result: TaskResult - get() = this.resultField - - override val events: Observable - get() = this.strategy.events.map(::ManifestFulfillmentEvent) - - override fun close() { - // Nothing to close - } - - override fun execute(): PlayerResult { - this.resultField = this.strategy.execute() - - return when (val result = this.strategy.execute()) { - is TaskResult.Failure -> { - PlayerResult.Failure( - AudioBookManifestFulfillmentError(result) - ) - } - - is TaskResult.Success -> { - PlayerResult.Success(result.result.fulfilled) - } - } - } -} diff --git a/simplified-books-audio/src/main/java/org/nypl/simplified/books/audio/AudioBookManifestFulfillmentError.kt b/simplified-books-audio/src/main/java/org/nypl/simplified/books/audio/AudioBookManifestFulfillmentError.kt deleted file mode 100644 index f7a17a71e..000000000 --- a/simplified-books-audio/src/main/java/org/nypl/simplified/books/audio/AudioBookManifestFulfillmentError.kt +++ /dev/null @@ -1,15 +0,0 @@ -package org.nypl.simplified.books.audio - -import org.librarysimplified.audiobook.manifest_fulfill.spi.ManifestFulfillmentErrorType -import org.nypl.simplified.taskrecorder.api.TaskResult - -class AudioBookManifestFulfillmentError( - val taskFailure: TaskResult.Failure -) : ManifestFulfillmentErrorType { - - override val message: String - get() = taskFailure.message - - override val serverData: ManifestFulfillmentErrorType.ServerData? - get() = null -} diff --git a/simplified-books-audio/src/main/java/org/nypl/simplified/books/audio/AudioBookManifestRequest.kt b/simplified-books-audio/src/main/java/org/nypl/simplified/books/audio/AudioBookManifestRequest.kt index 23a27c732..e1ea8b631 100644 --- a/simplified-books-audio/src/main/java/org/nypl/simplified/books/audio/AudioBookManifestRequest.kt +++ b/simplified-books-audio/src/main/java/org/nypl/simplified/books/audio/AudioBookManifestRequest.kt @@ -9,11 +9,14 @@ import org.librarysimplified.audiobook.manifest_fulfill.spi.ManifestFulfilled import org.librarysimplified.audiobook.manifest_parser.api.ManifestParsers import org.librarysimplified.audiobook.manifest_parser.api.ManifestParsersType import org.librarysimplified.audiobook.manifest_parser.extension_spi.ManifestParserExtensionType +import org.librarysimplified.http.api.LSHTTPClientType import org.librarysimplified.http.api.LSHTTPProblemReportParserFactoryType import org.librarysimplified.services.api.ServiceDirectoryType +import org.nypl.simplified.accounts.api.AccountAuthenticationCredentials import java.io.File -import java.net.URI +import java.io.IOException import java.util.ServiceLoader +import java.util.UUID /** * A request to fulfill, parse, and license check an audio book manifest. @@ -22,21 +25,16 @@ import java.util.ServiceLoader data class AudioBookManifestRequest( /** - * The audio book file on disk, if one has been downloaded. This is used for packaged audio - * books, where the entire book is downloaded in one file. For unpackaged audio books, where - * only the manifest is downloaded, this will always be null. + * The HTTP client. */ - val file: File? = null, + val httpClient: LSHTTPClientType, /** - * The target URI of the manifest. This must be a stable URI that can be accessed repeatedly - * in an idempotent manner. In practice, this will be either: - * - The "fulfill" URI provided by the Circulation Manager, for unpackaged audio books. - * - The path to the manifest file in the archive, for packaged audio books. + * The target link */ - val targetURI: URI, + val target: AudioBookLink, /** * The content type of the target manifest. @@ -51,10 +49,10 @@ data class AudioBookManifestRequest( val userAgent: PlayerUserAgent, /** - * The credentials used for any requests. + * The credentials used for license and manifest requests. */ - val credentials: AudioBookCredentials?, + val credentials: AccountAuthenticationCredentials?, /** * A service directory used to locate any required application services. @@ -122,4 +120,19 @@ data class AudioBookManifestRequest( val problemReportParsers: LSHTTPProblemReportParserFactoryType = ServiceLoader.load(LSHTTPProblemReportParserFactoryType::class.java) .first() -) +) { + + fun temporaryFile( + extension: String + ): File { + val ext = if (extension.isNotEmpty()) ".$extension" else "" + this.cacheDirectory.mkdirs() + for (i in 0..100) { + val file = File(this.cacheDirectory, "${UUID.randomUUID()}$ext") + if (!file.exists()) { + return file + } + } + throw IOException("Could not create a temporary file within 100 attempts!") + } +} diff --git a/simplified-books-audio/src/main/java/org/nypl/simplified/books/audio/AudioBookManifestStrategiesType.kt b/simplified-books-audio/src/main/java/org/nypl/simplified/books/audio/AudioBookManifestStrategiesType.kt index 62e90f666..a2d77dbf1 100644 --- a/simplified-books-audio/src/main/java/org/nypl/simplified/books/audio/AudioBookManifestStrategiesType.kt +++ b/simplified-books-audio/src/main/java/org/nypl/simplified/books/audio/AudioBookManifestStrategiesType.kt @@ -17,5 +17,5 @@ interface AudioBookManifestStrategiesType { fun createStrategy( context: Application, request: AudioBookManifestRequest - ): AudioBookManifestStrategyType + ): AudioBookStrategyType } diff --git a/simplified-books-audio/src/main/java/org/nypl/simplified/books/audio/AudioBookManifests.kt b/simplified-books-audio/src/main/java/org/nypl/simplified/books/audio/AudioBookManifests.kt index e92713ebf..f7cd431fb 100644 --- a/simplified-books-audio/src/main/java/org/nypl/simplified/books/audio/AudioBookManifests.kt +++ b/simplified-books-audio/src/main/java/org/nypl/simplified/books/audio/AudioBookManifests.kt @@ -10,11 +10,7 @@ object AudioBookManifests : AudioBookManifestStrategiesType { override fun createStrategy( context: Application, request: AudioBookManifestRequest - ): AudioBookManifestStrategyType { - return if (request.file != null) { - PackagedAudioBookManifestStrategy(context, request) - } else { - UnpackagedAudioBookManifestStrategy(context, request) - } + ): AudioBookStrategyType { + return AudioBookStrategy(context, request) } } diff --git a/simplified-books-audio/src/main/java/org/nypl/simplified/books/audio/AudioBookStrategy.kt b/simplified-books-audio/src/main/java/org/nypl/simplified/books/audio/AudioBookStrategy.kt new file mode 100644 index 000000000..d25454631 --- /dev/null +++ b/simplified-books-audio/src/main/java/org/nypl/simplified/books/audio/AudioBookStrategy.kt @@ -0,0 +1,604 @@ +package org.nypl.simplified.books.audio + +import android.app.Application +import io.reactivex.Observable +import io.reactivex.subjects.PublishSubject +import one.irradia.mime.api.MIMEType +import org.librarysimplified.audiobook.api.PlayerResult +import org.librarysimplified.audiobook.lcp.downloads.LCPDownloads +import org.librarysimplified.audiobook.license_check.api.LicenseCheckParameters +import org.librarysimplified.audiobook.license_check.api.LicenseCheckResult +import org.librarysimplified.audiobook.license_check.api.LicenseChecks +import org.librarysimplified.audiobook.license_check.spi.SingleLicenseCheckStatus +import org.librarysimplified.audiobook.manifest.api.PlayerManifest +import org.librarysimplified.audiobook.manifest_fulfill.basic.ManifestFulfillmentBasicCredentials +import org.librarysimplified.audiobook.manifest_fulfill.basic.ManifestFulfillmentBasicParameters +import org.librarysimplified.audiobook.manifest_fulfill.basic.ManifestFulfillmentBasicType +import org.librarysimplified.audiobook.manifest_fulfill.opa.OPAManifestFulfillmentStrategyProviderType +import org.librarysimplified.audiobook.manifest_fulfill.opa.OPAManifestURI +import org.librarysimplified.audiobook.manifest_fulfill.opa.OPAParameters +import org.librarysimplified.audiobook.manifest_fulfill.opa.OPAPassword +import org.librarysimplified.audiobook.manifest_fulfill.spi.ManifestFulfilled +import org.librarysimplified.audiobook.manifest_fulfill.spi.ManifestFulfillmentError +import org.librarysimplified.audiobook.manifest_fulfill.spi.ManifestFulfillmentEvent +import org.librarysimplified.audiobook.manifest_fulfill.spi.ManifestFulfillmentStrategyType +import org.librarysimplified.audiobook.parser.api.ParseError +import org.librarysimplified.audiobook.parser.api.ParseResult +import org.librarysimplified.audiobook.parser.api.ParseWarning +import org.librarysimplified.http.api.LSHTTPClientType +import org.librarysimplified.http.api.LSHTTPProblemReport +import org.librarysimplified.http.downloads.LSHTTPDownloadRequest +import org.librarysimplified.http.downloads.LSHTTPDownloadState.LSHTTPDownloadResult.DownloadCancelled +import org.librarysimplified.http.downloads.LSHTTPDownloadState.LSHTTPDownloadResult.DownloadCompletedSuccessfully +import org.librarysimplified.http.downloads.LSHTTPDownloadState.LSHTTPDownloadResult.DownloadFailed.DownloadFailedExceptionally +import org.librarysimplified.http.downloads.LSHTTPDownloadState.LSHTTPDownloadResult.DownloadFailed.DownloadFailedServer +import org.librarysimplified.http.downloads.LSHTTPDownloadState.LSHTTPDownloadResult.DownloadFailed.DownloadFailedUnacceptableMIME +import org.librarysimplified.http.downloads.LSHTTPDownloads +import org.nypl.simplified.accounts.api.AccountAuthenticatedHTTP.createAuthorizationIfPresent +import org.nypl.simplified.accounts.api.AccountAuthenticationCredentials +import org.nypl.simplified.books.book_database.api.BookFormats +import org.nypl.simplified.taskrecorder.api.TaskRecorder +import org.nypl.simplified.taskrecorder.api.TaskRecorderType +import org.nypl.simplified.taskrecorder.api.TaskResult +import org.nypl.simplified.taskrecorder.api.TaskStepResolution +import org.readium.r2.lcp.license.model.LicenseDocument +import org.slf4j.LoggerFactory +import java.net.URI + +class AudioBookStrategy( + private val context: Application, + private val request: AudioBookManifestRequest +) : AudioBookStrategyType { + + private val logger = + LoggerFactory.getLogger(AudioBookStrategy::class.java) + + private val eventSubject = + PublishSubject.create() + + private val taskRecorder: TaskRecorderType = + TaskRecorder.create() + + override val events: Observable = + this.eventSubject + + override fun execute(): TaskResult { + return try { + if (this.request.isNetworkAvailable.invoke()) { + this.fulfillLink() + } else { + this.fulfillFromFallback() + } + } catch (e: Exception) { + this.logger.error("Error during fulfillment: ", e) + val message = e.message ?: e.javaClass.name + this.taskRecorder.currentStepFailedAppending( + message = message, + errorCode = message, + exception = e, + extraMessages = listOf() + ) + this.taskRecorder.finishFailure() + } + } + + private class AsManifestFulfillment( + val owner: AudioBookStrategy + ) : ManifestFulfillmentStrategyType { + + private val eventSubject: PublishSubject = + PublishSubject.create() + + override val events: Observable + get() = this.eventSubject + + override fun close() { + this.eventSubject.onComplete() + } + + override fun execute(): PlayerResult { + return when (val r = this.owner.execute()) { + is TaskResult.Failure -> { + PlayerResult.Failure( + ManifestFulfillmentError( + message = r.message, + extraMessages = + r.steps.filter { s -> s.resolution is TaskStepResolution.TaskStepFailed } + .map { s -> s.resolution.message }, + serverData = null + ) + ) + } + + is TaskResult.Success -> { + PlayerResult.Success(r.result.fulfilled) + } + } + } + } + + override fun toManifestStrategy(): ManifestFulfillmentStrategyType { + return AsManifestFulfillment(this) + } + + private fun fulfillFromFallback(): TaskResult { + this.taskRecorder.beginNewStep("Loading manifest data from fallback.") + + val data = this.request.loadFallbackData.invoke() + if (data == null) { + this.taskRecorder.currentStepFailed( + message = "No network is available, and no fallback data is available", + errorCode = "errorNoFallback", + extraMessages = listOf() + ) + return this.taskRecorder.finishFailure() + } + + this.taskRecorder.currentStepSucceeded("Fallback data loaded successfully.") + return this.parseManifest( + source = data.source, + licenseBytes = null, + manifestBytes = data.data + ) + } + + private fun fulfillLink(): TaskResult { + return when (val target = this.request.target) { + is AudioBookLink.License -> this.fulfillLinkLicense(target) + is AudioBookLink.Manifest -> this.fulfillLinkManifest(target) + } + } + + private fun fulfillLinkManifest( + target: AudioBookLink.Manifest + ): TaskResult { + this.taskRecorder.beginNewStep("Retrieving manifest file.") + + val strategy = this.findExistingStrategy(target.target) + strategy.events.subscribe { event -> this.eventSubject.onNext(event.message) } + + return when (val result = strategy.execute()) { + is PlayerResult.Failure -> { + this.recordProblemReport(result.failure.serverData?.problemReport) + this.taskRecorder.currentStepFailed( + message = result.failure.message, + errorCode = "errorDownloadFailed", + extraMessages = result.failure.extraMessages + ) + this.taskRecorder.finishFailure() + } + + is PlayerResult.Success -> { + this.taskRecorder.currentStepSucceeded("Manifest retrieval succeeded.") + this.parseManifest( + source = target.target, + licenseBytes = null, + manifestBytes = result.result.data + ) + } + } + } + + private fun fulfillLinkLicense( + target: AudioBookLink.License + ): TaskResult { + this.taskRecorder.beginNewStep("Retrieving license file.") + + val httpRequest = + this.request.httpClient.newRequest(target.target) + .setAuthorization(createAuthorizationIfPresent(this.request.credentials)) + .build() + + val temporaryFile = + this.request.temporaryFile(".lcpl") + + try { + val downloadRequest = + LSHTTPDownloadRequest( + request = httpRequest, + outputFile = temporaryFile, + onEvent = { event -> + this.logger.debug("Download event: {}", event) + }, + isCancelled = { false } + ) + + return when (val result = LSHTTPDownloads.download(downloadRequest)) { + is DownloadCompletedSuccessfully -> { + this.taskRecorder.currentStepSucceeded("Download succeeded.") + this.taskRecorder.beginNewStep("Parsing LCP license.") + + val licenseBytes = temporaryFile.readBytes() + when (val licenseResult = LCPDownloads.parseLicense(licenseBytes)) { + is PlayerResult.Failure -> { + this.taskRecorder.currentStepFailed( + message = licenseResult.failure.message, + errorCode = "errorLicenseParse", + extraMessages = licenseResult.failure.extraMessages + ) + this.recordProblemReport(licenseResult.failure.serverData?.problemReport) + this.taskRecorder.finishFailure() + } + + is PlayerResult.Success -> { + this.taskRecorder.currentStepSucceeded("License parsed.") + this.fulfillManifestFromLicense( + licenseBytes = licenseBytes, + license = licenseResult.result.license + ) + } + } + } + + DownloadCancelled -> { + this.taskRecorder.currentStepFailed( + message = "Download cancelled.", + errorCode = "cancelled", + extraMessages = listOf() + ) + this.recordProblemReport(result.responseStatus?.properties?.problemReport) + this.taskRecorder.finishFailure() + } + + is DownloadFailedExceptionally -> { + this.taskRecorder.currentStepFailed( + message = "Request failed.", + errorCode = "errorException", + result.exception, + extraMessages = listOf() + ) + this.recordProblemReport(result.responseStatus?.properties?.problemReport) + this.taskRecorder.finishFailure() + } + + is DownloadFailedServer -> { + this.taskRecorder.currentStepFailed( + message = "Request failed.", + errorCode = "errorRequest", + extraMessages = listOf() + ) + this.recordProblemReport(result.responseStatus.properties.problemReport) + this.taskRecorder.finishFailure() + } + + is DownloadFailedUnacceptableMIME -> { + this.taskRecorder.currentStepFailed( + message = "Server returned unexpected MIME type.", + errorCode = "errorMime", + extraMessages = listOf() + ) + this.recordProblemReport(result.responseStatus.properties?.problemReport) + this.taskRecorder.finishFailure() + } + } + } finally { + temporaryFile.delete() + } + } + + private fun recordProblemReport( + report: LSHTTPProblemReport? + ) { + if (report != null) { + this.taskRecorder.addAttributes(report.toMap()) + } + } + + private fun fulfillManifestFromLicense( + licenseBytes: ByteArray, + license: LicenseDocument + ): TaskResult { + this.taskRecorder.beginNewStep("Retrieving manifest via LCP license.") + + val credentials: ManifestFulfillmentBasicCredentials? = + when (val c = this.request.credentials) { + is AccountAuthenticationCredentials.Basic -> { + ManifestFulfillmentBasicCredentials( + userName = c.userName.value, + password = c.password.value + ) + } + + is AccountAuthenticationCredentials.BasicToken -> { + ManifestFulfillmentBasicCredentials( + userName = c.userName.value, + password = c.password.value + ) + } + + is AccountAuthenticationCredentials.OAuthWithIntermediary -> { + this.taskRecorder.currentStepFailed( + message = "Using bearer token authentication to retrieve manifests from licenses is not yet supported.", + errorCode = "errorUnsupported", + extraMessages = listOf() + ) + return this.taskRecorder.finishFailure() + } + + is AccountAuthenticationCredentials.SAML2_0 -> { + this.taskRecorder.currentStepFailed( + message = "Using bearer token authentication to retrieve manifests from licenses is not yet supported.", + errorCode = "errorUnsupported", + extraMessages = listOf() + ) + return this.taskRecorder.finishFailure() + } + + null -> { + null + } + } + + return when (val result = LCPDownloads.downloadManifestFromPublication( + context = this.context, + credentials = credentials, + license = license, + receiver = { event -> + this.logger.debug("Downloading manifest event: {}", event) + } + )) { + is PlayerResult.Failure -> { + this.recordProblemReport(result.failure.serverData?.problemReport) + this.taskRecorder.currentStepFailed( + message = result.failure.message, + errorCode = "httpRequestFailed", + extraMessages = result.failure.extraMessages + ) + this.taskRecorder.finishFailure() + } + + is PlayerResult.Success -> { + this.taskRecorder.currentStepSucceeded("Successfully retrieved manifest.") + return this.parseManifest( + source = result.result.source, + licenseBytes = licenseBytes, + manifestBytes = result.result.data + ) + } + } + } + + /** + * Attempt to parse a manifest file. + */ + + private fun parseManifest( + source: URI?, + licenseBytes: ByteArray?, + manifestBytes: ByteArray + ): TaskResult { + this.taskRecorder.beginNewStep("Parsing manifest.") + return when (val result = this.request.manifestParsers.parse( + uri = source ?: URI.create("urn:unavailable"), + streams = manifestBytes, + extensions = this.request.extensions + )) { + is ParseResult.Failure -> { + this.taskRecorder.currentStepFailed( + message = "Manifest parsing failed.", + errorCode = "parseError", + extraMessages = + this.formatParseWarnings(result.warnings) + .plus(this.formatParseErrors(result.errors)) + ) + return this.taskRecorder.finishFailure() + } + + is ParseResult.Success -> { + this.taskRecorder.currentStepSucceeded("Manifest parsed successfully.") + this.taskRecorder.finishSuccess( + AudioBookManifestData( + result.result, + licenseBytes = licenseBytes, + fulfilled = ManifestFulfilled( + source = source, + contentType = MIMEType("text", "json", mapOf()), + authorization = null, + data = manifestBytes + ) + ) + ) + } + } + } + + private fun formatParseWarnings(warnings: List): List { + return warnings.map { warning -> + buildString { + this.append("Warning: ") + this.append(warning.source) + this.append(": ") + this.append(warning.line) + this.append(':') + this.append(warning.column) + this.append(": ") + this.append(warning.message) + this.append('\n') + } + } + } + + private fun formatParseErrors(errors: List): List { + return errors.map { error -> + buildString { + this.append("Error: ") + this.append(error.source) + this.append(": ") + this.append(error.line) + this.append(':') + this.append(error.column) + this.append(": ") + this.append(error.message) + this.append('\n') + } + } + } + + /** + * Attempt to perform any required license checks on the manifest. + */ + + private fun checkManifest( + manifest: PlayerManifest + ): LicenseCheckResult { + this.logger.debug("checkManifest") + + val check = + LicenseChecks.createLicenseCheck( + LicenseCheckParameters( + manifest = manifest, + userAgent = this.request.userAgent, + checks = this.request.licenseChecks, + cacheDirectory = this.request.cacheDirectory + ) + ) + + val checkSubscription = + check.events.subscribe { event -> + this.onLicenseCheckEvent(event) + } + + try { + return check.execute() + } finally { + checkSubscription.dispose() + } + } + + private fun onLicenseCheckEvent(event: SingleLicenseCheckStatus) { + this.logger.debug("onLicenseCheckEvent: {}: {}", event.source, event.message) + this.eventSubject.onNext(event.message) + } + + private fun findExistingStrategy( + targetURI: URI + ): ManifestFulfillmentStrategyType { + return if (this.isOverdrive()) { + this.logger.debug("findExistingStrategy: trying an Overdrive strategy") + + val secretService = + this.request.services.optionalService( + AudioBookOverdriveSecretServiceType::class.java + ) ?: throw UnsupportedOperationException("No Overdrive secret service is available") + + val strategies = + this.request.strategyRegistry.findStrategy( + OPAManifestFulfillmentStrategyProviderType::class.java + ) ?: throw UnsupportedOperationException("No Overdrive fulfillment strategy is available") + + val parameters = + when (val credentials = this.request.credentials) { + is AccountAuthenticationCredentials.Basic -> { + val password = credentials.password.value + val opaPassword = if (password.isBlank()) { + OPAPassword.NotRequired + } else { + OPAPassword.Password(password) + } + + OPAParameters( + userName = credentials.userName.value, + password = opaPassword, + clientKey = secretService.clientKey.orEmpty(), + clientPass = secretService.clientPass.orEmpty(), + targetURI = OPAManifestURI.Indirect(targetURI), + userAgent = this.request.userAgent + ) + } + + is AccountAuthenticationCredentials.BasicToken -> { + val password = credentials.password.value + val opaPassword = if (password.isBlank()) { + OPAPassword.NotRequired + } else { + OPAPassword.Password(password) + } + + OPAParameters( + userName = credentials.userName.value, + password = opaPassword, + clientKey = secretService.clientKey.orEmpty(), + clientPass = secretService.clientPass.orEmpty(), + targetURI = OPAManifestURI.Indirect(targetURI), + userAgent = this.request.userAgent + ) + } + + is AccountAuthenticationCredentials.OAuthWithIntermediary -> { + throw UnsupportedOperationException( + "Can't use bearer tokens for Overdrive fulfillment" + ) + } + + is AccountAuthenticationCredentials.SAML2_0 -> { + throw UnsupportedOperationException( + "Can't use bearer tokens for Overdrive fulfillment" + ) + } + + null -> { + throw UnsupportedOperationException( + "Credentials are required for Overdrive fulfillment" + ) + } + } + + strategies.create(parameters) + } else { + this.logger.debug("findExistingStrategy: trying a Basic strategy") + + val strategies = + this.request.strategyRegistry.findStrategy( + ManifestFulfillmentBasicType::class.java + ) ?: throw UnsupportedOperationException("No Basic fulfillment strategy is available") + + val parameters = + ManifestFulfillmentBasicParameters( + uri = targetURI, + credentials = when (val c = this.request.credentials) { + is AccountAuthenticationCredentials.Basic -> { + ManifestFulfillmentBasicCredentials( + userName = c.userName.value, + password = c.password.value + ) + } + + is AccountAuthenticationCredentials.BasicToken -> { + ManifestFulfillmentBasicCredentials( + userName = c.userName.value, + password = c.password.value + ) + } + + is AccountAuthenticationCredentials.OAuthWithIntermediary -> { + throw UnsupportedOperationException( + "Can't use OAuth tokens for Basic fulfillment" + ) + } + + is AccountAuthenticationCredentials.SAML2_0 -> { + throw UnsupportedOperationException( + "Can't use SAML tokens for Basic fulfillment" + ) + } + + null -> null + }, + httpClient = this.request.services.requireService(LSHTTPClientType::class.java), + userAgent = this.request.userAgent + ) + + strategies.create(parameters) + } + } + + /** + * @return `true` if the request content type implies an Overdrive audio book + */ + + private fun isOverdrive(): Boolean { + return BookFormats.audioBookOverdriveMimeTypes() + .map { it.fullType } + .contains(this.request.contentType.fullType) + } +} diff --git a/simplified-books-audio/src/main/java/org/nypl/simplified/books/audio/AudioBookManifestStrategyType.kt b/simplified-books-audio/src/main/java/org/nypl/simplified/books/audio/AudioBookStrategyType.kt similarity index 68% rename from simplified-books-audio/src/main/java/org/nypl/simplified/books/audio/AudioBookManifestStrategyType.kt rename to simplified-books-audio/src/main/java/org/nypl/simplified/books/audio/AudioBookStrategyType.kt index 5e6b37ca2..aee2ea6e9 100644 --- a/simplified-books-audio/src/main/java/org/nypl/simplified/books/audio/AudioBookManifestStrategyType.kt +++ b/simplified-books-audio/src/main/java/org/nypl/simplified/books/audio/AudioBookStrategyType.kt @@ -1,6 +1,7 @@ package org.nypl.simplified.books.audio import io.reactivex.Observable +import org.librarysimplified.audiobook.manifest_fulfill.spi.ManifestFulfillmentStrategyType import org.nypl.simplified.taskrecorder.api.TaskResult /** @@ -9,7 +10,7 @@ import org.nypl.simplified.taskrecorder.api.TaskResult * the same result each time it is executed. */ -interface AudioBookManifestStrategyType { +interface AudioBookStrategyType { /** * An observable source of events published during fulfillment. @@ -22,4 +23,10 @@ interface AudioBookManifestStrategyType { */ fun execute(): TaskResult + + /** + * Adapt this strategy to a manifest fulfillment strategy. + */ + + fun toManifestStrategy(): ManifestFulfillmentStrategyType } diff --git a/simplified-books-audio/src/main/java/org/nypl/simplified/books/audio/PackagedAudioBookManifestStrategy.kt b/simplified-books-audio/src/main/java/org/nypl/simplified/books/audio/PackagedAudioBookManifestStrategy.kt deleted file mode 100644 index 661286d18..000000000 --- a/simplified-books-audio/src/main/java/org/nypl/simplified/books/audio/PackagedAudioBookManifestStrategy.kt +++ /dev/null @@ -1,121 +0,0 @@ -package org.nypl.simplified.books.audio - -import android.app.Application -import kotlinx.coroutines.runBlocking -import org.librarysimplified.audiobook.api.PlayerResult -import org.librarysimplified.audiobook.manifest_fulfill.spi.ManifestFulfilled -import org.librarysimplified.audiobook.manifest_fulfill.spi.ManifestFulfillmentErrorType -import org.nypl.simplified.taskrecorder.api.TaskRecorderType -import org.readium.r2.shared.util.Try -import org.readium.r2.shared.util.Url -import org.readium.r2.shared.util.asset.AssetRetriever -import org.readium.r2.shared.util.asset.ContainerAsset -import org.readium.r2.shared.util.asset.ResourceAsset -import org.readium.r2.shared.util.http.DefaultHttpClient -import org.slf4j.LoggerFactory -import java.io.File - -/** - * An audio book manifest strategy that extracts the manifest from a downloaded audio book file. - */ - -class PackagedAudioBookManifestStrategy( - private val context: Application, - private val request: AudioBookManifestRequest -) : AbstractAudioBookManifestStrategy(context, request) { - - private val logger = - LoggerFactory.getLogger(PackagedAudioBookManifestStrategy::class.java) - - override fun fulfill( - taskRecorder: TaskRecorderType - ): PlayerResult { - taskRecorder.beginNewStep("Extracting manifest…") - return this.extractManifest(taskRecorder) - } - - /** - * Attempt to synchronously extract a manifest file from the audio book package. - */ - - private fun extractManifest( - taskRecorder: TaskRecorderType - ): PlayerResult { - if (this.request.file == null) { - taskRecorder.currentStepFailed("No audio book file", "no-audio-book-file") - return PlayerResult.Failure(ExtractFailed("No audio book file")) - } - - val manifestURI = this.request.targetURI - val manifestLink = Url(manifestURI.toString())!! - val filePath = this.request.file.absolutePath - - this.logger.debug("extractManifest: extracting {} from {}", manifestURI, filePath) - - val assetRetriever = - AssetRetriever( - contentResolver = context.contentResolver, - httpClient = DefaultHttpClient() - ) - - val manifestBytes = runBlocking { - return@runBlocking when (val r = assetRetriever.retrieve(File(filePath))) { - is Try.Failure -> { - taskRecorder.currentStepFailed( - message = "Failed to open file $filePath.", - errorCode = "audio-book-file" - ) - null - } - - is Try.Success -> { - when (val c = r.value) { - is ContainerAsset -> { - when (val d = c.container[manifestLink]?.read()) { - is Try.Failure -> { - taskRecorder.currentStepFailed( - message = "Failed to read $manifestLink from container", - errorCode = "audio-book-no-manifest" - ) - null - } - - is Try.Success -> { - d.value - } - - null -> { - taskRecorder.currentStepFailed( - message = "Asset retriever returned null for $filePath", - errorCode = "audio-book-asset-retriever-null-result" - ) - null - } - } - } - - is ResourceAsset -> { - taskRecorder.currentStepFailed( - message = "Returned asset $filePath is not a container asset", - errorCode = "audio-book-not-container" - ) - null - } - } - } - } - } - - return if (manifestBytes == null) { - PlayerResult.Failure(ExtractFailed("Unable to extract manifest from audio book file")) - } else { - PlayerResult.unit(ManifestFulfilled(this.request.contentType, null, manifestBytes)) - } - } - - private data class ExtractFailed( - override val message: String, - val exception: java.lang.Exception? = null, - override val serverData: ManifestFulfillmentErrorType.ServerData? = null - ) : ManifestFulfillmentErrorType -} diff --git a/simplified-books-audio/src/main/java/org/nypl/simplified/books/audio/UnpackagedAudioBookManifestStrategy.kt b/simplified-books-audio/src/main/java/org/nypl/simplified/books/audio/UnpackagedAudioBookManifestStrategy.kt deleted file mode 100644 index 2b9b6961e..000000000 --- a/simplified-books-audio/src/main/java/org/nypl/simplified/books/audio/UnpackagedAudioBookManifestStrategy.kt +++ /dev/null @@ -1,187 +0,0 @@ -package org.nypl.simplified.books.audio - -import android.app.Application -import com.io7m.junreachable.UnimplementedCodeException -import org.librarysimplified.audiobook.api.PlayerResult -import org.librarysimplified.audiobook.manifest_fulfill.basic.ManifestFulfillmentBasicCredentials -import org.librarysimplified.audiobook.manifest_fulfill.basic.ManifestFulfillmentBasicParameters -import org.librarysimplified.audiobook.manifest_fulfill.basic.ManifestFulfillmentBasicType -import org.librarysimplified.audiobook.manifest_fulfill.opa.OPAManifestFulfillmentStrategyProviderType -import org.librarysimplified.audiobook.manifest_fulfill.opa.OPAManifestURI -import org.librarysimplified.audiobook.manifest_fulfill.opa.OPAParameters -import org.librarysimplified.audiobook.manifest_fulfill.opa.OPAPassword -import org.librarysimplified.audiobook.manifest_fulfill.spi.ManifestFulfilled -import org.librarysimplified.audiobook.manifest_fulfill.spi.ManifestFulfillmentErrorType -import org.librarysimplified.audiobook.manifest_fulfill.spi.ManifestFulfillmentEvent -import org.librarysimplified.audiobook.manifest_fulfill.spi.ManifestFulfillmentStrategyType -import org.librarysimplified.http.api.LSHTTPClientType -import org.nypl.simplified.books.book_database.api.BookFormats -import org.nypl.simplified.taskrecorder.api.TaskRecorderType -import org.slf4j.LoggerFactory - -/** - * An audio book manifest strategy that downloads the manifest from a URI, or loads a fallback if - * the network is unavailable. - */ - -class UnpackagedAudioBookManifestStrategy( - context: Application, - private val request: AudioBookManifestRequest -) : AbstractAudioBookManifestStrategy(context, request) { - - private val logger = - LoggerFactory.getLogger(UnpackagedAudioBookManifestStrategy::class.java) - - override fun fulfill( - taskRecorder: TaskRecorderType - ): PlayerResult { - return if (this.request.isNetworkAvailable()) { - taskRecorder.beginNewStep("Downloading manifest…") - this.downloadManifest() - } else { - taskRecorder.beginNewStep("Loading manifest…") - this.loadFallbackManifest() - } - } - - private data class DataLoadFailed( - override val message: String, - val exception: java.lang.Exception? = null, - override val serverData: ManifestFulfillmentErrorType.ServerData? = null - ) : ManifestFulfillmentErrorType - - private fun loadFallbackManifest(): PlayerResult { - this.logger.debug("loadFallbackManifest") - return try { - val data = this.request.loadFallbackData() - if (data == null) { - PlayerResult.Failure(DataLoadFailed("No fallback manifest data is provided")) - } else { - PlayerResult.unit(data) - } - } catch (e: Exception) { - this.logger.error("loadFallbackManifest: ", e) - PlayerResult.Failure(DataLoadFailed(e.message ?: e.javaClass.name, e)) - } - } - - /** - * @return `true` if the request content type implies an Overdrive audio book - */ - - private fun isOverdrive(): Boolean { - return BookFormats.audioBookOverdriveMimeTypes() - .map { it.fullType } - .contains(this.request.contentType.fullType) - } - - /** - * Attempt to synchronously download a manifest file. If the download fails, return the - * error details. - */ - - private fun downloadManifest(): PlayerResult { - this.logger.debug("downloadManifest") - - val strategy: ManifestFulfillmentStrategyType = - this.downloadStrategyForCredentials() - val fulfillSubscription = - strategy.events.subscribe(this::onManifestFulfillmentEvent) - - try { - return strategy.execute() - } finally { - fulfillSubscription.dispose() - } - } - - /** - * Try to find an appropriate fulfillment strategy based on the audio book request. - */ - - private fun downloadStrategyForCredentials(): ManifestFulfillmentStrategyType { - return if (this.isOverdrive()) { - this.logger.debug("downloadStrategyForCredentials: trying an Overdrive strategy") - - val secretService = - this.request.services.optionalService( - AudioBookOverdriveSecretServiceType::class.java - ) ?: throw UnsupportedOperationException("No Overdrive secret service is available") - - val strategies = - this.request.strategyRegistry.findStrategy( - OPAManifestFulfillmentStrategyProviderType::class.java - ) ?: throw UnsupportedOperationException("No Overdrive fulfillment strategy is available") - - val parameters = - when (val credentials = this.request.credentials) { - is AudioBookCredentials.UsernamePassword -> - OPAParameters( - userName = credentials.userName, - password = OPAPassword.Password(credentials.password), - clientKey = secretService.clientKey.orEmpty(), - clientPass = secretService.clientPass.orEmpty(), - targetURI = OPAManifestURI.Indirect(this.request.targetURI), - userAgent = this.request.userAgent - ) - is AudioBookCredentials.UsernameOnly -> - OPAParameters( - userName = credentials.userName, - password = OPAPassword.NotRequired, - clientKey = secretService.clientKey.orEmpty(), - clientPass = secretService.clientPass.orEmpty(), - targetURI = OPAManifestURI.Indirect(this.request.targetURI), - userAgent = this.request.userAgent - ) - is AudioBookCredentials.BearerToken -> - throw UnsupportedOperationException("Can't use bearer tokens for Overdrive fulfillment") - null -> - throw UnimplementedCodeException() - } - - strategies.create(parameters) - } else { - this.logger.debug("downloadStrategyForCredentials: trying a Basic strategy") - - val strategies = - this.request.strategyRegistry.findStrategy( - ManifestFulfillmentBasicType::class.java - ) ?: throw UnsupportedOperationException("No Basic fulfillment strategy is available") - - val parameters = - ManifestFulfillmentBasicParameters( - uri = this.request.targetURI, - credentials = this.request.credentials?.let { credentials -> - when (credentials) { - is AudioBookCredentials.UsernamePassword -> - ManifestFulfillmentBasicCredentials( - userName = credentials.userName, - password = credentials.password - ) - is AudioBookCredentials.UsernameOnly -> - ManifestFulfillmentBasicCredentials( - userName = credentials.userName, - password = "" - ) - is AudioBookCredentials.BearerToken -> - throw UnsupportedOperationException( - "Can't use bearer tokens for direct audio book fulfillment" - // NB: Indirect bearer token fulfillment is supported: If basic auth is used, and - // the CM returns a bearer token document, the http client will automatically use - // the bearer token to complete the download. - ) - } - }, - httpClient = this.request.services.requireService(LSHTTPClientType::class.java), - userAgent = this.request.userAgent - ) - - strategies.create(parameters) - } - } - - private fun onManifestFulfillmentEvent(event: ManifestFulfillmentEvent) { - this.logger.debug("onManifestFulfillmentEvent: {}", event.message) - this.eventSubject.onNext(event.message) - } -} diff --git a/simplified-books-borrowing/build.gradle.kts b/simplified-books-borrowing/build.gradle.kts index 304e7aeb1..dc399a6cf 100644 --- a/simplified-books-borrowing/build.gradle.kts +++ b/simplified-books-borrowing/build.gradle.kts @@ -17,7 +17,7 @@ dependencies { implementation(project(":simplified-taskrecorder-api")) implementation(libs.androidx.constraintlayout) - implementation(libs.commons.compress) + implementation(libs.androidx.constraintlayout.core) implementation(libs.google.failureaccess) implementation(libs.google.guava) implementation(libs.io7m.jfunctional) @@ -28,7 +28,10 @@ dependencies { implementation(libs.kotlin.stdlib) implementation(libs.kotlinx.coroutines) implementation(libs.palace.audiobook.api) + implementation(libs.palace.audiobook.manifest.api) implementation(libs.palace.audiobook.manifest.fulfill.api) + implementation(libs.palace.audiobook.manifest.fulfill.basic) + implementation(libs.palace.audiobook.manifest.fulfill.opa) implementation(libs.palace.audiobook.manifest.fulfill.spi) implementation(libs.palace.audiobook.manifest.parser.api) implementation(libs.palace.drm.core) @@ -38,12 +41,5 @@ dependencies { implementation(libs.r2.shared) implementation(libs.rxjava) implementation(libs.rxjava2) - implementation(libs.service.wight.core) implementation(libs.slf4j) - implementation(libs.truecommons.key.disable) - implementation(libs.truecommons.shed) - implementation(libs.truevfs.access) - implementation(libs.truevfs.driver.zip) - implementation(libs.truevfs.kernel.impl) - implementation(libs.truevfs.kernel.spec) } diff --git a/simplified-books-borrowing/src/main/java/org/nypl/simplified/books/borrowing/BorrowContextType.kt b/simplified-books-borrowing/src/main/java/org/nypl/simplified/books/borrowing/BorrowContextType.kt index 948ad599d..384d12e96 100644 --- a/simplified-books-borrowing/src/main/java/org/nypl/simplified/books/borrowing/BorrowContextType.kt +++ b/simplified-books-borrowing/src/main/java/org/nypl/simplified/books/borrowing/BorrowContextType.kt @@ -91,7 +91,11 @@ interface BorrowContextType { val uri = this.currentURI() if (uri == null) { this.logError("no current URI") - this.taskRecorder.currentStepFailed("A required URI is missing.", BorrowErrorCodes.requiredURIMissing) + this.taskRecorder.currentStepFailed( + message = "A required URI is missing.", + errorCode = BorrowErrorCodes.requiredURIMissing, + extraMessages = listOf() + ) throw BorrowSubtaskFailed() } return uri diff --git a/simplified-books-borrowing/src/main/java/org/nypl/simplified/books/borrowing/BorrowTask.kt b/simplified-books-borrowing/src/main/java/org/nypl/simplified/books/borrowing/BorrowTask.kt index 8234c0ff5..b8f7443b7 100644 --- a/simplified-books-borrowing/src/main/java/org/nypl/simplified/books/borrowing/BorrowTask.kt +++ b/simplified-books-borrowing/src/main/java/org/nypl/simplified/books/borrowing/BorrowTask.kt @@ -1,6 +1,7 @@ package org.nypl.simplified.books.borrowing import android.app.Application +import one.irradia.mime.api.MIMEType import org.joda.time.Instant import org.librarysimplified.http.api.LSHTTPClientType import org.librarysimplified.services.api.ServiceDirectoryType @@ -111,8 +112,13 @@ class BorrowTask private constructor( this.warn("handled: ", e) this.taskRecorder.finishFailure() } catch (e: Throwable) { - this.error("unhandled exception during borrowing: ", e) - this.taskRecorder.currentStepFailedAppending(this.messageOrName(e), unexpectedException, e) + this.error("Unhandled exception during borrowing: ", e) + this.taskRecorder.currentStepFailedAppending( + message = this.messageOrName(e), + errorCode = unexpectedException, + exception = e, + extraMessages = listOf() + ) this.taskRecorder.finishFailure() } } @@ -213,7 +219,13 @@ class BorrowTask private constructor( elementQueue.removeAt(0) context.currentOPDSAcquisitionPathElement = pathElement context.currentRemainingOPDSPathElements = elementQueue.toList() - val subtaskFactory = this.subtaskFindForPathElement(context, pathElement, book) + val subtaskFactory = + this.subtaskFindForPathElement( + context = context, + pathElement = pathElement, + book = book, + remaining = elementQueue.toList().map(OPDSAcquisitionPathElement::mimeType) + ) this.subtaskExecute(subtaskFactory, context, book) } catch (e: BorrowSubtaskHaltedEarly) { this.logger.debug("subtask halted early: ", e) @@ -247,14 +259,16 @@ class BorrowTask private constructor( step.resolution = TaskStepFailed( message = "Subtask '$name' raised an unexpected exception", exception = e, - errorCode = subtaskFailed + errorCode = subtaskFailed, + extraMessages = listOf() ) throw e } catch (e: Exception) { step.resolution = TaskStepFailed( message = "Subtask '$name' raised an unexpected exception", exception = e, - errorCode = subtaskFailed + errorCode = subtaskFailed, + extraMessages = listOf() ) this.publishBookFailure(book) throw BorrowFailedHandled(e) @@ -268,19 +282,22 @@ class BorrowTask private constructor( private fun subtaskFindForPathElement( context: BorrowContext, pathElement: OPDSAcquisitionPathElement, - book: Book + book: Book, + remaining: List ): BorrowSubtaskFactoryType { - this.taskRecorder.beginNewStep("Finding subtask for acquisition path element...") + this.taskRecorder.beginNewStep("Finding subtask for acquisition path element ${pathElement.mimeType}...") val subtaskFactory = this.requirements.subtasks.findSubtaskFor( - pathElement.mimeType, - context.currentURI(), - context.account + mimeType = pathElement.mimeType, + target = context.currentURI(), + account = context.account, + remainingTypes = remaining ) if (subtaskFactory == null) { this.taskRecorder.currentStepFailed( message = "We don't know how to handle this kind of acquisition.", - errorCode = noSubtaskAvailable + errorCode = noSubtaskAvailable, + extraMessages = listOf() ) this.publishBookFailure(book) throw BorrowFailedHandled(null) @@ -311,7 +328,8 @@ class BorrowTask private constructor( this.taskRecorder.currentStepFailed( message = "Could not set up the book database entry.", errorCode = bookDatabaseFailed, - exception = e + exception = e, + extraMessages = listOf() ) this.publishBookFailure(book) throw BorrowFailedHandled(e) @@ -334,7 +352,8 @@ class BorrowTask private constructor( this.taskRecorder.currentStepFailed( message = "Failed to find profile.", errorCode = profileNotFound, - exception = IllegalArgumentException() + exception = IllegalArgumentException(), + extraMessages = listOf() ) this.publishBookFailure(book) throw BorrowFailedHandled(null) @@ -362,7 +381,8 @@ class BorrowTask private constructor( this.taskRecorder.currentStepFailedAppending( message = "An unexpected exception was raised.", errorCode = accountsDatabaseException, - exception = e + exception = e, + extraMessages = listOf() ) this.publishBookFailure(book) @@ -384,9 +404,14 @@ class BorrowTask private constructor( ): OPDSAcquisitionPath { this.taskRecorder.beginNewStep("Planning the borrow operation…") - val path = BorrowAcquisitions.pickBestAcquisitionPath(this.requirements.bookFormatSupport, entry) + val path = + BorrowAcquisitions.pickBestAcquisitionPath(this.requirements.bookFormatSupport, entry) if (path == null) { - this.taskRecorder.currentStepFailed("No supported acquisitions.", noSupportedAcquisitions) + this.taskRecorder.currentStepFailed( + message = "No supported acquisitions.", + errorCode = noSupportedAcquisitions, + extraMessages = listOf() + ) this.publishBookFailure(book) throw BorrowFailedHandled(null) } @@ -397,7 +422,12 @@ class BorrowTask private constructor( private fun publishBookFailure(book: Book) { val failure = this.taskRecorder.finishFailure() - this.requirements.bookRegistry.update(BookWithStatus(book, BookStatus.FailedLoan(book.id, failure))) + this.requirements.bookRegistry.update( + BookWithStatus( + book, + BookStatus.FailedLoan(book.id, failure) + ) + ) } private class BorrowContext( diff --git a/simplified-books-borrowing/src/main/java/org/nypl/simplified/books/borrowing/internal/BorrowACSM.kt b/simplified-books-borrowing/src/main/java/org/nypl/simplified/books/borrowing/internal/BorrowACSM.kt index 3f31cc9c4..fd3c35c0f 100644 --- a/simplified-books-borrowing/src/main/java/org/nypl/simplified/books/borrowing/internal/BorrowACSM.kt +++ b/simplified-books-borrowing/src/main/java/org/nypl/simplified/books/borrowing/internal/BorrowACSM.kt @@ -63,7 +63,8 @@ class BorrowACSM private constructor() : BorrowSubtaskType { override fun isApplicableFor( type: MIMEType, target: URI?, - account: AccountReadableType? + account: AccountReadableType?, + remaining: List ): Boolean { return MIMECompatibility.isCompatibleStrictWithoutAttributes(type, adobeACSMFiles) } @@ -153,7 +154,8 @@ class BorrowACSM private constructor() : BorrowSubtaskType { if (credentials == null) { context.taskRecorder.currentStepFailed( message = "The account has no credentials.", - errorCode = accountCredentialsRequired + errorCode = accountCredentialsRequired, + extraMessages = listOf() ) throw BorrowSubtaskFailed() } @@ -162,7 +164,8 @@ class BorrowACSM private constructor() : BorrowSubtaskType { if (preActivation == null) { context.taskRecorder.currentStepFailed( message = "The account has no pre-activation ACS credentials.", - errorCode = acsNoCredentialsPre + errorCode = acsNoCredentialsPre, + extraMessages = listOf() ) throw BorrowSubtaskFailed() } @@ -171,7 +174,8 @@ class BorrowACSM private constructor() : BorrowSubtaskType { if (postActivation == null) { context.taskRecorder.currentStepFailed( message = "The account's ACS device is not activated.", - errorCode = acsNoCredentialsPost + errorCode = acsNoCredentialsPost, + extraMessages = listOf() ) throw BorrowSubtaskFailed() } @@ -191,7 +195,8 @@ class BorrowACSM private constructor() : BorrowSubtaskType { if (context.adobeExecutor == null) { context.taskRecorder.currentStepFailed( message = "This build of the application does not support Adobe ACS.", - errorCode = acsNotSupported + errorCode = acsNotSupported, + extraMessages = listOf() ) throw BorrowSubtaskFailed() } @@ -267,7 +272,8 @@ class BorrowACSM private constructor() : BorrowSubtaskType { context.taskRecorder.currentStepFailed( message = "Unparseable ACSM file: ${e.message}", errorCode = acsUnparseableACSM, - exception = e + exception = e, + extraMessages = listOf() ) throw BorrowSubtaskFailed() } @@ -342,7 +348,8 @@ class BorrowACSM private constructor() : BorrowSubtaskType { context.taskRecorder.currentStepFailed( message = "Adobe ACS fulfillment timed out.", errorCode = acsTimedOut, - exception = e + exception = e, + extraMessages = listOf() ) throw BorrowSubtaskFailed() } catch (e: ExecutionException) { @@ -354,7 +361,8 @@ class BorrowACSM private constructor() : BorrowSubtaskType { context.taskRecorder.currentStepFailed( message = "Adobe ACS fulfillment failed (${cause.errorCode})", errorCode = "ACS: ${cause.errorCode}", - exception = cause + exception = cause, + extraMessages = listOf() ) BorrowSubtaskFailed() } @@ -362,7 +370,8 @@ class BorrowACSM private constructor() : BorrowSubtaskType { context.taskRecorder.currentStepFailed( message = "Adobe ACS fulfillment failed (${cause.javaClass})", errorCode = "ACS: ${cause.javaClass}", - exception = cause + exception = cause, + extraMessages = listOf() ) BorrowSubtaskFailed() } @@ -431,7 +440,8 @@ class BorrowACSM private constructor() : BorrowSubtaskType { if (formatHandle == null) { context.taskRecorder.currentStepFailed( message = "No format handle available for ${eventualType.fullType}", - errorCode = noFormatHandle + errorCode = noFormatHandle, + extraMessages = listOf() ) throw BorrowSubtaskFailed() } diff --git a/simplified-books-borrowing/src/main/java/org/nypl/simplified/books/borrowing/internal/BorrowAudioBook.kt b/simplified-books-borrowing/src/main/java/org/nypl/simplified/books/borrowing/internal/BorrowAudioBook.kt index 4ba7cbefb..91dcae560 100644 --- a/simplified-books-borrowing/src/main/java/org/nypl/simplified/books/borrowing/internal/BorrowAudioBook.kt +++ b/simplified-books-borrowing/src/main/java/org/nypl/simplified/books/borrowing/internal/BorrowAudioBook.kt @@ -4,9 +4,8 @@ import com.io7m.junreachable.UnreachableCodeException import one.irradia.mime.api.MIMECompatibility import one.irradia.mime.api.MIMEType import org.librarysimplified.audiobook.api.PlayerUserAgent -import org.nypl.simplified.accounts.api.AccountAuthenticationCredentials import org.nypl.simplified.accounts.api.AccountReadableType -import org.nypl.simplified.books.audio.AudioBookCredentials +import org.nypl.simplified.books.audio.AudioBookLink import org.nypl.simplified.books.audio.AudioBookManifestRequest import org.nypl.simplified.books.book_database.api.BookDatabaseEntryFormatHandle.BookDatabaseEntryFormatHandleAudioBook import org.nypl.simplified.books.book_database.api.BookDatabaseEntryFormatHandle.BookDatabaseEntryFormatHandleEPUB @@ -38,7 +37,8 @@ class BorrowAudioBook private constructor() : BorrowSubtaskType { override fun isApplicableFor( type: MIMEType, target: URI?, - account: AccountReadableType? + account: AccountReadableType?, + remaining: List ): Boolean { for (audioType in StandardFormatNames.allAudioBooks) { if (MIMECompatibility.isCompatibleStrictWithoutAttributes(type, audioType)) { @@ -85,53 +85,17 @@ class BorrowAudioBook private constructor() : BorrowSubtaskType { ): DownloadedManifest { context.taskRecorder.beginNewStep("Executing audio book manifest strategy...") - val audioBookCredentials = - when (val credentials = context.account.loginState.credentials) { - is AccountAuthenticationCredentials.Basic -> { - if (credentials.password.value.isBlank()) { - AudioBookCredentials.UsernameOnly( - userName = credentials.userName.value - ) - } else { - AudioBookCredentials.UsernamePassword( - userName = credentials.userName.value, - password = credentials.password.value - ) - } - } - is AccountAuthenticationCredentials.BasicToken -> { - if (credentials.password.value.isBlank()) { - AudioBookCredentials.UsernameOnly( - userName = credentials.userName.value - ) - } else { - AudioBookCredentials.UsernamePassword( - userName = credentials.userName.value, - password = credentials.password.value - ) - } - } - is AccountAuthenticationCredentials.OAuthWithIntermediary -> { - AudioBookCredentials.BearerToken(accessToken = credentials.accessToken) - } - is AccountAuthenticationCredentials.SAML2_0 -> { - AudioBookCredentials.BearerToken(accessToken = credentials.accessToken) - } - null -> { - null - } - } - val strategy = context.audioBookManifestStrategies.createStrategy( context = context.application, AudioBookManifestRequest( - targetURI = currentURI, + cacheDirectory = context.cacheDirectory(), contentType = context.currentAcquisitionPathElement.mimeType, - userAgent = PlayerUserAgent(context.httpClient.userAgent()), - credentials = audioBookCredentials, + credentials = context.account.loginState.credentials, + httpClient = context.httpClient, services = context.services, - cacheDirectory = context.cacheDirectory() + target = AudioBookLink.Manifest(currentURI), + userAgent = PlayerUserAgent(context.httpClient.userAgent()), ) ) @@ -160,14 +124,17 @@ class BorrowAudioBook private constructor() : BorrowSubtaskType { sourceURI = currentURI ) } + is TaskResult.Failure -> { - context.taskRecorder.currentStepFailed("Strategy failed.", audioStrategyFailed) + val exception = BorrowSubtaskFailed() + context.taskRecorder.currentStepFailed( + message = "Strategy failed.", + errorCode = audioStrategyFailed, + exception = exception, + extraMessages = listOf() + ) context.taskRecorder.addAll(result.steps) context.taskRecorder.addAttributes(result.attributes) - context.taskRecorder.beginNewStep("Checking AudioBook strategy result…") - - val exception = BorrowSubtaskFailed() - context.taskRecorder.currentStepFailed("Failed", audioStrategyFailed, exception) throw exception } } @@ -193,6 +160,7 @@ class BorrowAudioBook private constructor() : BorrowSubtaskType { ) context.bookDownloadSucceeded() } + is BookDatabaseEntryFormatHandlePDF, is BookDatabaseEntryFormatHandleEPUB, null -> { diff --git a/simplified-books-borrowing/src/main/java/org/nypl/simplified/books/borrowing/internal/BorrowAxisNow.kt b/simplified-books-borrowing/src/main/java/org/nypl/simplified/books/borrowing/internal/BorrowAxisNow.kt index d3e8b5c88..6538db3df 100644 --- a/simplified-books-borrowing/src/main/java/org/nypl/simplified/books/borrowing/internal/BorrowAxisNow.kt +++ b/simplified-books-borrowing/src/main/java/org/nypl/simplified/books/borrowing/internal/BorrowAxisNow.kt @@ -39,7 +39,8 @@ class BorrowAxisNow private constructor() : BorrowSubtaskType { override fun isApplicableFor( type: MIMEType, target: URI?, - account: AccountReadableType? + account: AccountReadableType?, + remaining: List ): Boolean { return MIMECompatibility.isCompatibleStrictWithoutAttributes(type, StandardFormatNames.axisNow) } @@ -113,7 +114,8 @@ class BorrowAxisNow private constructor() : BorrowSubtaskType { if (context.axisNowService == null) { context.taskRecorder.currentStepFailed( message = "This build of the application does not support AxisNow DRM.", - errorCode = axisNowNotSupported + errorCode = axisNowNotSupported, + extraMessages = listOf() ) throw BorrowSubtaskFailed() } @@ -133,7 +135,8 @@ class BorrowAxisNow private constructor() : BorrowSubtaskType { context.taskRecorder.currentStepFailed( message = "AxisNow fulfillment error: ${e.message}", errorCode = BorrowErrorCodes.axisNowFulfillmentFailed, - exception = e + exception = e, + extraMessages = listOf() ) throw BorrowSubtaskFailed() } finally { diff --git a/simplified-books-borrowing/src/main/java/org/nypl/simplified/books/borrowing/internal/BorrowBearerToken.kt b/simplified-books-borrowing/src/main/java/org/nypl/simplified/books/borrowing/internal/BorrowBearerToken.kt index fd1f6bde3..63b2a033c 100644 --- a/simplified-books-borrowing/src/main/java/org/nypl/simplified/books/borrowing/internal/BorrowBearerToken.kt +++ b/simplified-books-borrowing/src/main/java/org/nypl/simplified/books/borrowing/internal/BorrowBearerToken.kt @@ -28,7 +28,8 @@ class BorrowBearerToken : BorrowSubtaskType { override fun isApplicableFor( type: MIMEType, target: URI?, - account: AccountReadableType? + account: AccountReadableType?, + remaining: List ): Boolean { return MIMECompatibility.isCompatibleStrictWithoutAttributes( type, diff --git a/simplified-books-borrowing/src/main/java/org/nypl/simplified/books/borrowing/internal/BorrowCopy.kt b/simplified-books-borrowing/src/main/java/org/nypl/simplified/books/borrowing/internal/BorrowCopy.kt index f893fd021..e6a3a2f80 100644 --- a/simplified-books-borrowing/src/main/java/org/nypl/simplified/books/borrowing/internal/BorrowCopy.kt +++ b/simplified-books-borrowing/src/main/java/org/nypl/simplified/books/borrowing/internal/BorrowCopy.kt @@ -35,7 +35,8 @@ class BorrowCopy private constructor() : BorrowSubtaskType { override fun isApplicableFor( type: MIMEType, target: URI?, - account: AccountReadableType? + account: AccountReadableType?, + remaining: List ): Boolean { return if (target != null) { target.scheme == "content" @@ -86,7 +87,8 @@ class BorrowCopy private constructor() : BorrowSubtaskType { context.taskRecorder.currentStepFailed( message = "File not found.", errorCode = contentFileNotFound, - exception = e + exception = e, + extraMessages = listOf() ) throw BorrowSubtaskFailed() } diff --git a/simplified-books-borrowing/src/main/java/org/nypl/simplified/books/borrowing/internal/BorrowDirectDownload.kt b/simplified-books-borrowing/src/main/java/org/nypl/simplified/books/borrowing/internal/BorrowDirectDownload.kt index aafa0fbc9..ea89dbf45 100644 --- a/simplified-books-borrowing/src/main/java/org/nypl/simplified/books/borrowing/internal/BorrowDirectDownload.kt +++ b/simplified-books-borrowing/src/main/java/org/nypl/simplified/books/borrowing/internal/BorrowDirectDownload.kt @@ -29,7 +29,8 @@ class BorrowDirectDownload private constructor() : BorrowSubtaskType { override fun isApplicableFor( type: MIMEType, target: URI?, - account: AccountReadableType? + account: AccountReadableType?, + remaining: List ): Boolean { if (MIMECompatibility.isCompatibleStrictWithoutAttributes(type, genericEPUBFiles)) { return true diff --git a/simplified-books-borrowing/src/main/java/org/nypl/simplified/books/borrowing/internal/BorrowHTTP.kt b/simplified-books-borrowing/src/main/java/org/nypl/simplified/books/borrowing/internal/BorrowHTTP.kt index f4105a15a..71c2c1c92 100644 --- a/simplified-books-borrowing/src/main/java/org/nypl/simplified/books/borrowing/internal/BorrowHTTP.kt +++ b/simplified-books-borrowing/src/main/java/org/nypl/simplified/books/borrowing/internal/BorrowHTTP.kt @@ -113,10 +113,10 @@ object BorrowHTTP { true } else { val expectedTypesDesc = expectedTypes.map { it.fullType }.joinToString(" or ") - context.taskRecorder.currentStepFailed( message = "The server returned an incompatible context type: We wanted something compatible with $expectedTypesDesc but received ${receivedType.fullType}.", - errorCode = BorrowErrorCodes.httpContentTypeIncompatible + errorCode = BorrowErrorCodes.httpContentTypeIncompatible, + extraMessages = listOf() ) false } @@ -135,7 +135,8 @@ object BorrowHTTP { context.taskRecorder.currentStepFailed( message = "HTTP request failed: ${status.properties.originalStatus} ${status.properties.message}", errorCode = BorrowErrorCodes.httpRequestFailed, - exception = null + exception = null, + extraMessages = listOf() ) return BorrowSubtaskFailed() } @@ -151,7 +152,8 @@ object BorrowHTTP { context.taskRecorder.currentStepFailed( message = result.exception.message ?: "Exception raised during connection attempt.", errorCode = BorrowErrorCodes.httpConnectionFailed, - exception = result.exception + exception = result.exception, + extraMessages = listOf() ) return BorrowSubtaskFailed() } diff --git a/simplified-books-borrowing/src/main/java/org/nypl/simplified/books/borrowing/internal/BorrowLCP.kt b/simplified-books-borrowing/src/main/java/org/nypl/simplified/books/borrowing/internal/BorrowLCP.kt deleted file mode 100644 index 5c0137b61..000000000 --- a/simplified-books-borrowing/src/main/java/org/nypl/simplified/books/borrowing/internal/BorrowLCP.kt +++ /dev/null @@ -1,473 +0,0 @@ -package org.nypl.simplified.books.borrowing.internal - -import net.java.truevfs.access.TConfig -import net.java.truevfs.access.TFile -import net.java.truevfs.access.TVFS -import net.java.truevfs.kernel.spec.FsAccessOption -import one.irradia.mime.api.MIMECompatibility -import one.irradia.mime.api.MIMEType -import org.librarysimplified.http.api.LSHTTPResponseStatus -import org.librarysimplified.http.downloads.LSHTTPDownloadState.LSHTTPDownloadResult.DownloadCancelled -import org.librarysimplified.http.downloads.LSHTTPDownloadState.LSHTTPDownloadResult.DownloadCompletedSuccessfully -import org.librarysimplified.http.downloads.LSHTTPDownloadState.LSHTTPDownloadResult.DownloadFailed.DownloadFailedExceptionally -import org.librarysimplified.http.downloads.LSHTTPDownloadState.LSHTTPDownloadResult.DownloadFailed.DownloadFailedServer -import org.librarysimplified.http.downloads.LSHTTPDownloadState.LSHTTPDownloadResult.DownloadFailed.DownloadFailedUnacceptableMIME -import org.librarysimplified.http.downloads.LSHTTPDownloads -import org.nypl.simplified.accounts.api.AccountAuthenticatedHTTP -import org.nypl.simplified.accounts.api.AccountAuthenticatedHTTP.addCredentialsToProperties -import org.nypl.simplified.accounts.api.AccountAuthenticatedHTTP.getAccessToken -import org.nypl.simplified.accounts.api.AccountReadableType -import org.nypl.simplified.books.api.BookDRMKind -import org.nypl.simplified.books.book_database.BookDRMInformationHandleLCP -import org.nypl.simplified.books.book_database.api.BookDatabaseEntryFormatHandle -import org.nypl.simplified.books.book_database.api.BookDatabaseEntryFormatHandle.BookDatabaseEntryFormatHandleAudioBook -import org.nypl.simplified.books.book_database.api.BookDatabaseEntryFormatHandle.BookDatabaseEntryFormatHandleEPUB -import org.nypl.simplified.books.book_database.api.BookDatabaseEntryFormatHandle.BookDatabaseEntryFormatHandlePDF -import org.nypl.simplified.books.borrowing.BorrowContextType -import org.nypl.simplified.books.borrowing.internal.BorrowErrorCodes.lcpNotSupported -import org.nypl.simplified.books.borrowing.subtasks.BorrowSubtaskException.BorrowSubtaskCancelled -import org.nypl.simplified.books.borrowing.subtasks.BorrowSubtaskException.BorrowSubtaskFailed -import org.nypl.simplified.books.borrowing.subtasks.BorrowSubtaskException.BorrowSubtaskHaltedEarly -import org.nypl.simplified.books.borrowing.subtasks.BorrowSubtaskFactoryType -import org.nypl.simplified.books.borrowing.subtasks.BorrowSubtaskType -import org.nypl.simplified.books.formats.api.StandardFormatNames -import org.nypl.simplified.opds.core.OPDSAcquisitionPaths -import org.nypl.simplified.opds.core.OPDSFeedParserType -import org.readium.r2.lcp.license.model.LicenseDocument -import org.readium.r2.shared.util.ErrorException -import org.readium.r2.shared.util.Try -import java.io.ByteArrayInputStream -import java.io.ByteArrayOutputStream -import java.io.File -import java.io.IOException -import java.net.URI - -/** - * A task that downloads an LCP license and then fulfills a book. - */ - -class BorrowLCP private constructor() : BorrowSubtaskType { - - private val manifestFileName = "manifest.json" - - companion object : BorrowSubtaskFactoryType { - override val name: String - get() = "LCP Download" - - override fun createSubtask(): BorrowSubtaskType { - return BorrowLCP() - } - - override fun isApplicableFor( - type: MIMEType, - target: URI?, - account: AccountReadableType? - ): Boolean { - return MIMECompatibility.isCompatibleStrictWithoutAttributes( - type, - StandardFormatNames.lcpLicenseFiles - ) - } - } - - override fun execute(context: BorrowContextType) { - try { - this.checkDRMSupport(context) - - context.taskRecorder.beginNewStep("Downloading LCP license…") - context.bookDownloadIsRunning( - "Downloading...", - receivedSize = 0L, - expectedSize = 100L, - bytesPerSecond = 1L - ) - - val passphrase = if (context.isManualLCPPassphraseEnabled) { - // if the manual input for the LCP passphrase is enabled, we need to catch a possible - // exception while fetching the current passphrase as it may be possible for the user to - // manually input it and if the exception isn't caught, the download will immediately fail. - try { - this.findPassphrase(context) - } catch (e: Exception) { - "" - } - } else { - this.findPassphrase(context) - } - - val currentURI = context.currentURICheck() - context.logDebug("downloading {}", currentURI) - context.taskRecorder.beginNewStep("Downloading $currentURI...") - context.taskRecorder.addAttribute("URI", currentURI.toString()) - context.checkCancelled() - - val temporaryFile = context.temporaryFile() - - try { - val downloadRequest = - BorrowHTTP.createDownloadRequest( - context = context, - target = currentURI, - outputFile = temporaryFile - ) - - when (val result = LSHTTPDownloads.download(downloadRequest)) { - DownloadCancelled -> { - throw BorrowSubtaskCancelled() - } - - is DownloadFailedServer -> { - throw BorrowHTTP.onDownloadFailedServer(context, result) - } - - is DownloadFailedUnacceptableMIME -> { - throw BorrowSubtaskFailed() - } - - is DownloadFailedExceptionally -> { - throw BorrowHTTP.onDownloadFailedExceptionally(context, result) - } - - is DownloadCompletedSuccessfully -> { - this.fulfill(context, temporaryFile.readBytes(), passphrase) - } - } - } finally { - temporaryFile.delete() - } - } catch (e: BorrowSubtaskFailed) { - context.bookDownloadFailed() - throw e - } - } - - private fun findPassphrase(context: BorrowContextType): String { - context.taskRecorder.beginNewStep("Retrieving LCP hashed passphrase…") - - val loansURI = context.account.provider.loansURI - if (loansURI == null) { - context.taskRecorder.currentStepFailed( - message = "No loans URI provided; unable to retrieve a passphrase.", - errorCode = "lcpMissingLoans" - ) - throw BorrowSubtaskFailed() - } - - val credentials = context.account.loginState.credentials - - val auth = - AccountAuthenticatedHTTP.createAuthorizationIfPresent(credentials) - - val request = - context.httpClient.newRequest(loansURI) - .setAuthorization(auth) - .addCredentialsToProperties(credentials) - .build() - - return request.execute().use { response -> - when (val status = response.status) { - is LSHTTPResponseStatus.Responded.OK -> { - context.account.updateBasicTokenCredentials(status.getAccessToken()) - this.findPassphraseHandleOK(context, loansURI, status) - } - - is LSHTTPResponseStatus.Responded.Error -> { - this.findPassphraseHandleError(context, status) - } - - is LSHTTPResponseStatus.Failed -> { - this.findPassphraseHandleFailure(context, status) - } - } - } - } - - private fun findPassphraseHandleFailure( - context: BorrowContextType, - status: LSHTTPResponseStatus.Failed - ): String { - context.taskRecorder.currentStepFailed( - message = status.exception.message ?: "Exception raised during connection attempt.", - errorCode = BorrowErrorCodes.httpConnectionFailed, - exception = status.exception - ) - throw BorrowSubtaskFailed() - } - - private fun findPassphraseHandleError( - context: BorrowContextType, - status: LSHTTPResponseStatus.Responded.Error - ): String { - val report = status.properties.problemReport - if (report != null) { - context.taskRecorder.addAttributes(report.toMap()) - } - context.taskRecorder.currentStepFailed( - message = "HTTP request failed: ${status.properties.originalStatus} ${status.properties.message}", - errorCode = BorrowErrorCodes.httpRequestFailed, - exception = null - ) - throw BorrowSubtaskFailed() - } - - private fun findPassphraseHandleOK( - context: BorrowContextType, - loansURI: URI, - status: LSHTTPResponseStatus.Responded.OK - ): String { - val feedParser = - context.services.requireService(OPDSFeedParserType::class.java) - - val entryFound = try { - val result = feedParser.parse(loansURI, status.bodyStream) - result.feedEntries.find { entry -> entry.id == context.bookCurrent.entry.id } - } catch (e: Exception) { - context.taskRecorder.currentStepFailed( - message = "Unable to parse loans feed (${e.message})", - errorCode = "lcpUnparseableLoansFeed", - exception = e - ) - throw BorrowSubtaskFailed() - } - - if (entryFound == null) { - context.taskRecorder.currentStepFailed( - message = "Unable to locate the current book in the user's loans feed.", - errorCode = "lcpMissingEntryInLoansFeed", - exception = null - ) - throw BorrowSubtaskFailed() - } - - val linearized = OPDSAcquisitionPaths.linearize(entryFound) - for (path in linearized) { - for (element in path.elements) { - val passphrase = element.properties["lcp:hashed_passphrase"] - if (passphrase != null) { - context.taskRecorder.currentStepSucceeded("Found LCP passphrase") - return passphrase - } - } - } - - context.taskRecorder.currentStepFailed( - message = "No LCP hashed passphrase was provided.", - errorCode = "lcpMissingPassphrase" - ) - throw BorrowSubtaskFailed() - } - - /** - * Check that we actually have the required DRM support. - */ - - private fun checkDRMSupport( - context: BorrowContextType - ) { - context.taskRecorder.beginNewStep("Checking for LCP support...") - if (context.lcpService == null) { - context.taskRecorder.currentStepFailed( - message = "This build of the application does not support LCP DRM.", - errorCode = lcpNotSupported - ) - throw BorrowSubtaskFailed() - } - } - - private fun fulfill( - context: BorrowContextType, - licenseBytes: ByteArray, - passphrase: String - ) { - context.taskRecorder.beginNewStep("Fulfilling book...") - context.checkCancelled() - - val fulfillmentMimeType = context.opdsAcquisitionPath.asMIMETypes().last() - - // An LCP download is always actually a zip file, whether it's an epub, audio book, or pdf. - // TrueVFS will by default detect files with the .zip extension as ZIP archives that can be - // mounted, so the extension is important to install the license. - - val temporaryFile = context.temporaryFile("zip") - - try { - val license = - when (val r = LicenseDocument.fromBytes(licenseBytes)) { - is Try.Failure -> throw ErrorException(r.value) - is Try.Success -> r.value - } - - val link = - license.link(LicenseDocument.Rel.Publication) - val url = - URI.create( - link - ?.url() - ?.toString() - ?: throw IOException("Unparseable license link ($link)") - ) - - val downloadRequest = - BorrowHTTP.createDownloadRequest( - context = context, - target = url, - outputFile = temporaryFile, - requestModifier = { properties -> - properties.copy( - authorization = null - ) - }, - expectedTypes = hashSetOf( - fulfillmentMimeType, - // Sometimes fulfillment servers will set the content type to generic zip or octet - // stream, so these are acceptable too. - StandardFormatNames.genericZIPFiles, - MIMECompatibility.applicationOctetStream - ) - ) - - when (val result = LSHTTPDownloads.download(downloadRequest)) { - DownloadCancelled -> - throw BorrowSubtaskCancelled() - - is DownloadFailedServer -> - throw BorrowHTTP.onDownloadFailedServer(context, result) - - is DownloadFailedUnacceptableMIME -> - throw BorrowSubtaskFailed() - - is DownloadFailedExceptionally -> - throw BorrowHTTP.onDownloadFailedExceptionally(context, result) - - is DownloadCompletedSuccessfully -> { - this.installLicense(context, fulfillmentMimeType, temporaryFile, licenseBytes) - this.saveFulfilledBook(context, temporaryFile, passphrase) - context.bookDownloadSucceeded() - } - } - } catch (e: BorrowSubtaskFailed) { - context.bookDownloadFailed() - throw e - } catch (e: Exception) { - context.taskRecorder.currentStepFailed( - message = "LCP fulfillment error: ${e.message}", - errorCode = BorrowErrorCodes.lcpFulfillmentFailed, - exception = e - ) - throw BorrowSubtaskFailed() - } finally { - temporaryFile.delete() - } - - /* - * LCP is a special case in the sense that it supersedes any acquisition - * path elements that might follow this one. We mark this subtask as having halted - * early. - */ - - throw BorrowSubtaskHaltedEarly() - } - - /** - * Install the license into the fulfilled book, which is in all cases a zip file. This is done - * efficiently, without fully rewriting the potentially large file. - */ - - private fun installLicense( - context: BorrowContextType, - bookType: MIMEType, - bookFile: File, - licenseBytes: ByteArray - ) { - context.taskRecorder.beginNewStep("Installing license...") - - val pathInZIP = when (bookType) { - StandardFormatNames.genericEPUBFiles -> "META-INF/license.lcpl" - else -> "license.lcpl" - } - - // Use TrueVFS to mount the zip file, and copy in the license. The GROW option ensures that - // adding the new entry to the zip only appends the file to the end of the archive, without - // rewriting the entire file. - - TConfig.current().setAccessPreference(FsAccessOption.GROW, true) - - TFile.cp( - ByteArrayInputStream(licenseBytes), - TFile(bookFile, pathInZIP) - ) - - TVFS.umount() - - context.taskRecorder.currentStepSucceeded("License installed.") - } - - private fun extractManifest( - context: BorrowContextType, - bookFile: File - ): ByteArray { - context.taskRecorder.beginNewStep("Extracting manifest...") - - val byteArrayOutputStream = ByteArrayOutputStream() - TFile.cp( - TFile(bookFile, this.manifestFileName), - byteArrayOutputStream - ) - TVFS.umount() - - context.taskRecorder.currentStepSucceeded("Extracted manifest.") - return byteArrayOutputStream.toByteArray() - } - - private fun saveFulfilledBook( - context: BorrowContextType, - bookFile: File, - passphrase: String - ) { - context.taskRecorder.beginNewStep("Saving fulfilled book...") - - val formatHandle = this.findFormatHandle(context) - formatHandle.setDRMKind(BookDRMKind.LCP) - - val drmHandle = formatHandle.drmInformationHandle as BookDRMInformationHandleLCP - drmHandle.setHashedPassphrase(passphrase) - - when (formatHandle) { - is BookDatabaseEntryFormatHandleEPUB -> { - formatHandle.copyInBook(bookFile) - } - - is BookDatabaseEntryFormatHandleAudioBook -> { - formatHandle.moveInBook(bookFile) - formatHandle.copyInManifestAndURI( - this.extractManifest(context, formatHandle.format.file!!), - URI.create(this.manifestFileName) - ) - } - - is BookDatabaseEntryFormatHandlePDF -> - formatHandle.copyInBook(bookFile) - } - - context.taskRecorder.currentStepSucceeded("Saved book.") - } - - /** - * Determine the actual book format we're aiming for at the end of the acquisition path. - */ - - private fun findFormatHandle( - context: BorrowContextType - ): BookDatabaseEntryFormatHandle { - val eventualType = context.opdsAcquisitionPath.asMIMETypes().last() - val formatHandle = context.bookDatabaseEntry.findFormatHandleForContentType(eventualType) - if (formatHandle == null) { - context.taskRecorder.currentStepFailed( - message = "No format handle available for ${eventualType.fullType}", - errorCode = BorrowErrorCodes.noFormatHandle - ) - throw BorrowSubtaskFailed() - } - return formatHandle - } -} diff --git a/simplified-books-borrowing/src/main/java/org/nypl/simplified/books/borrowing/internal/BorrowLCPAudiobook.kt b/simplified-books-borrowing/src/main/java/org/nypl/simplified/books/borrowing/internal/BorrowLCPAudiobook.kt new file mode 100644 index 000000000..d90ad4065 --- /dev/null +++ b/simplified-books-borrowing/src/main/java/org/nypl/simplified/books/borrowing/internal/BorrowLCPAudiobook.kt @@ -0,0 +1,209 @@ +package org.nypl.simplified.books.borrowing.internal + +import com.io7m.junreachable.UnreachableCodeException +import one.irradia.mime.api.MIMECompatibility +import one.irradia.mime.api.MIMEType +import org.librarysimplified.audiobook.api.PlayerUserAgent +import org.nypl.simplified.accounts.api.AccountReadableType +import org.nypl.simplified.books.api.BookDRMKind +import org.nypl.simplified.books.audio.AudioBookLink +import org.nypl.simplified.books.audio.AudioBookManifestRequest +import org.nypl.simplified.books.book_database.BookDRMInformationHandleLCP +import org.nypl.simplified.books.book_database.api.BookDatabaseEntryFormatHandle +import org.nypl.simplified.books.book_database.api.BookDatabaseEntryFormatHandle.BookDatabaseEntryFormatHandleAudioBook +import org.nypl.simplified.books.book_database.api.BookDatabaseEntryFormatHandle.BookDatabaseEntryFormatHandleEPUB +import org.nypl.simplified.books.book_database.api.BookDatabaseEntryFormatHandle.BookDatabaseEntryFormatHandlePDF +import org.nypl.simplified.books.borrowing.BorrowContextType +import org.nypl.simplified.books.borrowing.internal.BorrowErrorCodes.audioStrategyFailed +import org.nypl.simplified.books.borrowing.subtasks.BorrowSubtaskException +import org.nypl.simplified.books.borrowing.subtasks.BorrowSubtaskException.BorrowSubtaskFailed +import org.nypl.simplified.books.borrowing.subtasks.BorrowSubtaskFactoryType +import org.nypl.simplified.books.borrowing.subtasks.BorrowSubtaskType +import org.nypl.simplified.books.formats.api.StandardFormatNames.lcpAudioBooks +import org.nypl.simplified.books.formats.api.StandardFormatNames.lcpLicenseFiles +import org.nypl.simplified.taskrecorder.api.TaskResult +import java.io.File +import java.net.URI + +class BorrowLCPAudiobook : BorrowSubtaskType { + + companion object : BorrowSubtaskFactoryType { + override val name: String + get() = "LCP AudioBook Download" + + override fun createSubtask(): BorrowSubtaskType { + return BorrowLCPAudiobook() + } + + override fun isApplicableFor( + type: MIMEType, + target: URI?, + account: AccountReadableType?, + remaining: List + ): Boolean { + return if (MIMECompatibility.isCompatibleStrictWithoutAttributes(type, lcpLicenseFiles)) { + val next = remaining.firstOrNull() + if (next != null) { + MIMECompatibility.isCompatibleStrictWithoutAttributes(next, lcpAudioBooks) + } else { + false + } + } else { + false + } + } + } + + override fun execute( + context: BorrowContextType + ) { + try { + BorrowLCPSupport.checkDRMSupport(context) + val passphrase = BorrowLCPSupport.findPassphraseOrManual(context) + val downloaded = this.fulfill(context, passphrase) + this.saveFulfilledBook(context, passphrase, downloaded) + throw BorrowSubtaskException.BorrowSubtaskHaltedEarly() + } catch (e: BorrowSubtaskFailed) { + context.bookDownloadFailed() + throw e + } + } + + private data class DownloadedManifest( + val manifestURI: URI?, + val manifestData: ByteArray, + val licenseData: ByteArray + ) + + private fun fulfill( + context: BorrowContextType, + passphrase: String + ): DownloadedManifest { + context.taskRecorder.beginNewStep("Executing audio book manifest strategy...") + + val strategy = + context.audioBookManifestStrategies.createStrategy( + context = context.application, + AudioBookManifestRequest( + cacheDirectory = context.cacheDirectory(), + contentType = context.currentAcquisitionPathElement.mimeType, + credentials = context.account.loginState.credentials, + httpClient = context.httpClient, + services = context.services, + target = AudioBookLink.License(context.currentURICheck()), + userAgent = PlayerUserAgent(context.httpClient.userAgent()), + ) + ) + + val subscription = + strategy.events.subscribe { message -> + context.bookDownloadIsRunning( + message = message, + receivedSize = 50L, + expectedSize = 100L, + bytesPerSecond = 0L + ) + } + + return try { + when (val result = strategy.execute()) { + is TaskResult.Success -> { + val licenseBytes = result.result.licenseBytes + if (licenseBytes == null) { + val exception = BorrowSubtaskFailed() + context.taskRecorder.addAll(result.steps) + context.taskRecorder.addAttributes(result.attributes) + context.taskRecorder.beginNewStep("Checking audiobook strategy result...") + context.taskRecorder.currentStepFailed( + "Download succeeded, but no LCP license was provided!", + audioStrategyFailed, + exception = exception, + extraMessages = listOf() + ) + throw exception + } + + context.taskRecorder.addAll(result.steps) + context.taskRecorder.addAttributes(result.attributes) + context.taskRecorder.beginNewStep("Checking audiobook strategy result...") + context.taskRecorder.currentStepSucceeded("Strategy succeeded.") + + val outputFile = File.createTempFile("manifest", "data", context.cacheDirectory()) + outputFile.writeBytes(result.result.fulfilled.data) + DownloadedManifest( + manifestURI = result.result.fulfilled.source, + manifestData = result.result.fulfilled.data, + licenseData = licenseBytes + ) + } + + is TaskResult.Failure -> { + val exception = BorrowSubtaskFailed() + context.taskRecorder.addAll(result.steps) + context.taskRecorder.addAttributes(result.attributes) + context.taskRecorder.beginNewStep("Checking AudioBook strategy result…") + context.taskRecorder.currentStepFailed( + message = "Strategy failed.", + errorCode = audioStrategyFailed, + exception = exception, + extraMessages = listOf() + ) + throw exception + } + } + } finally { + subscription.dispose() + } + } + + private fun saveFulfilledBook( + context: BorrowContextType, + passphrase: String, + manifest: DownloadedManifest + ) { + context.taskRecorder.beginNewStep("Saving fulfilled book...") + + val formatHandle = this.findFormatHandle(context) + formatHandle.setDRMKind(BookDRMKind.LCP) + + val drmHandle = formatHandle.drmInformationHandle as BookDRMInformationHandleLCP + drmHandle.setInfo(passphrase, manifest.licenseData) + + when (formatHandle) { + is BookDatabaseEntryFormatHandleAudioBook -> { + formatHandle.copyInManifestAndURI( + data = manifest.manifestData, + manifestURI = manifest.manifestURI + ) + context.taskRecorder.currentStepSucceeded("Saved book.") + context.bookDownloadSucceeded() + } + + is BookDatabaseEntryFormatHandleEPUB, + is BookDatabaseEntryFormatHandlePDF -> + throw UnreachableCodeException() + } + + context.taskRecorder.currentStepSucceeded("Saved book.") + } + + /** + * Determine the actual book format we're aiming for at the end of the acquisition path. + */ + + private fun findFormatHandle( + context: BorrowContextType + ): BookDatabaseEntryFormatHandle { + val eventualType = lcpAudioBooks + val formatHandle = context.bookDatabaseEntry.findFormatHandleForContentType(eventualType) + if (formatHandle == null) { + context.taskRecorder.currentStepFailed( + message = "No format handle available for ${eventualType.fullType}", + errorCode = BorrowErrorCodes.noFormatHandle, + extraMessages = listOf() + ) + throw BorrowSubtaskFailed() + } + return formatHandle + } +} diff --git a/simplified-books-borrowing/src/main/java/org/nypl/simplified/books/borrowing/internal/BorrowLCPEpub.kt b/simplified-books-borrowing/src/main/java/org/nypl/simplified/books/borrowing/internal/BorrowLCPEpub.kt new file mode 100644 index 000000000..3e5c56587 --- /dev/null +++ b/simplified-books-borrowing/src/main/java/org/nypl/simplified/books/borrowing/internal/BorrowLCPEpub.kt @@ -0,0 +1,256 @@ +package org.nypl.simplified.books.borrowing.internal + +import com.io7m.junreachable.UnreachableCodeException +import kotlinx.coroutines.runBlocking +import one.irradia.mime.api.MIMECompatibility +import one.irradia.mime.api.MIMEType +import org.librarysimplified.http.downloads.LSHTTPDownloadState.LSHTTPDownloadResult.DownloadCancelled +import org.librarysimplified.http.downloads.LSHTTPDownloadState.LSHTTPDownloadResult.DownloadCompletedSuccessfully +import org.librarysimplified.http.downloads.LSHTTPDownloadState.LSHTTPDownloadResult.DownloadFailed.DownloadFailedExceptionally +import org.librarysimplified.http.downloads.LSHTTPDownloadState.LSHTTPDownloadResult.DownloadFailed.DownloadFailedServer +import org.librarysimplified.http.downloads.LSHTTPDownloadState.LSHTTPDownloadResult.DownloadFailed.DownloadFailedUnacceptableMIME +import org.librarysimplified.http.downloads.LSHTTPDownloads +import org.nypl.simplified.accounts.api.AccountReadableType +import org.nypl.simplified.books.api.BookDRMKind +import org.nypl.simplified.books.book_database.BookDRMInformationHandleLCP +import org.nypl.simplified.books.book_database.api.BookDatabaseEntryFormatHandle +import org.nypl.simplified.books.book_database.api.BookDatabaseEntryFormatHandle.BookDatabaseEntryFormatHandleAudioBook +import org.nypl.simplified.books.book_database.api.BookDatabaseEntryFormatHandle.BookDatabaseEntryFormatHandleEPUB +import org.nypl.simplified.books.book_database.api.BookDatabaseEntryFormatHandle.BookDatabaseEntryFormatHandlePDF +import org.nypl.simplified.books.borrowing.BorrowContextType +import org.nypl.simplified.books.borrowing.internal.BorrowLCPSupport.fetchAllR2ErrorMessages +import org.nypl.simplified.books.borrowing.subtasks.BorrowSubtaskException.BorrowSubtaskCancelled +import org.nypl.simplified.books.borrowing.subtasks.BorrowSubtaskException.BorrowSubtaskFailed +import org.nypl.simplified.books.borrowing.subtasks.BorrowSubtaskException.BorrowSubtaskHaltedEarly +import org.nypl.simplified.books.borrowing.subtasks.BorrowSubtaskFactoryType +import org.nypl.simplified.books.borrowing.subtasks.BorrowSubtaskType +import org.nypl.simplified.books.formats.api.StandardFormatNames +import org.nypl.simplified.books.formats.api.StandardFormatNames.genericEPUBFiles +import org.nypl.simplified.books.formats.api.StandardFormatNames.lcpLicenseFiles +import org.readium.r2.lcp.license.model.LicenseDocument +import org.readium.r2.shared.util.ErrorException +import org.readium.r2.shared.util.Try +import java.io.File +import java.io.IOException +import java.net.URI + +class BorrowLCPEpub : BorrowSubtaskType { + + companion object : BorrowSubtaskFactoryType { + override val name: String + get() = "LCP EPub Download" + + override fun createSubtask(): BorrowSubtaskType { + return BorrowLCPEpub() + } + + override fun isApplicableFor( + type: MIMEType, + target: URI?, + account: AccountReadableType?, + remaining: List + ): Boolean { + return if (MIMECompatibility.isCompatibleStrictWithoutAttributes(type, lcpLicenseFiles)) { + val next = remaining.firstOrNull() + if (next != null) { + MIMECompatibility.isCompatibleStrictWithoutAttributes(next, genericEPUBFiles) + } else { + false + } + } else { + false + } + } + } + + override fun execute( + context: BorrowContextType + ) { + try { + BorrowLCPSupport.checkDRMSupport(context) + + val passphrase = + BorrowLCPSupport.findPassphraseOrManual(context) + val licenseBytes = + BorrowLCPSupport.downloadLicense(context, context.currentURICheck()) + + this.fulfill(context, licenseBytes, passphrase) + } catch (e: BorrowSubtaskFailed) { + context.bookDownloadFailed() + throw e + } + } + + private fun fulfill( + context: BorrowContextType, + licenseBytes: ByteArray, + passphrase: String + ) { + context.taskRecorder.beginNewStep("Fulfilling book...") + context.checkCancelled() + + val fulfillmentMimeType = + context.opdsAcquisitionPath.asMIMETypes().last() + val temporaryFile = + context.temporaryFile("epub") + + try { + val license = + when (val r = LicenseDocument.fromBytes(licenseBytes)) { + is Try.Failure -> throw ErrorException(r.value) + is Try.Success -> r.value + } + + val link = + license.link(LicenseDocument.Rel.Publication) + val url = + URI.create( + link + ?.url() + ?.toString() + ?: throw IOException("Unparseable license link ($link)") + ) + + val downloadRequest = + BorrowHTTP.createDownloadRequest( + context = context, + target = url, + outputFile = temporaryFile, + requestModifier = { properties -> + properties.copy( + authorization = null + ) + }, + expectedTypes = hashSetOf( + fulfillmentMimeType, + // Sometimes fulfillment servers will set the content type to generic zip or octet + // stream, so these are acceptable too. + StandardFormatNames.genericZIPFiles, + MIMECompatibility.applicationOctetStream + ) + ) + + when (val result = LSHTTPDownloads.download(downloadRequest)) { + DownloadCancelled -> + throw BorrowSubtaskCancelled() + + is DownloadFailedServer -> + throw BorrowHTTP.onDownloadFailedServer(context, result) + + is DownloadFailedUnacceptableMIME -> + throw BorrowSubtaskFailed() + + is DownloadFailedExceptionally -> + throw BorrowHTTP.onDownloadFailedExceptionally(context, result) + + is DownloadCompletedSuccessfully -> { + this.installLicense(context, temporaryFile, licenseBytes) + this.saveFulfilledBook(context, temporaryFile, passphrase, licenseBytes) + context.bookDownloadSucceeded() + } + } + } catch (e: BorrowSubtaskFailed) { + context.bookDownloadFailed() + throw e + } catch (e: Exception) { + context.taskRecorder.currentStepFailed( + message = "LCP fulfillment error: ${e.message}", + errorCode = BorrowErrorCodes.lcpFulfillmentFailed, + exception = e, + extraMessages = listOf() + ) + throw BorrowSubtaskFailed() + } finally { + temporaryFile.delete() + } + + /* + * LCP is a special case in the sense that it supersedes any acquisition + * path elements that might follow this one. We mark this subtask as having halted + * early. + */ + + throw BorrowSubtaskHaltedEarly() + } + + /** + * Install the license into the fulfilled book. + */ + + private fun installLicense( + context: BorrowContextType, + bookFile: File, + licenseBytes: ByteArray + ) { + context.taskRecorder.beginNewStep("Installing license...") + + val lcpService = + context.lcpService ?: throw UnreachableCodeException() + + runBlocking { + when (val r = LicenseDocument.fromBytes(licenseBytes)) { + is Try.Failure -> { + context.taskRecorder.currentStepFailed( + message = "Unparseable LCP license.", + errorCode = "errorLCPLicense", + extraMessages = fetchAllR2ErrorMessages(r) + ) + context.taskRecorder.addAttribute("Parse Error", r.value.message) + context.bookDownloadFailed() + throw BorrowSubtaskFailed() + } + + is Try.Success -> lcpService.injectLicenseDocument(r.value, bookFile) + } + } + + context.taskRecorder.currentStepSucceeded("License installed.") + } + + private fun saveFulfilledBook( + context: BorrowContextType, + bookFile: File, + passphrase: String, + licenseBytes: ByteArray + ) { + context.taskRecorder.beginNewStep("Saving fulfilled book...") + + val formatHandle = this.findFormatHandle(context) + formatHandle.setDRMKind(BookDRMKind.LCP) + + val drmHandle = formatHandle.drmInformationHandle as BookDRMInformationHandleLCP + drmHandle.setInfo(passphrase, licenseBytes) + + when (formatHandle) { + is BookDatabaseEntryFormatHandleEPUB -> { + formatHandle.copyInBook(bookFile) + } + + is BookDatabaseEntryFormatHandleAudioBook, + is BookDatabaseEntryFormatHandlePDF -> + throw UnreachableCodeException() + } + + context.taskRecorder.currentStepSucceeded("Saved book.") + } + + /** + * Determine the actual book format we're aiming for at the end of the acquisition path. + */ + + private fun findFormatHandle( + context: BorrowContextType + ): BookDatabaseEntryFormatHandle { + val eventualType = context.opdsAcquisitionPath.asMIMETypes().last() + val formatHandle = context.bookDatabaseEntry.findFormatHandleForContentType(eventualType) + if (formatHandle == null) { + context.taskRecorder.currentStepFailed( + message = "No format handle available for ${eventualType.fullType}", + errorCode = BorrowErrorCodes.noFormatHandle, + extraMessages = listOf() + ) + throw BorrowSubtaskFailed() + } + return formatHandle + } +} diff --git a/simplified-books-borrowing/src/main/java/org/nypl/simplified/books/borrowing/internal/BorrowLCPSupport.kt b/simplified-books-borrowing/src/main/java/org/nypl/simplified/books/borrowing/internal/BorrowLCPSupport.kt new file mode 100644 index 000000000..774ad16cf --- /dev/null +++ b/simplified-books-borrowing/src/main/java/org/nypl/simplified/books/borrowing/internal/BorrowLCPSupport.kt @@ -0,0 +1,269 @@ +package org.nypl.simplified.books.borrowing.internal + +import org.librarysimplified.http.api.LSHTTPResponseStatus +import org.librarysimplified.http.downloads.LSHTTPDownloadState.LSHTTPDownloadResult.DownloadCancelled +import org.librarysimplified.http.downloads.LSHTTPDownloadState.LSHTTPDownloadResult.DownloadCompletedSuccessfully +import org.librarysimplified.http.downloads.LSHTTPDownloadState.LSHTTPDownloadResult.DownloadFailed.DownloadFailedExceptionally +import org.librarysimplified.http.downloads.LSHTTPDownloadState.LSHTTPDownloadResult.DownloadFailed.DownloadFailedServer +import org.librarysimplified.http.downloads.LSHTTPDownloadState.LSHTTPDownloadResult.DownloadFailed.DownloadFailedUnacceptableMIME +import org.librarysimplified.http.downloads.LSHTTPDownloads +import org.nypl.simplified.accounts.api.AccountAuthenticatedHTTP +import org.nypl.simplified.accounts.api.AccountAuthenticatedHTTP.addCredentialsToProperties +import org.nypl.simplified.accounts.api.AccountAuthenticatedHTTP.getAccessToken +import org.nypl.simplified.books.borrowing.BorrowContextType +import org.nypl.simplified.books.borrowing.internal.BorrowErrorCodes.lcpNotSupported +import org.nypl.simplified.books.borrowing.subtasks.BorrowSubtaskException.BorrowSubtaskCancelled +import org.nypl.simplified.books.borrowing.subtasks.BorrowSubtaskException.BorrowSubtaskFailed +import org.nypl.simplified.opds.core.OPDSAcquisitionPaths +import org.nypl.simplified.opds.core.OPDSFeedParserType +import org.readium.r2.shared.util.Error +import org.readium.r2.shared.util.Try +import org.readium.r2.shared.util.http.HttpError +import java.net.URI + +/** + * Support functionality for LCP books. + */ + +object BorrowLCPSupport { + + /** + * Check that we actually have the required DRM support. + */ + + fun checkDRMSupport( + context: BorrowContextType + ) { + context.taskRecorder.beginNewStep("Checking for LCP support...") + if (context.lcpService == null) { + context.taskRecorder.currentStepFailed( + message = "This build of the application does not support LCP DRM.", + errorCode = lcpNotSupported, + extraMessages = listOf() + ) + throw BorrowSubtaskFailed() + } + } + + fun downloadLicense( + context: BorrowContextType, + target: URI + ): ByteArray { + context.logDebug("Downloading license from {}", target) + context.taskRecorder.beginNewStep("Downloading license from $target...") + context.taskRecorder.addAttribute("URI", target.toString()) + context.checkCancelled() + + val temporaryFile = context.temporaryFile() + + return try { + val downloadRequest = + BorrowHTTP.createDownloadRequest( + context = context, + target = target, + outputFile = temporaryFile + ) + + when (val result = LSHTTPDownloads.download(downloadRequest)) { + DownloadCancelled -> { + throw BorrowSubtaskCancelled() + } + + is DownloadFailedServer -> { + throw BorrowHTTP.onDownloadFailedServer(context, result) + } + + is DownloadFailedUnacceptableMIME -> { + throw BorrowSubtaskFailed() + } + + is DownloadFailedExceptionally -> { + throw BorrowHTTP.onDownloadFailedExceptionally(context, result) + } + + is DownloadCompletedSuccessfully -> { + temporaryFile.readBytes() + } + } + } finally { + temporaryFile.delete() + } + } + + /** + * Find a passphrase for the given LCP book from an associated loans feed. + */ + + fun findPassphraseOrManual( + context: BorrowContextType + ): String { + return if (context.isManualLCPPassphraseEnabled) { + // if the manual input for the LCP passphrase is enabled, we need to catch a possible + // exception while fetching the current passphrase as it may be possible for the user to + // manually input it and if the exception isn't caught, the download will immediately fail. + try { + this.findPassphrase(context) + } catch (e: Exception) { + "" + } + } else { + this.findPassphrase(context) + } + } + + /** + * Find a passphrase for the given LCP book from an associated loans feed. + */ + + fun findPassphrase( + context: BorrowContextType + ): String { + context.taskRecorder.beginNewStep("Retrieving LCP hashed passphrase…") + + val loansURI = context.account.provider.loansURI + if (loansURI == null) { + context.taskRecorder.currentStepFailed( + message = "No loans URI provided; unable to retrieve a passphrase.", + errorCode = "lcpMissingLoans", + extraMessages = listOf() + ) + throw BorrowSubtaskFailed() + } + + val credentials = + context.account.loginState.credentials + val auth = + AccountAuthenticatedHTTP.createAuthorizationIfPresent(credentials) + + val request = + context.httpClient.newRequest(loansURI) + .setAuthorization(auth) + .addCredentialsToProperties(credentials) + .build() + + return request.execute().use { response -> + when (val status = response.status) { + is LSHTTPResponseStatus.Responded.OK -> { + context.account.updateBasicTokenCredentials(status.getAccessToken()) + this.findPassphraseHandleOK(context, loansURI, status) + } + + is LSHTTPResponseStatus.Responded.Error -> { + this.findPassphraseHandleError(context, status) + } + + is LSHTTPResponseStatus.Failed -> { + this.findPassphraseHandleFailure(context, status) + } + } + } + } + + private fun findPassphraseHandleFailure( + context: BorrowContextType, + status: LSHTTPResponseStatus.Failed + ): String { + context.taskRecorder.currentStepFailed( + message = status.exception.message ?: "Exception raised during connection attempt.", + errorCode = BorrowErrorCodes.httpConnectionFailed, + exception = status.exception, + extraMessages = listOf() + ) + throw BorrowSubtaskFailed() + } + + private fun findPassphraseHandleError( + context: BorrowContextType, + status: LSHTTPResponseStatus.Responded.Error + ): String { + val report = status.properties.problemReport + if (report != null) { + context.taskRecorder.addAttributes(report.toMap()) + } + context.taskRecorder.currentStepFailed( + message = "HTTP request failed: ${status.properties.originalStatus} ${status.properties.message}", + errorCode = BorrowErrorCodes.httpRequestFailed, + exception = null, + extraMessages = listOf() + ) + throw BorrowSubtaskFailed() + } + + private fun findPassphraseHandleOK( + context: BorrowContextType, + loansURI: URI, + status: LSHTTPResponseStatus.Responded.OK + ): String { + val feedParser = + context.services.requireService(OPDSFeedParserType::class.java) + + val entryFound = try { + val result = feedParser.parse(loansURI, status.bodyStream) + result.feedEntries.find { entry -> entry.id == context.bookCurrent.entry.id } + } catch (e: Exception) { + context.taskRecorder.currentStepFailed( + message = "Unable to parse loans feed (${e.message})", + errorCode = "lcpUnparseableLoansFeed", + exception = e, + extraMessages = listOf() + ) + throw BorrowSubtaskFailed() + } + + if (entryFound == null) { + context.taskRecorder.currentStepFailed( + message = "Unable to locate the current book in the user's loans feed.", + errorCode = "lcpMissingEntryInLoansFeed", + exception = null, + extraMessages = listOf() + ) + throw BorrowSubtaskFailed() + } + + val linearized = OPDSAcquisitionPaths.linearize(entryFound) + for (path in linearized) { + for (element in path.elements) { + val passphrase = element.properties["lcp:hashed_passphrase"] + if (passphrase != null) { + context.taskRecorder.currentStepSucceeded("Found LCP passphrase") + return passphrase + } + } + } + + context.taskRecorder.currentStepFailed( + message = "No LCP hashed passphrase was provided.", + errorCode = "lcpMissingPassphrase", + extraMessages = listOf() + ) + throw BorrowSubtaskFailed() + } + + fun fetchAllR2ErrorMessages( + failure: Try.Failure<*, Error> + ): List { + val messages = mutableListOf() + var errorNow: Error? = failure.value + while (true) { + if (errorNow == null) { + break + } + messages.add(errorNow.message) + when (val e = errorNow) { + is HttpError.ErrorResponse -> { + messages.add("URL returned HTTP status ${e.status}.") + val problemDetails = e.problemDetails + if (problemDetails != null) { + messages.add("Problem details [Title]: ${problemDetails.title}") + messages.add("Problem details [Detail]: ${problemDetails.detail}") + messages.add("Problem details [Type]: ${problemDetails.type}") + messages.add("Problem details [Instance]: ${problemDetails.instance}") + messages.add("Problem details [Status]: ${problemDetails.status}") + } + } + } + errorNow = errorNow.cause + } + return messages.toList() + } +} diff --git a/simplified-books-borrowing/src/main/java/org/nypl/simplified/books/borrowing/internal/BorrowLoanCreate.kt b/simplified-books-borrowing/src/main/java/org/nypl/simplified/books/borrowing/internal/BorrowLoanCreate.kt index 910a67791..45d5b939b 100644 --- a/simplified-books-borrowing/src/main/java/org/nypl/simplified/books/borrowing/internal/BorrowLoanCreate.kt +++ b/simplified-books-borrowing/src/main/java/org/nypl/simplified/books/borrowing/internal/BorrowLoanCreate.kt @@ -64,7 +64,8 @@ class BorrowLoanCreate private constructor() : BorrowSubtaskType { override fun isApplicableFor( type: MIMEType, target: URI?, - account: AccountReadableType? + account: AccountReadableType?, + remaining: List ): Boolean { for (opdsType in allOPDSFeeds) { if (MIMECompatibility.isCompatibleStrictWithoutAttributes(opdsType, type)) { @@ -128,7 +129,8 @@ class BorrowLoanCreate private constructor() : BorrowSubtaskType { context.taskRecorder.currentStepFailed( message = status.exception.message ?: "Exception raised during connection attempt.", errorCode = httpConnectionFailed, - exception = status.exception + exception = status.exception, + extraMessages = listOf() ) throw BorrowSubtaskFailed() } @@ -158,7 +160,8 @@ class BorrowLoanCreate private constructor() : BorrowSubtaskType { context.taskRecorder.currentStepFailed( message = "HTTP request failed: ${status.properties.originalStatus} ${status.properties.message}", errorCode = httpRequestFailed, - exception = null + exception = null, + extraMessages = listOf() ) if (report?.type == "http://librarysimplified.org/terms/problem/loan-limit-reached") { @@ -230,8 +233,9 @@ class BorrowLoanCreate private constructor() : BorrowSubtaskType { override fun onHoldable(a: OPDSAvailabilityHoldable) { context.taskRecorder.currentStepFailed( - "Book is unexpectedly holdable.", - opdsFeedEntryHoldable + message = "Book is unexpectedly holdable.", + errorCode = opdsFeedEntryHoldable, + extraMessages = listOf() ) throw BorrowSubtaskFailed() } @@ -258,8 +262,9 @@ class BorrowLoanCreate private constructor() : BorrowSubtaskType { override fun onLoanable(a: OPDSAvailabilityLoanable) { context.taskRecorder.currentStepFailed( - "Book is unexpectedly loanable.", - opdsFeedEntryLoanable + message = "Book is unexpectedly loanable.", + errorCode = opdsFeedEntryLoanable, + extraMessages = listOf() ) throw BorrowSubtaskFailed() } @@ -304,7 +309,8 @@ class BorrowLoanCreate private constructor() : BorrowSubtaskType { context.taskRecorder.currentStepFailed( message = "Failed to parse the OPDS feed entry (${e.message}).", errorCode = opdsFeedEntryParseError, - exception = e + exception = e, + extraMessages = listOf() ) throw BorrowSubtaskFailed() } @@ -333,7 +339,8 @@ class BorrowLoanCreate private constructor() : BorrowSubtaskType { context.taskRecorder.currentStepFailed( message = "The OPDS feed entry did not provide a 'next' URI.", - errorCode = opdsFeedEntryNoNext + errorCode = opdsFeedEntryNoNext, + extraMessages = listOf() ) throw BorrowSubtaskFailed() } diff --git a/simplified-books-borrowing/src/main/java/org/nypl/simplified/books/borrowing/internal/BorrowSAMLDownload.kt b/simplified-books-borrowing/src/main/java/org/nypl/simplified/books/borrowing/internal/BorrowSAMLDownload.kt index 884a168a4..1d0162ac6 100644 --- a/simplified-books-borrowing/src/main/java/org/nypl/simplified/books/borrowing/internal/BorrowSAMLDownload.kt +++ b/simplified-books-borrowing/src/main/java/org/nypl/simplified/books/borrowing/internal/BorrowSAMLDownload.kt @@ -37,10 +37,11 @@ class BorrowSAMLDownload private constructor() : BorrowSubtaskType { override fun isApplicableFor( type: MIMEType, target: URI?, - account: AccountReadableType? + account: AccountReadableType?, + remaining: List ): Boolean = account?.loginState?.credentials is AccountAuthenticationCredentials.SAML2_0 && - BorrowDirectDownload.isApplicableFor(type, target, account) + BorrowDirectDownload.isApplicableFor(type, target, account, remaining) } override fun execute( diff --git a/simplified-books-borrowing/src/main/java/org/nypl/simplified/books/borrowing/internal/BorrowSubtaskDirectory.kt b/simplified-books-borrowing/src/main/java/org/nypl/simplified/books/borrowing/internal/BorrowSubtaskDirectory.kt index a1542bb5b..778c4652e 100644 --- a/simplified-books-borrowing/src/main/java/org/nypl/simplified/books/borrowing/internal/BorrowSubtaskDirectory.kt +++ b/simplified-books-borrowing/src/main/java/org/nypl/simplified/books/borrowing/internal/BorrowSubtaskDirectory.kt @@ -17,7 +17,8 @@ class BorrowSubtaskDirectory : BorrowSubtaskDirectoryType { // BorrowSAMLDownload must precede BorrowDirectDownload in precedence. BorrowSAMLDownload, BorrowDirectDownload, - BorrowLCP, + BorrowLCPAudiobook, + BorrowLCPEpub, BorrowLoanCreate ) } diff --git a/simplified-books-borrowing/src/main/java/org/nypl/simplified/books/borrowing/subtasks/BorrowSubtaskDirectoryType.kt b/simplified-books-borrowing/src/main/java/org/nypl/simplified/books/borrowing/subtasks/BorrowSubtaskDirectoryType.kt index 78659572d..b927d5076 100644 --- a/simplified-books-borrowing/src/main/java/org/nypl/simplified/books/borrowing/subtasks/BorrowSubtaskDirectoryType.kt +++ b/simplified-books-borrowing/src/main/java/org/nypl/simplified/books/borrowing/subtasks/BorrowSubtaskDirectoryType.kt @@ -23,10 +23,11 @@ interface BorrowSubtaskDirectoryType { fun findSubtaskFor( mimeType: MIMEType, target: URI?, - account: AccountReadableType? + account: AccountReadableType?, + remainingTypes: List ): BorrowSubtaskFactoryType? { return this.subtasks.firstOrNull { factory -> - factory.isApplicableFor(mimeType, target, account) + factory.isApplicableFor(mimeType, target, account, remainingTypes) } } } diff --git a/simplified-books-borrowing/src/main/java/org/nypl/simplified/books/borrowing/subtasks/BorrowSubtaskFactoryType.kt b/simplified-books-borrowing/src/main/java/org/nypl/simplified/books/borrowing/subtasks/BorrowSubtaskFactoryType.kt index cc003dcfa..f3d82fa49 100644 --- a/simplified-books-borrowing/src/main/java/org/nypl/simplified/books/borrowing/subtasks/BorrowSubtaskFactoryType.kt +++ b/simplified-books-borrowing/src/main/java/org/nypl/simplified/books/borrowing/subtasks/BorrowSubtaskFactoryType.kt @@ -31,6 +31,7 @@ interface BorrowSubtaskFactoryType { fun isApplicableFor( type: MIMEType, target: URI?, - account: AccountReadableType? + account: AccountReadableType?, + remaining: List ): Boolean } diff --git a/simplified-books-controller/src/main/java/org/nypl/simplified/books/controller/AbstractBookTask.kt b/simplified-books-controller/src/main/java/org/nypl/simplified/books/controller/AbstractBookTask.kt index 1353351f5..2ed6f6ba3 100644 --- a/simplified-books-controller/src/main/java/org/nypl/simplified/books/controller/AbstractBookTask.kt +++ b/simplified-books-controller/src/main/java/org/nypl/simplified/books/controller/AbstractBookTask.kt @@ -52,23 +52,28 @@ abstract class AbstractBookTask( protected abstract fun onFailure(result: TaskResult.Failure) override fun call(): TaskResult { - this.logger.debug("starting task") + this.logger.debug("Starting task") return try { val profile = this.findProfile(profileID) val account = this.findAccount(accountID, profile) val result = this.execute(account) - this.logger.debug("task succeeded") + this.logger.debug("Task succeeded") result } catch (e: TaskFailedHandled) { - this.logger.error("task failed with handled exception: ", e.cause) + this.logger.error("Task failed with handled exception: ", e.cause) val result = this.taskRecorder.finishFailure() this.onFailure(result) result - } catch (e: Exception) { - this.logger.error("task failed with unhandled exception: ", e) + } catch (e: Throwable) { + this.logger.error("Task failed with unhandled exception: ", e) val msg = e.message ?: e.javaClass.name - this.taskRecorder.currentStepFailedAppending(msg, BorrowErrorCodes.unexpectedException, e) + this.taskRecorder.currentStepFailedAppending( + message = msg, + errorCode = BorrowErrorCodes.unexpectedException, + exception = e, + extraMessages = listOf() + ) val result = this.taskRecorder.finishFailure() this.onFailure(result) result @@ -91,7 +96,8 @@ abstract class AbstractBookTask( this.taskRecorder.currentStepFailedAppending( message = "Failed to find profile.", errorCode = BorrowErrorCodes.profileNotFound, - exception = exception + exception = exception, + extraMessages = listOf() ) throw TaskFailedHandled(exception) } else { @@ -118,7 +124,8 @@ abstract class AbstractBookTask( this.taskRecorder.currentStepFailedAppending( message = "Failed to find account.", errorCode = BorrowErrorCodes.accountsDatabaseException, - exception = e + exception = e, + extraMessages = listOf() ) throw TaskFailedHandled(e) @@ -157,7 +164,12 @@ abstract class AbstractBookTask( } else { this.logger.debug("credentials required but none are available") val exception = BookRevokeExceptionNoCredentials() - this.taskRecorder.currentStepFailed("Credentials required, but none are available.", "credentialsRequired", exception) + this.taskRecorder.currentStepFailed( + message = "Credentials required, but none are available.", + errorCode = "credentialsRequired", + exception = exception, + extraMessages = listOf() + ) throw TaskFailedHandled(exception) } } diff --git a/simplified-books-controller/src/main/java/org/nypl/simplified/books/controller/BookDeleteTask.kt b/simplified-books-controller/src/main/java/org/nypl/simplified/books/controller/BookDeleteTask.kt index 754067103..692acd64d 100644 --- a/simplified-books-controller/src/main/java/org/nypl/simplified/books/controller/BookDeleteTask.kt +++ b/simplified-books-controller/src/main/java/org/nypl/simplified/books/controller/BookDeleteTask.kt @@ -51,7 +51,8 @@ class BookDeleteTask( this.taskRecorder.currentStepFailed( message = e.message ?: e.javaClass.canonicalName ?: "unknown", errorCode = "deleteFailed", - exception = e + exception = e, + extraMessages = listOf() ) throw TaskFailedHandled(e) } diff --git a/simplified-books-controller/src/main/java/org/nypl/simplified/books/controller/BookRevokeTask.kt b/simplified-books-controller/src/main/java/org/nypl/simplified/books/controller/BookRevokeTask.kt index a736ab84a..6f445cdca 100644 --- a/simplified-books-controller/src/main/java/org/nypl/simplified/books/controller/BookRevokeTask.kt +++ b/simplified-books-controller/src/main/java/org/nypl/simplified/books/controller/BookRevokeTask.kt @@ -196,7 +196,12 @@ class BookRevokeTask( val exception = BookRevokeExceptionNotRevocable() val message = this.revokeStrings.revokeServerNotifyNotRevocable(availability.javaClass.simpleName) - this.taskRecorder.currentStepFailed(message, "notRevocable", exception) + this.taskRecorder.currentStepFailed( + message = message, + errorCode = "notRevocable", + exception = exception, + extraMessages = listOf() + ) throw TaskFailedHandled(exception) } @@ -215,7 +220,12 @@ class BookRevokeTask( val exception = BookRevokeExceptionNotRevocable() val message = this.revokeStrings.revokeServerNotifyNotRevocable(availability.javaClass.simpleName) - this.taskRecorder.currentStepFailed(message, "notRevocable", exception) + this.taskRecorder.currentStepFailed( + message = message, + errorCode = "notRevocable", + exception = exception, + extraMessages = listOf() + ) throw TaskFailedHandled(exception) } @@ -280,7 +290,10 @@ class BookRevokeTask( this.databaseEntry.writeOPDSEntry(entry.feedEntry) } catch (e: Exception) { this.taskRecorder.currentStepFailed( - this.revokeStrings.revokeServerNotifySavingEntryFailed, "unexpectedException", e + message = this.revokeStrings.revokeServerNotifySavingEntryFailed, + errorCode = "unexpectedException", + exception = e, + extraMessages = listOf() ) throw TaskFailedHandled(e) } @@ -316,7 +329,12 @@ class BookRevokeTask( ).get(this.revokeServerTimeoutDuration.standardSeconds, TimeUnit.SECONDS) } catch (e: TimeoutException) { val message = this.revokeStrings.revokeServerNotifyFeedTimedOut - this.taskRecorder.currentStepFailed(message, "timedOut", e) + this.taskRecorder.currentStepFailed( + message = message, + errorCode = "timedOut", + exception = e, + extraMessages = listOf() + ) throw TaskFailedHandled(e) } catch (e: ExecutionException) { val ex = e.cause!! @@ -325,7 +343,12 @@ class BookRevokeTask( } val message = this.revokeStrings.revokeServerNotifyFeedTimedOut - this.taskRecorder.currentStepFailed(message, "feedLoaderFailed", ex) + this.taskRecorder.currentStepFailed( + message = message, + errorCode = "feedLoaderFailed", + exception = ex, + extraMessages = listOf() + ) throw TaskFailedHandled(ex) } @@ -339,14 +362,24 @@ class BookRevokeTask( is FeedLoaderFailedGeneral -> { val message = this.revokeStrings.revokeServerNotifyFeedFailed this.taskRecorder.addAttributesIfPresent(feedResult.problemReport?.toMap()) - this.taskRecorder.currentStepFailed(message, "feedLoaderFailed", feedResult.exception) + this.taskRecorder.currentStepFailed( + message = message, + errorCode = "feedLoaderFailed", + exception = feedResult.exception, + extraMessages = listOf() + ) throw TaskFailedHandled(feedResult.exception) } is FeedLoaderFailedAuthentication -> { val message = this.revokeStrings.revokeServerNotifyFeedFailed this.taskRecorder.addAttributesIfPresent(feedResult.problemReport?.toMap()) - this.taskRecorder.currentStepFailed(message, "feedLoaderFailed", feedResult.exception) + this.taskRecorder.currentStepFailed( + message = message, + errorCode = "feedLoaderFailed", + exception = feedResult.exception, + extraMessages = listOf() + ) throw TaskFailedHandled(feedResult.exception) } } @@ -360,7 +393,12 @@ class BookRevokeTask( if (feed.size == 0) { val exception = BookRevokeExceptionBadFeed() val message = this.revokeStrings.revokeServerNotifyFeedEmpty - this.taskRecorder.currentStepFailed(message, "feedLoaderFailed", exception) + this.taskRecorder.currentStepFailed( + message = message, + errorCode = "feedLoaderFailed", + exception = exception, + extraMessages = listOf() + ) throw TaskFailedHandled(exception) } @@ -370,17 +408,29 @@ class BookRevokeTask( is FeedEntryCorrupt -> { val exception = BookRevokeExceptionBadFeed() val message = this.revokeStrings.revokeServerNotifyFeedCorrupt - this.taskRecorder.currentStepFailed(message, "feedCorrupted", feedEntry.error) + this.taskRecorder.currentStepFailed( + message = message, + errorCode = "feedCorrupted", + exception = feedEntry.error, + extraMessages = listOf() + ) throw TaskFailedHandled(exception) } + is FeedEntryOPDS -> feedEntry } } + is Feed.FeedWithGroups -> { val exception = BookRevokeExceptionBadFeed() val message = this.revokeStrings.revokeServerNotifyFeedWithGroups - this.taskRecorder.currentStepFailed(message, "feedUnusable", exception) + this.taskRecorder.currentStepFailed( + message = message, + errorCode = "feedUnusable", + exception = exception, + extraMessages = listOf() + ) throw TaskFailedHandled(exception) } } @@ -394,10 +444,13 @@ class BookRevokeTask( return when (val handle = this.databaseEntry.findPreferredFormatHandle()) { is BookDatabaseEntryFormatHandleEPUB -> this.revokeFormatHandleEPUB(handle, account) + is BookDatabaseEntryFormatHandlePDF -> this.revokeFormatHandlePDF(handle) + is BookDatabaseEntryFormatHandleAudioBook -> this.revokeFormatHandleAudioBook(handle) + null -> { this.debug("no format handle available, nothing to do!") this.taskRecorder.currentStepSucceeded(this.revokeStrings.revokeFormatNothingToDo) @@ -406,7 +459,10 @@ class BookRevokeTask( } } - private fun revokeFormatHandleEPUB(handle: BookDatabaseEntryFormatHandleEPUB, account: AccountType) { + private fun revokeFormatHandleEPUB( + handle: BookDatabaseEntryFormatHandleEPUB, + account: AccountType + ) { this.debug("revoking via EPUB format handle") this.taskRecorder.beginNewStep(this.revokeStrings.revokeFormatSpecific("EPUB")) this.publishRequestingRevokeStatus() @@ -420,6 +476,7 @@ class BookRevokeTask( this.debug("no Adobe rights, nothing to do!") } } + is BookDRMInformationHandle.LCPHandle, is BookDRMInformationHandle.AxisHandle, is BookDRMInformationHandle.NoneHandle -> { @@ -488,27 +545,54 @@ class BookRevokeTask( adeptFuture.get(this.revokeACSTimeoutDuration.standardSeconds, TimeUnit.SECONDS) } catch (e: TimeoutException) { val message = this.revokeStrings.revokeACSTimedOut - this.taskRecorder.currentStepFailed(message, "timedOut", e) + this.taskRecorder.currentStepFailed( + message, + errorCode = "timedOut", + exception = e, + extraMessages = listOf() + ) throw TaskFailedHandled(e) } catch (e: ExecutionException) { throw when (val cause = e.cause!!) { is CancellationException -> { val message = this.revokeStrings.revokeBookCancelled - this.taskRecorder.currentStepFailed(message, "cancelled", cause) + this.taskRecorder.currentStepFailed( + message = message, + errorCode = "cancelled", + exception = cause, + extraMessages = listOf() + ) TaskFailedHandled(cause) } + is AdobeDRMExtensions.AdobeDRMRevokeException -> { val message = this.revokeStrings.revokeBookACSConnectorFailed(cause.errorCode) - this.taskRecorder.currentStepFailed(message, "${this.adobeACS}: ${cause.errorCode}", cause) + this.taskRecorder.currentStepFailed( + message = message, + errorCode = "${this.adobeACS}: ${cause.errorCode}", + exception = cause, + extraMessages = listOf() + ) TaskFailedHandled(cause) } + else -> { - this.taskRecorder.currentStepFailed(this.revokeStrings.revokeBookACSFailed, "unexpectedException", cause) + this.taskRecorder.currentStepFailed( + message = this.revokeStrings.revokeBookACSFailed, + errorCode = "unexpectedException", + exception = cause, + extraMessages = listOf() + ) TaskFailedHandled(cause) } } } catch (e: Throwable) { - this.taskRecorder.currentStepFailed(this.revokeStrings.revokeBookACSFailed, "unexpectedException", e) + this.taskRecorder.currentStepFailed( + message = this.revokeStrings.revokeBookACSFailed, + errorCode = "unexpectedException", + exception = e, + extraMessages = listOf() + ) throw TaskFailedHandled(e) } @@ -531,7 +615,12 @@ class BookRevokeTask( if (credentials == null) { val exception = BookRevokeExceptionDeviceNotActivated() val message = this.revokeStrings.revokeACSGettingDeviceCredentialsNotActivated - this.taskRecorder.currentStepFailed(message, "${this.adobeACS}: drmDeviceNotActive", exception) + this.taskRecorder.currentStepFailed( + message = message, + errorCode = "${this.adobeACS}: drmDeviceNotActive", + exception = exception, + extraMessages = listOf() + ) throw TaskFailedHandled(exception) } @@ -548,6 +637,7 @@ class BookRevokeTask( when (val drm = handle.drmInformationHandle) { is BookDRMInformationHandle.ACSHandle -> drm.setAdobeRightsInformation(null) + is BookDRMInformationHandle.AxisHandle, is BookDRMInformationHandle.LCPHandle, is BookDRMInformationHandle.NoneHandle -> { @@ -556,7 +646,10 @@ class BookRevokeTask( } } catch (e: Exception) { this.taskRecorder.currentStepFailed( - this.revokeStrings.revokeACSDeleteRightsFailed, "unexpectedException", e + message = this.revokeStrings.revokeACSDeleteRightsFailed, + errorCode = "unexpectedException", + exception = e, + extraMessages = listOf() ) throw TaskFailedHandled(e) } @@ -596,7 +689,10 @@ class BookRevokeTask( } catch (e: Exception) { this.error("failed to set up book database entry: ", e) this.taskRecorder.currentStepFailed( - this.revokeStrings.revokeBookDatabaseLookupFailed, "unexpectedException", e + message = this.revokeStrings.revokeBookDatabaseLookupFailed, + errorCode = "unexpectedException", + exception = e, + extraMessages = listOf() ) throw TaskFailedHandled(e) } diff --git a/simplified-books-controller/src/main/java/org/nypl/simplified/books/controller/BookSyncTask.kt b/simplified-books-controller/src/main/java/org/nypl/simplified/books/controller/BookSyncTask.kt index b4d8f5835..ffee910a9 100644 --- a/simplified-books-controller/src/main/java/org/nypl/simplified/books/controller/BookSyncTask.kt +++ b/simplified-books-controller/src/main/java/org/nypl/simplified/books/controller/BookSyncTask.kt @@ -120,7 +120,8 @@ class BookSyncTask( this.taskRecorder.currentStepFailed( message = message, errorCode = "syncFailed", - exception = exception + exception = exception, + extraMessages = listOf() ) throw TaskFailedHandled(exception) } diff --git a/simplified-books-controller/src/main/java/org/nypl/simplified/books/controller/PatronUserProfiles.kt b/simplified-books-controller/src/main/java/org/nypl/simplified/books/controller/PatronUserProfiles.kt index 7c1372b0f..3e73259d2 100644 --- a/simplified-books-controller/src/main/java/org/nypl/simplified/books/controller/PatronUserProfiles.kt +++ b/simplified-books-controller/src/main/java/org/nypl/simplified/books/controller/PatronUserProfiles.kt @@ -1,7 +1,5 @@ package org.nypl.simplified.books.controller -import com.io7m.jfunctional.OptionType -import com.io7m.jfunctional.Some import org.librarysimplified.http.api.LSHTTPClientType import org.librarysimplified.http.api.LSHTTPResponseStatus import org.nypl.simplified.accounts.api.AccountAuthenticatedHTTP @@ -40,7 +38,12 @@ internal object PatronUserProfiles { val patronSettingsURI = account.provider.patronSettingsURI if (patronSettingsURI == null) { val exception = Exception() - taskRecorder.currentStepFailed("No available patron user profile URI", "noPatronURI", exception) + taskRecorder.currentStepFailed( + message = "No available patron user profile URI", + errorCode = "noPatronURI", + exception = exception, + extraMessages = listOf() + ) throw exception } @@ -61,6 +64,7 @@ internal object PatronUserProfiles { stream = status.bodyStream ?: ByteArrayInputStream(ByteArray(0)) ) } + is LSHTTPResponseStatus.Responded.Error -> { this.onPatronProfileRequestHTTPError( taskRecorder = taskRecorder, @@ -68,6 +72,7 @@ internal object PatronUserProfiles { result = status ) } + is LSHTTPResponseStatus.Failed -> { this.onPatronProfileRequestHTTPException( taskRecorder = taskRecorder, @@ -97,13 +102,19 @@ internal object PatronUserProfiles { taskRecorder.currentStepSucceeded("Parsed patron user profile") parseResult.result } + is ParseResult.Failure -> { this.logger.error("failed to parse patron profile") val message: String = parseResult.errors.map(this::showParseError) .joinToString("\n") val exception = Exception() - taskRecorder.currentStepFailed(message, "parseErrorPatronSettings", exception) + taskRecorder.currentStepFailed( + message = message, + errorCode = "parseErrorPatronSettings", + exception = exception, + extraMessages = listOf() + ) throw exception } } @@ -157,7 +168,12 @@ internal object PatronUserProfiles { patronSettingsURI: URI, result: LSHTTPResponseStatus.Failed ): T { - taskRecorder.currentStepFailed("Connection failed when fetching patron user profile.", "connectionFailed", result.exception) + taskRecorder.currentStepFailed( + message = "Connection failed when fetching patron user profile.", + errorCode = "connectionFailed", + exception = result.exception, + extraMessages = listOf() + ) throw result.exception } @@ -166,27 +182,35 @@ internal object PatronUserProfiles { patronSettingsURI: URI, result: LSHTTPResponseStatus.Responded.Error ): T { - this.logger.error("received http error: {}: {}: {}", patronSettingsURI, result.properties.message, result.properties.status) + this.logger.error( + "received http error: {}: {}: {}", + patronSettingsURI, + result.properties.message, + result.properties.status + ) val exception = Exception() when (result.properties.status) { HttpURLConnection.HTTP_UNAUTHORIZED -> { - taskRecorder.currentStepFailed("Invalid credentials!", "invalidCredentials", exception) + taskRecorder.currentStepFailed( + message = "Invalid credentials!", + errorCode = "invalidCredentials", + exception = exception, + extraMessages = listOf() + ) throw exception } + else -> { taskRecorder.addAttributesIfPresent(result.properties.problemReport?.toMap()) - taskRecorder.currentStepFailed("Server error: ${result.properties.status} ${result.properties.message}", "httpError ${result.properties.status} $patronSettingsURI", exception) + taskRecorder.currentStepFailed( + message = "Server error: ${result.properties.status} ${result.properties.message}", + errorCode = "httpError ${result.properties.status} $patronSettingsURI", + exception = exception, + extraMessages = listOf() + ) throw exception } } } - - private fun someOrNull(option: OptionType): T? { - return if (option is Some) { - option.get() - } else { - null - } - } } diff --git a/simplified-books-controller/src/main/java/org/nypl/simplified/books/controller/ProfileAccountCreateCustomOPDSTask.kt b/simplified-books-controller/src/main/java/org/nypl/simplified/books/controller/ProfileAccountCreateCustomOPDSTask.kt index 846547d96..2ec79dc7a 100644 --- a/simplified-books-controller/src/main/java/org/nypl/simplified/books/controller/ProfileAccountCreateCustomOPDSTask.kt +++ b/simplified-books-controller/src/main/java/org/nypl/simplified/books/controller/ProfileAccountCreateCustomOPDSTask.kt @@ -70,12 +70,18 @@ class ProfileAccountCreateCustomOPDSTask( when (resolutionResult) { is TaskResult.Success -> this.createAccount(accountProviderDescription) + is TaskResult.Failure -> this.accountResolutionFailed(resolutionResult) } } catch (e: Throwable) { this.logger.error("account creation failed: ", e) - this.taskRecorder.currentStepFailedAppending(this.strings.unexpectedException, "unexpectedException", e) + this.taskRecorder.currentStepFailedAppending( + message = this.strings.unexpectedException, + errorCode = "unexpectedException", + exception = e, + extraMessages = listOf() + ) this.publishFailureEvent() this.taskRecorder.finishFailure() } @@ -88,7 +94,9 @@ class ProfileAccountCreateCustomOPDSTask( this.taskRecorder.addAll(resolutionResult.steps) this.taskRecorder.addAttributes(resolutionResult.attributes) this.taskRecorder.currentStepFailed( - this.strings.resolvingAccountProviderFailed, "resolvingAccountProviderFailed" + message = this.strings.resolvingAccountProviderFailed, + errorCode = "resolvingAccountProviderFailed", + extraMessages = listOf() ) return this.taskRecorder.finishFailure() } @@ -114,6 +122,7 @@ class ProfileAccountCreateCustomOPDSTask( this.publishProgressEvent(this.taskRecorder.currentStepSucceeded(this.strings.creatingAccountSucceeded)) this.taskRecorder.finishSuccess(createResult.result) } + is TaskResult.Failure -> { this.taskRecorder.addAll(createResult.steps) this.taskRecorder.finishFailure() @@ -188,7 +197,9 @@ class ProfileAccountCreateCustomOPDSTask( .build() return request.execute().use { response -> - this.taskRecorder.addAttributes(response.status.properties?.problemReport?.toMap() ?: emptyMap()) + this.taskRecorder.addAttributes( + response.status.properties?.problemReport?.toMap() ?: emptyMap() + ) when (val status = response.status) { is LSHTTPResponseStatus.Responded.OK -> { @@ -206,9 +217,9 @@ class ProfileAccountCreateCustomOPDSTask( this.findAuthenticationDocumentLink(feed) } catch (e: Exception) { this.taskRecorder.currentStepFailed( - e.message - ?: e.javaClass.name, - "parsingOPDSFeedFailed" + message = e.message ?: e.javaClass.name, + errorCode = "parsingOPDSFeedFailed", + extraMessages = listOf() ) this.publishFailureEvent() throw e @@ -235,7 +246,11 @@ class ProfileAccountCreateCustomOPDSTask( * Any other error is fatal. */ - this.taskRecorder.currentStepFailed(this.strings.fetchingOPDSFeedFailed, "fetchingOPDSFeedFailed") + this.taskRecorder.currentStepFailed( + message = this.strings.fetchingOPDSFeedFailed, + errorCode = "fetchingOPDSFeedFailed", + extraMessages = listOf() + ) this.publishFailureEvent() throw IOException() } @@ -248,9 +263,10 @@ class ProfileAccountCreateCustomOPDSTask( */ this.taskRecorder.currentStepFailed( - this.strings.fetchingOPDSFeedFailed, - "httpRequestFailed", - status.exception + message = this.strings.fetchingOPDSFeedFailed, + errorCode = "httpRequestFailed", + exception = status.exception, + extraMessages = listOf() ) this.publishFailureEvent() diff --git a/simplified-books-controller/src/main/java/org/nypl/simplified/books/controller/ProfileAccountCreateOrReturnExistingTask.kt b/simplified-books-controller/src/main/java/org/nypl/simplified/books/controller/ProfileAccountCreateOrReturnExistingTask.kt index 5832a1e0b..d4b6a5994 100644 --- a/simplified-books-controller/src/main/java/org/nypl/simplified/books/controller/ProfileAccountCreateOrReturnExistingTask.kt +++ b/simplified-books-controller/src/main/java/org/nypl/simplified/books/controller/ProfileAccountCreateOrReturnExistingTask.kt @@ -51,9 +51,10 @@ class ProfileAccountCreateOrReturnExistingTask( this.logger.error("account creation failed: ", e) this.taskRecorder.currentStepFailedAppending( - this.strings.unexpectedException, - "unexpectedException", - e + message = this.strings.unexpectedException, + errorCode = "unexpectedException", + exception = e, + extraMessages = listOf() ) this.publishFailureEvent() diff --git a/simplified-books-controller/src/main/java/org/nypl/simplified/books/controller/ProfileAccountCreateTask.kt b/simplified-books-controller/src/main/java/org/nypl/simplified/books/controller/ProfileAccountCreateTask.kt index af5e46b19..57887c8e3 100644 --- a/simplified-books-controller/src/main/java/org/nypl/simplified/books/controller/ProfileAccountCreateTask.kt +++ b/simplified-books-controller/src/main/java/org/nypl/simplified/books/controller/ProfileAccountCreateTask.kt @@ -52,9 +52,10 @@ class ProfileAccountCreateTask( this.logger.error("account creation failed: ", e) this.taskRecorder.currentStepFailedAppending( - this.strings.unexpectedException, - "unexpectedException", - e + message = this.strings.unexpectedException, + errorCode = "unexpectedException", + exception = e, + extraMessages = listOf() ) this.publishFailureEvent() @@ -71,7 +72,12 @@ class ProfileAccountCreateTask( val profile = this.profiles.currentProfileUnsafe() profile.createAccount(accountProvider) } catch (e: Exception) { - this.taskRecorder.currentStepFailed(e.message ?: e.javaClass.name, "creatingAccountFailed") + this.taskRecorder.currentStepFailed( + message = e.message ?: e.javaClass.name, + errorCode = "creatingAccountFailed", + exception = e, + extraMessages = listOf() + ) this.publishFailureEvent() throw e } @@ -115,7 +121,11 @@ class ProfileAccountCreateTask( is TaskResult.Failure -> { this.taskRecorder.addAll(resolution.steps) this.taskRecorder.addAttributes(resolution.attributes) - this.taskRecorder.currentStepFailed(resolution.message, "resolutionFailed") + this.taskRecorder.currentStepFailed( + message = resolution.message, + errorCode = "resolutionFailed", + extraMessages = listOf() + ) throw AccountUnresolvableProviderException(resolution.message) } } diff --git a/simplified-books-controller/src/main/java/org/nypl/simplified/books/controller/ProfileAccountDeleteTask.kt b/simplified-books-controller/src/main/java/org/nypl/simplified/books/controller/ProfileAccountDeleteTask.kt index 66b7b611c..8bf6d1f53 100644 --- a/simplified-books-controller/src/main/java/org/nypl/simplified/books/controller/ProfileAccountDeleteTask.kt +++ b/simplified-books-controller/src/main/java/org/nypl/simplified/books/controller/ProfileAccountDeleteTask.kt @@ -64,17 +64,19 @@ class ProfileAccountDeleteTask( this.taskRecorder.finishSuccess(Unit) } catch (e: AccountsDatabaseLastAccountException) { this.taskRecorder.currentStepFailed( - e.message ?: e.javaClass.name, - "oneAccountMustExist", - e + message = e.message ?: e.javaClass.name, + errorCode = "oneAccountMustExist", + exception = e, + extraMessages = listOf() ) this.publishFailureEvent() this.taskRecorder.finishFailure() } catch (e: Throwable) { this.taskRecorder.currentStepFailed( - e.message ?: e.javaClass.name, - "deletionFailed", - e + message = e.message ?: e.javaClass.name, + errorCode = "deletionFailed", + exception = e, + extraMessages = listOf() ) this.publishFailureEvent() this.taskRecorder.finishFailure() diff --git a/simplified-books-controller/src/main/java/org/nypl/simplified/books/controller/ProfileAccountLoginTask.kt b/simplified-books-controller/src/main/java/org/nypl/simplified/books/controller/ProfileAccountLoginTask.kt index 717e7a809..b366649ba 100644 --- a/simplified-books-controller/src/main/java/org/nypl/simplified/books/controller/ProfileAccountLoginTask.kt +++ b/simplified-books-controller/src/main/java/org/nypl/simplified/books/controller/ProfileAccountLoginTask.kt @@ -118,7 +118,11 @@ class ProfileAccountLoginTask( if (!this.validateRequest()) { this.debug("account does not support the given authentication") - this.steps.currentStepFailed(this.loginStrings.loginAuthNotRequired, "loginAuthNotRequired") + this.steps.currentStepFailed( + message = this.loginStrings.loginAuthNotRequired, + errorCode = "loginAuthNotRequired", + extraMessages = listOf() + ) this.account.setLoginState(AccountLoginFailed(this.steps.finishFailure())) return this.steps.finishFailure() } @@ -161,7 +165,10 @@ class ProfileAccountLoginTask( } catch (e: Throwable) { this.logger.error("error during login process: ", e) this.steps.currentStepFailedAppending( - this.loginStrings.loginUnexpectedException, "unexpectedException", e + message = this.loginStrings.loginUnexpectedException, + errorCode = "unexpectedException", + exception = e, + extraMessages = listOf() ) val failure = this.steps.finishFailure() this.account.setLoginState(AccountLoginFailed(failure)) @@ -183,16 +190,22 @@ class ProfileAccountLoginTask( val exception = Exception() when (result.properties.status) { HttpURLConnection.HTTP_UNAUTHORIZED -> { - this.steps.currentStepFailed("Invalid credentials!", "invalidCredentials", exception) + this.steps.currentStepFailed( + message = "Invalid credentials!", + errorCode = "invalidCredentials", + exception = exception, + extraMessages = listOf() + ) throw exception } else -> { this.steps.addAttributesIfPresent(result.properties.problemReport?.toMap()) this.steps.currentStepFailed( - "Server error: ${result.properties.status} ${result.properties.message}", - "httpError ${result.properties.status} $uri", - exception + message = "Server error: ${result.properties.status} ${result.properties.message}", + errorCode = "httpError ${result.properties.status} $uri", + exception = exception, + extraMessages = listOf() ) throw exception } @@ -420,9 +433,10 @@ class ProfileAccountLoginTask( is LSHTTPResponseStatus.Failed -> { this.steps.currentStepFailed( - "Connection failed when fetching authentication token.", - "connectionFailed", - status.exception + message = "Connection failed when fetching authentication token.", + errorCode = "connectionFailed", + exception = status.exception, + extraMessages = listOf() ) throw status.exception } @@ -615,15 +629,30 @@ class ProfileAccountLoginTask( val text = this.loginStrings.loginDeviceActivationFailed(ex) return when (ex) { is AdobeDRMExtensions.AdobeDRMLoginNoActivationsException -> { - this.steps.currentStepFailed(text, "Adobe ACS: drmNoAvailableActivations", ex) + this.steps.currentStepFailed( + message = text, + errorCode = "Adobe ACS: drmNoAvailableActivations", + exception = ex, + extraMessages = listOf() + ) } is AdobeDRMExtensions.AdobeDRMLoginConnectorException -> { - this.steps.currentStepFailed(text, "Adobe ACS: ${ex.errorCode}", ex) + this.steps.currentStepFailed( + message = text, + errorCode = "Adobe ACS: ${ex.errorCode}", + exception = ex, + extraMessages = listOf() + ) } else -> { - this.steps.currentStepFailed(text, "Adobe ACS: drmUnspecifiedError", ex) + this.steps.currentStepFailed( + message = text, + errorCode = "Adobe ACS: drmUnspecifiedError", + exception = ex, + extraMessages = listOf() + ) } } } diff --git a/simplified-books-controller/src/main/java/org/nypl/simplified/books/controller/ProfileAccountLogoutTask.kt b/simplified-books-controller/src/main/java/org/nypl/simplified/books/controller/ProfileAccountLogoutTask.kt index 14f462345..b49017f61 100644 --- a/simplified-books-controller/src/main/java/org/nypl/simplified/books/controller/ProfileAccountLogoutTask.kt +++ b/simplified-books-controller/src/main/java/org/nypl/simplified/books/controller/ProfileAccountLogoutTask.kt @@ -117,9 +117,10 @@ class ProfileAccountLogoutTask( return this.steps.finishSuccess(Unit) } catch (e: Throwable) { this.steps.currentStepFailedAppending( - this.logoutStrings.logoutUnexpectedException, - "unexpectedException", - e + message = this.logoutStrings.logoutUnexpectedException, + errorCode = "unexpectedException", + exception = e, + extraMessages = listOf() ) val failure = this.steps.finishFailure() @@ -192,7 +193,11 @@ class ProfileAccountLogoutTask( if (token == null) { this.warn("Patron user profile contained no Adobe DRM client token") val message = "Patron user profile is missing DRM information." - this.steps.currentStepFailed(message, "patronUserProfileNoDRM") + this.steps.currentStepFailed( + message = message, + errorCode = "patronUserProfileNoDRM", + extraMessages = listOf() + ) throw IOException(message) } @@ -262,14 +267,20 @@ class ProfileAccountLogoutTask( when (ex) { is AdobeDRMExtensions.AdobeDRMLogoutConnectorException -> { val message = this.logoutStrings.logoutDeactivatingDeviceAdobeFailed(ex.errorCode, ex) - this.steps.currentStepFailed(message, "Adobe ACS: ${ex.errorCode}", ex) + this.steps.currentStepFailed( + message, + errorCode = "Adobe ACS: ${ex.errorCode}", + exception = ex, + extraMessages = listOf() + ) } else -> { this.steps.currentStepFailed( - this.logoutStrings.logoutDeactivatingDeviceAdobeFailed("UNKNOWN", ex), - "unexpectedException", - ex + message = this.logoutStrings.logoutDeactivatingDeviceAdobeFailed("UNKNOWN", ex), + errorCode = "unexpectedException", + exception = ex, + extraMessages = listOf() ) } } @@ -298,7 +309,11 @@ class ProfileAccountLogoutTask( if (alternate == null) { this.error("no alternate link available for book $book. skipping...") val message = this.logoutStrings.logoutNoAlternateLinkInDatabase - this.steps.currentStepFailed(message, "noAlternateLink") + this.steps.currentStepFailed( + message = message, + errorCode = "noAlternateLink", + extraMessages = listOf() + ) } else { val newFeedEntry = this.fetchOPDSEntry(alternate) entry.writeOPDSEntry(newFeedEntry) @@ -308,7 +323,12 @@ class ProfileAccountLogoutTask( } catch (e: Exception) { this.error("step failed with unexpected exception", e) val message = this.logoutStrings.logoutUnexpectedException - this.steps.currentStepFailed(message, "unexpectedException", e) + this.steps.currentStepFailed( + message, + errorCode = "unexpectedException", + exception = e, + extraMessages = listOf() + ) } } } @@ -323,7 +343,12 @@ class ProfileAccountLogoutTask( ).get() } catch (e: TimeoutException) { val message = this.logoutStrings.logoutOPDSFeedTimedOut - this.steps.currentStepFailed(message, "timedOut", e) + this.steps.currentStepFailed( + message = message, + "timedOut", + exception = e, + extraMessages = listOf() + ) throw StepFailedHandled(e) } catch (e: ExecutionException) { throw e.cause!! @@ -342,7 +367,12 @@ class ProfileAccountLogoutTask( is FeedLoaderResult.FeedLoaderFailure.FeedLoaderFailedGeneral -> { val message = this.logoutStrings.logoutOPDSFeedFailed - this.steps.currentStepFailed(message, "feedLoaderFailed", feedResult.exception) + this.steps.currentStepFailed( + message = message, + errorCode = "feedLoaderFailed", + exception = feedResult.exception, + extraMessages = listOf() + ) throw StepFailedHandled(feedResult.exception) } } @@ -350,7 +380,12 @@ class ProfileAccountLogoutTask( if (feed.size == 0) { val message = this.logoutStrings.logoutOPDSFeedEmpty val exception = Exception(message) - this.steps.currentStepFailed(message, "feedEmpty") + this.steps.currentStepFailed( + message = message, + errorCode = "feedEmpty", + exception = exception, + extraMessages = listOf() + ) throw StepFailedHandled(exception) } @@ -359,7 +394,12 @@ class ProfileAccountLogoutTask( when (val feedEntry = feed.entriesInOrder[0]) { is FeedEntry.FeedEntryCorrupt -> { val message = this.logoutStrings.logoutOPDSFeedCorrupt - this.steps.currentStepFailed(message, "feedCorrupted", feedEntry.error) + this.steps.currentStepFailed( + message = message, + errorCode = "feedCorrupted", + exception = feedEntry.error, + extraMessages = listOf() + ) throw StepFailedHandled(feedEntry.error) } @@ -371,7 +411,12 @@ class ProfileAccountLogoutTask( is Feed.FeedWithGroups -> { val message = this.logoutStrings.logoutOPDSFeedWithGroups val exception = Exception(message) - this.steps.currentStepFailed(message, "feedUnusable") + this.steps.currentStepFailed( + message = message, + errorCode = "feedUnusable", + exception = exception, + extraMessages = listOf() + ) throw StepFailedHandled(exception) } } @@ -393,7 +438,9 @@ class ProfileAccountLogoutTask( } catch (e: Throwable) { this.error("could not clear book database: ", e) this.steps.currentStepFailed( - this.logoutStrings.logoutClearingBookDatabaseFailed, "unexpectedException" + message = this.logoutStrings.logoutClearingBookDatabaseFailed, + errorCode = "unexpectedException", + extraMessages = listOf() ) } } diff --git a/simplified-books-database-api/src/main/java/org/nypl/simplified/books/book_database/api/BookDRMInformationHandle.kt b/simplified-books-database-api/src/main/java/org/nypl/simplified/books/book_database/api/BookDRMInformationHandle.kt index 94fef2e8f..5fb7925da 100644 --- a/simplified-books-database-api/src/main/java/org/nypl/simplified/books/book_database/api/BookDRMInformationHandle.kt +++ b/simplified-books-database-api/src/main/java/org/nypl/simplified/books/book_database/api/BookDRMInformationHandle.kt @@ -62,16 +62,18 @@ sealed class BookDRMInformationHandle { abstract override val info: BookDRMInformation.LCP /** - * Set the LCP hashed passphrase. + * Set the LCP hashed passphrase and license. * * @param passphrase The passphrase + * @param licenseBytes The bytes of the license * * @throws IOException On I/O errors */ @Throws(IOException::class) - abstract fun setHashedPassphrase( - passphrase: String + abstract fun setInfo( + passphrase: String, + licenseBytes: ByteArray ): BookDRMInformation.LCP } diff --git a/simplified-books-database-api/src/main/java/org/nypl/simplified/books/book_database/api/BookDatabaseEntryType.kt b/simplified-books-database-api/src/main/java/org/nypl/simplified/books/book_database/api/BookDatabaseEntryType.kt index b2e16924f..0c70fef4c 100644 --- a/simplified-books-database-api/src/main/java/org/nypl/simplified/books/book_database/api/BookDatabaseEntryType.kt +++ b/simplified-books-database-api/src/main/java/org/nypl/simplified/books/book_database/api/BookDatabaseEntryType.kt @@ -1,5 +1,6 @@ package org.nypl.simplified.books.book_database.api +import android.app.Application import net.jcip.annotations.ThreadSafe import one.irradia.mime.api.MIMEType import org.nypl.simplified.books.api.Book @@ -167,7 +168,7 @@ sealed class BookDatabaseEntryFormatHandle { */ @Throws(IOException::class) - abstract fun deleteBookData() + abstract fun deleteBookData(context: Application) /** * Delete the bookmark with the given ID. @@ -257,7 +258,7 @@ sealed class BookDatabaseEntryFormatHandle { /** * Save the manifest and the URI that can be used to fetch more up-to-date copies of it - * later. + * later (assuming that such a URI is available). * * @throws IOException On I/O errors or lock acquisition failures */ @@ -265,29 +266,7 @@ sealed class BookDatabaseEntryFormatHandle { @Throws(IOException::class) abstract fun copyInManifestAndURI( data: ByteArray, - manifestURI: URI + manifestURI: URI? ) - - /** - * Copy the given audio book file into the directory as the book data. - * - * @param file The file to be copied - * - * @throws IOException On I/O errors - */ - - @Throws(IOException::class) - abstract fun copyInBook(file: File) - - /** - * Move the given audio book file into the directory as the book data. - * - * @param file The file to be copied - * - * @throws IOException On I/O errors - */ - - @Throws(IOException::class) - abstract fun moveInBook(file: File) } } diff --git a/simplified-books-database/src/main/java/org/nypl/simplified/books/book_database/BookDRMInformationHandleLCP.kt b/simplified-books-database/src/main/java/org/nypl/simplified/books/book_database/BookDRMInformationHandleLCP.kt index 890e4b3d1..e89691ae5 100644 --- a/simplified-books-database/src/main/java/org/nypl/simplified/books/book_database/BookDRMInformationHandleLCP.kt +++ b/simplified-books-database/src/main/java/org/nypl/simplified/books/book_database/BookDRMInformationHandleLCP.kt @@ -32,6 +32,10 @@ class BookDRMInformationHandleLCP( File(this.directory, "${format.shortName}-passphrase") private val filePassphraseTmp = File(this.directory, "${format.shortName}-passphrase.tmp") + private val fileLicense = + File(this.directory, "${format.shortName}-license") + private val fileLicenseTmp = + File(this.directory, "${format.shortName}-license.tmp") private val infoLock: Any = Any() private var infoRef: BookDRMInformation.LCP = @@ -45,6 +49,11 @@ class BookDRMInformationHandleLCP( this.filePassphrase.readText().trim() } else { null + }, + licenseBytes = if (this.fileLicense.isFile) { + this.fileLicense.readBytes() + } else { + null } ) } @@ -55,10 +64,25 @@ class BookDRMInformationHandleLCP( return synchronized(this.infoLock) { this.infoRef } } - override fun setHashedPassphrase(passphrase: String): BookDRMInformation.LCP { + override fun setInfo( + passphrase: String, + licenseBytes: ByteArray + ): BookDRMInformation.LCP { synchronized(this.infoLock) { - FileUtilities.fileWriteUTF8Atomically(this.filePassphrase, this.filePassphraseTmp, passphrase) - this.infoRef = this.infoRef.copy(hashedPassphrase = passphrase) + FileUtilities.fileWriteUTF8Atomically( + this.filePassphrase, + this.filePassphraseTmp, + passphrase + ) + FileUtilities.fileWriteBytesAtomically( + this.fileLicense, + this.fileLicenseTmp, + licenseBytes + ) + this.infoRef = this.infoRef.copy( + hashedPassphrase = passphrase, + licenseBytes = licenseBytes + ) } this.onUpdate.invoke() diff --git a/simplified-books-database/src/main/java/org/nypl/simplified/books/book_database/BookDatabaseEntry.kt b/simplified-books-database/src/main/java/org/nypl/simplified/books/book_database/BookDatabaseEntry.kt index 2dee944e5..1840d981c 100644 --- a/simplified-books-database/src/main/java/org/nypl/simplified/books/book_database/BookDatabaseEntry.kt +++ b/simplified-books-database/src/main/java/org/nypl/simplified/books/book_database/BookDatabaseEntry.kt @@ -175,7 +175,7 @@ internal class BookDatabaseEntry internal constructor( val failures = mutableListOf() for (handle in this.formatHandles) { try { - handle.deleteBookData() + handle.deleteBookData(this.context) } catch (e: Exception) { failures.add(e) } diff --git a/simplified-books-database/src/main/java/org/nypl/simplified/books/book_database/DatabaseFormatHandleAudioBook.kt b/simplified-books-database/src/main/java/org/nypl/simplified/books/book_database/DatabaseFormatHandleAudioBook.kt index f2c1f91ac..ac72557ea 100644 --- a/simplified-books-database/src/main/java/org/nypl/simplified/books/book_database/DatabaseFormatHandleAudioBook.kt +++ b/simplified-books-database/src/main/java/org/nypl/simplified/books/book_database/DatabaseFormatHandleAudioBook.kt @@ -1,13 +1,15 @@ package org.nypl.simplified.books.book_database +import android.app.Application import com.fasterxml.jackson.databind.ObjectMapper import com.google.common.base.Preconditions import net.jcip.annotations.GuardedBy import one.irradia.mime.api.MIMEType import org.librarysimplified.audiobook.api.PlayerAudioEngineRequest import org.librarysimplified.audiobook.api.PlayerAudioEngines -import org.librarysimplified.audiobook.api.PlayerResult +import org.librarysimplified.audiobook.api.PlayerBookSource import org.librarysimplified.audiobook.api.PlayerUserAgent +import org.librarysimplified.audiobook.api.extensions.PlayerExtensionType import org.librarysimplified.audiobook.manifest.api.PlayerManifest import org.librarysimplified.audiobook.manifest_parser.api.ManifestParsers import org.librarysimplified.audiobook.parser.api.ParseResult @@ -31,6 +33,7 @@ import java.io.File import java.io.FileInputStream import java.io.IOException import java.net.URI +import java.util.ServiceLoader /** * Operations on audio book formats in database entries. @@ -146,7 +149,7 @@ internal class DatabaseFormatHandleAudioBook internal constructor( } } - override fun deleteBookData() { + override fun deleteBookData(context: Application) { val briefID = this.parameters.bookID.brief() this.log.debug("[{}]: deleting audio book data", briefID) @@ -185,36 +188,19 @@ internal class DatabaseFormatHandleAudioBook internal constructor( is ParseResult.Success -> { this.log.debug("[{}]: selecting audio engine", briefID) - val engine = - PlayerAudioEngines.findBestFor( - PlayerAudioEngineRequest( - bookFile = this.fileBook, - manifest = manifestResult.result, - filter = { true }, - downloadProvider = NullDownloadProvider(), - userAgent = PlayerUserAgent("unused"), - bookCredentials = this.drmHandleRef.info.playerCredentials() - ) + PlayerAudioEngines.delete( + context = context, + extensions = ServiceLoader.load(PlayerExtensionType::class.java).toList(), + request = PlayerAudioEngineRequest( + bookSource = PlayerBookSource.PlayerBookSourceManifestOnly, + manifest = manifestResult.result, + filter = { true }, + downloadProvider = NullDownloadProvider(), + userAgent = PlayerUserAgent("unused"), + bookCredentials = this.drmHandleRef.info.playerCredentials() ) - - if (engine == null) { - throw UnsupportedOperationException( - "No audio engine is available to process the given request" - ) - } - - this.log.debug( - "[{}]: selected audio engine: {} {}", - briefID, - engine.engineProvider.name(), - engine.engineProvider.version() ) - when (val bookResult = engine.bookProvider.create(this.parameters.context)) { - is PlayerResult.Success -> bookResult.result.wholeBookDownloadTask.delete() - is PlayerResult.Failure -> throw bookResult.failure - } - this.log.debug("[{}]: deleted audio book data", briefID) } } @@ -263,15 +249,18 @@ internal class DatabaseFormatHandleAudioBook internal constructor( override fun copyInManifestAndURI( data: ByteArray, - manifestURI: URI + manifestURI: URI? ) { val newFormat = synchronized(this.dataLock) { - FileUtilities.fileWriteBytes( - data, this.fileManifest - ) - FileUtilities.fileWriteUTF8Atomically( - this.fileManifestURI, this.fileManifestURITmp, manifestURI.toString() - ) + FileUtilities.fileWriteBytes(data, this.fileManifest) + + if (manifestURI != null) { + FileUtilities.fileWriteUTF8Atomically( + this.fileManifestURI, this.fileManifestURITmp, manifestURI.toString() + ) + } else { + FileUtilities.fileDelete(this.fileManifestURI) + } this.formatRef = this.formatRef.copy( @@ -286,44 +275,6 @@ internal class DatabaseFormatHandleAudioBook internal constructor( this.parameters.onUpdated.invoke(newFormat) } - override fun copyInBook(file: File) { - val newFormat = synchronized(this.dataLock) { - if (file.isDirectory) { - DirectoryUtilities.directoryCopy(file, this.fileBook) - } else { - FileUtilities.fileCopy(file, this.fileBook) - } - - this.formatRef = this.formatRef.copy( - file = this.fileBook, - manifest = BookFormat.AudioBookManifestReference( - manifestURI = URI("manifest.json"), - manifestFile = this.fileManifest - ) - ) - this.formatRef - } - - this.parameters.onUpdated.invoke(newFormat) - } - - override fun moveInBook(file: File) { - val newFormat = synchronized(this.dataLock) { - FileUtilities.fileRename(file, this.fileBook) - - this.formatRef = this.formatRef.copy( - file = this.fileBook, - manifest = BookFormat.AudioBookManifestReference( - manifestURI = URI("manifest.json"), - manifestFile = this.fileManifest - ) - ) - this.formatRef - } - - this.parameters.onUpdated.invoke(newFormat) - } - override fun setLastReadLocation(bookmark: SerializedBookmark?) { if (bookmark != null) { Preconditions.checkArgument( diff --git a/simplified-books-database/src/main/java/org/nypl/simplified/books/book_database/DatabaseFormatHandleEPUB.kt b/simplified-books-database/src/main/java/org/nypl/simplified/books/book_database/DatabaseFormatHandleEPUB.kt index b0f9d4d11..500764ab1 100644 --- a/simplified-books-database/src/main/java/org/nypl/simplified/books/book_database/DatabaseFormatHandleEPUB.kt +++ b/simplified-books-database/src/main/java/org/nypl/simplified/books/book_database/DatabaseFormatHandleEPUB.kt @@ -1,5 +1,6 @@ package org.nypl.simplified.books.book_database +import android.app.Application import com.fasterxml.jackson.databind.ObjectMapper import com.google.common.base.Preconditions import net.jcip.annotations.GuardedBy @@ -130,7 +131,7 @@ internal class DatabaseFormatHandleEPUB internal constructor( } } - override fun deleteBookData() { + override fun deleteBookData(context: Application) { val newFormat = synchronized(this.dataLock) { if (this.fileBook.isDirectory) { DirectoryUtilities.directoryDelete(this.fileBook) diff --git a/simplified-books-database/src/main/java/org/nypl/simplified/books/book_database/DatabaseFormatHandlePDF.kt b/simplified-books-database/src/main/java/org/nypl/simplified/books/book_database/DatabaseFormatHandlePDF.kt index a61e6e225..8631e88a5 100644 --- a/simplified-books-database/src/main/java/org/nypl/simplified/books/book_database/DatabaseFormatHandlePDF.kt +++ b/simplified-books-database/src/main/java/org/nypl/simplified/books/book_database/DatabaseFormatHandlePDF.kt @@ -1,5 +1,6 @@ package org.nypl.simplified.books.book_database +import android.app.Application import com.fasterxml.jackson.databind.ObjectMapper import com.google.common.base.Preconditions import net.jcip.annotations.GuardedBy @@ -125,7 +126,7 @@ internal class DatabaseFormatHandlePDF internal constructor( } } - override fun deleteBookData() { + override fun deleteBookData(context: Application) { val newFormat = synchronized(this.dataLock) { FileUtilities.fileDelete(this.fileBook) this.formatRef = this.formatRef.copy(file = null) diff --git a/simplified-books-preview/src/main/java/org/nypl/simplified/books/preview/BookPreviewHttp.kt b/simplified-books-preview/src/main/java/org/nypl/simplified/books/preview/BookPreviewHttp.kt index 7e8c36193..ffb8cc60f 100644 --- a/simplified-books-preview/src/main/java/org/nypl/simplified/books/preview/BookPreviewHttp.kt +++ b/simplified-books-preview/src/main/java/org/nypl/simplified/books/preview/BookPreviewHttp.kt @@ -117,7 +117,8 @@ class BookPreviewHttp { taskRecorder.currentStepFailed( message = "HTTP request failed: ${status?.properties?.originalStatus} ${status?.properties?.message}", errorCode = "httpRequestFailed", - exception = null + exception = null, + extraMessages = listOf() ) } diff --git a/simplified-books-preview/src/main/java/org/nypl/simplified/books/preview/BookPreviewTask.kt b/simplified-books-preview/src/main/java/org/nypl/simplified/books/preview/BookPreviewTask.kt index a50220319..212f9125b 100644 --- a/simplified-books-preview/src/main/java/org/nypl/simplified/books/preview/BookPreviewTask.kt +++ b/simplified-books-preview/src/main/java/org/nypl/simplified/books/preview/BookPreviewTask.kt @@ -74,7 +74,8 @@ class BookPreviewTask( step.resolution = TaskStepResolution.TaskStepFailed( message = "Subtask $taskName raised an unexpected exception", exception = e, - errorCode = "subtaskFailed" + errorCode = "subtaskFailed", + extraMessages = listOf() ) this.taskRecorder.finishFailure() } @@ -126,10 +127,12 @@ class BookPreviewTask( previewAcquisition = previewAcquisition ) } + else -> { this.taskRecorder.currentStepFailed( - "Non supported book format.", - BookPreviewErrorCodes.nonSupportedBookFormat + message = "Non supported book format.", + errorCode = BookPreviewErrorCodes.nonSupportedBookFormat, + extraMessages = listOf() ) this.taskRecorder.finishFailure() } @@ -165,8 +168,9 @@ class BookPreviewTask( BookPreviewStatus.None ) this.taskRecorder.currentStepFailed( - "No preview acquisitions.", - BookPreviewErrorCodes.noPreviewAcquisitions + message = "No preview acquisitions.", + errorCode = BookPreviewErrorCodes.noPreviewAcquisitions, + extraMessages = listOf() ) this.taskRecorder.finishFailure() throw BookPreviewException(null) @@ -175,8 +179,9 @@ class BookPreviewTask( val previewAcquisition = BookPreviewAcquisitions.pickBestPreviewAcquisition(feedEntry) if (previewAcquisition == null) { this.taskRecorder.currentStepFailed( - "No supported preview acquisitions.", - BookPreviewErrorCodes.noSupportedPreviewAcquisitions + message = "No supported preview acquisitions.", + errorCode = BookPreviewErrorCodes.noSupportedPreviewAcquisitions, + extraMessages = listOf() ) throw BookPreviewException(null) } @@ -196,7 +201,12 @@ class BookPreviewTask( this.taskRecorder.finishFailure() } catch (e: Throwable) { this.error("unhandled exception during book preview handling: ", e) - this.taskRecorder.currentStepFailedAppending(this.messageOrName(e), "unexpected exception", e) + this.taskRecorder.currentStepFailedAppending( + message = this.messageOrName(e), + errorCode = "unexpected exception", + exception = e, + extraMessages = listOf() + ) this.taskRecorder.finishFailure() } } diff --git a/simplified-feeds-api/src/main/java/org/nypl/simplified/feeds/api/FeedLoading.kt b/simplified-feeds-api/src/main/java/org/nypl/simplified/feeds/api/FeedLoading.kt index 345242338..f99aec049 100644 --- a/simplified-feeds-api/src/main/java/org/nypl/simplified/feeds/api/FeedLoading.kt +++ b/simplified-feeds-api/src/main/java/org/nypl/simplified/feeds/api/FeedLoading.kt @@ -48,13 +48,25 @@ object FeedLoading { return when (feedResult) { is FeedLoaderFailedAuthentication -> { - taskRecorder.currentStepFailed(feedResult.message, "feedAuthentication", feedResult.exception) + taskRecorder.currentStepFailed( + message = feedResult.message, + errorCode = "feedAuthentication", + exception = feedResult.exception, + extraMessages = listOf() + ) throw feedResult.exception } + is FeedLoaderFailedGeneral -> { - taskRecorder.currentStepFailed(feedResult.message, "feedFailed", feedResult.exception) + taskRecorder.currentStepFailed( + message = feedResult.message, + errorCode = "feedFailed", + exception = feedResult.exception, + extraMessages = listOf() + ) throw feedResult.exception } + is FeedLoaderSuccess -> { taskRecorder.currentStepSucceeded("Feed retrieved and parsed.") taskRecorder.beginNewStep("Finding OPDS feed entry...") @@ -62,6 +74,7 @@ object FeedLoading { when (val feed = feedResult.feed) { is FeedWithGroups -> this.checkEntry(taskRecorder, findFirstInGroups(feed.feedGroupsInOrder)) + is FeedWithoutGroups -> this.checkEntry(taskRecorder, findFirst(feed.entriesInOrder)) } @@ -79,7 +92,12 @@ object FeedLoading { } else { val message = "Expected a feed containing at least one OPDS entry" val exception = IllegalArgumentException(message) - taskRecorder.currentStepFailed(message, "feedUnsuitable", exception) + taskRecorder.currentStepFailed( + message = message, + errorCode = "feedUnsuitable", + exception = exception, + extraMessages = listOf() + ) throw exception } @@ -90,6 +108,7 @@ object FeedLoading { when (entry) { is FeedEntry.FeedEntryCorrupt -> Unit + is FeedEntryOPDS -> return entry } @@ -105,6 +124,7 @@ object FeedLoading { when (entry) { is FeedEntry.FeedEntryCorrupt -> Unit + is FeedEntryOPDS -> return entry } diff --git a/simplified-main/src/main/java/org/librarysimplified/main/MainMigrations.kt b/simplified-main/src/main/java/org/librarysimplified/main/MainMigrations.kt deleted file mode 100644 index df5879098..000000000 --- a/simplified-main/src/main/java/org/librarysimplified/main/MainMigrations.kt +++ /dev/null @@ -1,150 +0,0 @@ -package org.librarysimplified.main - -import android.content.Context -import org.nypl.simplified.accounts.api.AccountAuthenticationCredentials -import org.nypl.simplified.accounts.api.AccountProviderAuthenticationDescription -import org.nypl.simplified.accounts.database.api.AccountType -import org.nypl.simplified.migration.api.Migrations -import org.nypl.simplified.migration.api.MigrationsType -import org.nypl.simplified.migration.spi.MigrationServiceDependencies -import org.nypl.simplified.profiles.api.ProfilesDatabaseType -import org.nypl.simplified.profiles.controller.api.ProfileAccountLoginRequest -import org.nypl.simplified.profiles.controller.api.ProfilesControllerType -import org.nypl.simplified.taskrecorder.api.TaskRecorder -import org.nypl.simplified.taskrecorder.api.TaskResult -import org.slf4j.LoggerFactory -import java.net.URI -import java.util.concurrent.TimeUnit - -internal object MainMigrations { - - private val logger = LoggerFactory.getLogger(MainMigrations::class.java) - - fun create( - context: Context, - profilesController: ProfilesControllerType - ): MigrationsType { - val isAnonymous = - profilesController.profileAnonymousEnabled() == ProfilesDatabaseType.AnonymousProfileEnabled.ANONYMOUS_PROFILE_ENABLED - - val dependencies = MigrationServiceDependencies( - createAccount = { uri -> - this.doCreateAccount(profilesController, uri) - }, - loginAccount = { account, credentials -> - this.doLoginAccount(profilesController, account, credentials) - }, - accountEvents = profilesController.accountEvents(), - applicationProfileIsAnonymous = isAnonymous, - applicationVersion = this.applicationVersion(context), - context = context - ) - - return Migrations.create(dependencies) - } - - private fun applicationVersion(context: Context): String { - return try { - val packageInfo = - context - .packageManager - .getPackageInfo(context.packageName, 0) - - "${packageInfo.packageName} ${packageInfo.versionName} (${packageInfo.versionCode})" - } catch (e: Exception) { - this.logger.error("could not get package info: ", e) - "unknown" - } - } - - private fun doLoginAccount( - profilesController: ProfilesControllerType, - account: AccountType, - credentials: AccountAuthenticationCredentials - ): TaskResult { - this.logger.debug("doLoginAccount") - - val taskRecorder = TaskRecorder.create() - taskRecorder.beginNewStep("Logging in...") - - if (account.provider.authenticationAlternatives.isEmpty()) { - when (val description = account.provider.authentication) { - is AccountProviderAuthenticationDescription.COPPAAgeGate, - AccountProviderAuthenticationDescription.Anonymous -> { - return taskRecorder.finishSuccess(Unit) - } - is AccountProviderAuthenticationDescription.Basic -> { - when (credentials) { - is AccountAuthenticationCredentials.Basic -> { - return profilesController.profileAccountLogin( - ProfileAccountLoginRequest.Basic( - account.id, - description, - credentials.userName, - credentials.password - ) - ).get(3L, TimeUnit.MINUTES) - } - is AccountAuthenticationCredentials.BasicToken -> { - val message = "Can't use Basic Token authentication during migrations." - taskRecorder.currentStepFailed(message, "missingInformation") - return taskRecorder.finishFailure() - } - is AccountAuthenticationCredentials.OAuthWithIntermediary -> { - val message = "Can't use OAuth authentication during migrations." - taskRecorder.currentStepFailed(message, "missingInformation") - return taskRecorder.finishFailure() - } - is AccountAuthenticationCredentials.SAML2_0 -> { - val message = "Can't use SAML 2.0 authentication during migrations." - taskRecorder.currentStepFailed(message, "missingInformation") - return taskRecorder.finishFailure() - } - } - } - is AccountProviderAuthenticationDescription.BasicToken -> { - when (credentials) { - is AccountAuthenticationCredentials.BasicToken -> { - return profilesController.profileAccountLogin( - ProfileAccountLoginRequest.BasicToken( - account.id, - description, - credentials.userName, - credentials.password - ) - ).get(3L, TimeUnit.MINUTES) - } - else -> { - val message = "Can't use $credentials authentication during migrations." - taskRecorder.currentStepFailed(message, "missingInformation") - return taskRecorder.finishFailure() - } - } - } - is AccountProviderAuthenticationDescription.OAuthWithIntermediary -> { - val message = "Can't use OAuth authentication during migrations." - taskRecorder.currentStepFailed(message, "missingInformation") - return taskRecorder.finishFailure() - } - is AccountProviderAuthenticationDescription.SAML2_0 -> { - val message = "Can't use SAML 2.0 authentication during migrations." - taskRecorder.currentStepFailed(message, "missingInformation") - return taskRecorder.finishFailure() - } - } - } else { - val message = "Can't determine which authentication method is required." - taskRecorder.currentStepFailed(message, "missingInformation") - return taskRecorder.finishFailure() - } - } - - private fun doCreateAccount( - profilesController: ProfilesControllerType, - provider: URI - ): TaskResult { - this.logger.debug("doCreateAccount") - return profilesController.profileAccountCreateOrReturnExisting(provider) - .get(3L, TimeUnit.MINUTES) - } -} diff --git a/simplified-main/src/main/java/org/librarysimplified/main/MainServices.kt b/simplified-main/src/main/java/org/librarysimplified/main/MainServices.kt index 39a214e8b..d9b023ea6 100644 --- a/simplified-main/src/main/java/org/librarysimplified/main/MainServices.kt +++ b/simplified-main/src/main/java/org/librarysimplified/main/MainServices.kt @@ -86,7 +86,6 @@ import org.nypl.simplified.feeds.api.FeedLoaderType import org.nypl.simplified.files.DirectoryUtilities import org.nypl.simplified.metrics.api.MetricServiceFactoryType import org.nypl.simplified.metrics.api.MetricServiceType -import org.nypl.simplified.migration.api.MigrationsType import org.nypl.simplified.networkconnectivity.NetworkConnectivity import org.nypl.simplified.networkconnectivity.api.NetworkConnectivityType import org.nypl.simplified.notifications.NotificationTokenHTTPCalls @@ -982,12 +981,6 @@ internal object MainServices { serviceConstructor = { NetworkConnectivity.create(context) } ) - addService( - message = strings.bootingGeneral("migrations"), - interfaceType = MigrationsType::class.java, - serviceConstructor = { MainMigrations.create(context, bookController) } - ) - this.showThreads() this.publishApplicationStartupEvent(context, analytics) diff --git a/simplified-taskrecorder-api/src/main/java/org/nypl/simplified/taskrecorder/api/TaskRecorder.kt b/simplified-taskrecorder-api/src/main/java/org/nypl/simplified/taskrecorder/api/TaskRecorder.kt index ecffa0ac8..633de92bc 100644 --- a/simplified-taskrecorder-api/src/main/java/org/nypl/simplified/taskrecorder/api/TaskRecorder.kt +++ b/simplified-taskrecorder-api/src/main/java/org/nypl/simplified/taskrecorder/api/TaskRecorder.kt @@ -59,7 +59,8 @@ class TaskRecorder private constructor() : TaskRecorderType { override fun currentStepFailed( message: String, errorCode: String, - exception: Throwable? + exception: Throwable?, + extraMessages: List ): TaskStep { Preconditions.checkState(this.steps.isNotEmpty(), "A step must be active") @@ -69,7 +70,8 @@ class TaskRecorder private constructor() : TaskRecorderType { TaskStepResolution.TaskStepFailed( message = message, errorCode = errorCode, - exception = exception + exception = exception, + extraMessages = extraMessages ) return step } @@ -77,7 +79,8 @@ class TaskRecorder private constructor() : TaskRecorderType { override fun currentStepFailedAppending( message: String, errorCode: String, - exception: Throwable + exception: Throwable, + extraMessages: List ): TaskStep { Preconditions.checkState(this.steps.isNotEmpty(), "A step must be active") @@ -88,7 +91,8 @@ class TaskRecorder private constructor() : TaskRecorderType { step.resolution = TaskStepResolution.TaskStepFailed( message = message, exception = exception, - errorCode = errorCode + errorCode = errorCode, + extraMessages = extraMessages ) step } diff --git a/simplified-taskrecorder-api/src/main/java/org/nypl/simplified/taskrecorder/api/TaskRecorderType.kt b/simplified-taskrecorder-api/src/main/java/org/nypl/simplified/taskrecorder/api/TaskRecorderType.kt index a0ca3299a..c32338f92 100644 --- a/simplified-taskrecorder-api/src/main/java/org/nypl/simplified/taskrecorder/api/TaskRecorderType.kt +++ b/simplified-taskrecorder-api/src/main/java/org/nypl/simplified/taskrecorder/api/TaskRecorderType.kt @@ -48,7 +48,8 @@ interface TaskRecorderType { fun currentStepFailed( message: String, errorCode: String, - exception: Throwable? = null + exception: Throwable? = null, + extraMessages: List ): TaskStep /** @@ -60,7 +61,8 @@ interface TaskRecorderType { fun currentStepFailedAppending( message: String, errorCode: String, - exception: Throwable + exception: Throwable, + extraMessages: List ): TaskStep /** diff --git a/simplified-taskrecorder-api/src/main/java/org/nypl/simplified/taskrecorder/api/TaskResult.kt b/simplified-taskrecorder-api/src/main/java/org/nypl/simplified/taskrecorder/api/TaskResult.kt index 7e51ff03c..b1c9174ca 100644 --- a/simplified-taskrecorder-api/src/main/java/org/nypl/simplified/taskrecorder/api/TaskResult.kt +++ b/simplified-taskrecorder-api/src/main/java/org/nypl/simplified/taskrecorder/api/TaskResult.kt @@ -137,7 +137,8 @@ sealed class TaskResult : PresentableType { resolution = TaskStepFailed( message = resolution, errorCode = errorCode, - exception = null + exception = null, + extraMessages = listOf() ) ) ) diff --git a/simplified-taskrecorder-api/src/main/java/org/nypl/simplified/taskrecorder/api/TaskStepResolution.kt b/simplified-taskrecorder-api/src/main/java/org/nypl/simplified/taskrecorder/api/TaskStepResolution.kt index 1c77e82d7..ad6ced181 100644 --- a/simplified-taskrecorder-api/src/main/java/org/nypl/simplified/taskrecorder/api/TaskStepResolution.kt +++ b/simplified-taskrecorder-api/src/main/java/org/nypl/simplified/taskrecorder/api/TaskStepResolution.kt @@ -37,6 +37,7 @@ sealed class TaskStepResolution : Serializable { data class TaskStepFailed( override val message: String, override val exception: Throwable?, - val errorCode: String + val errorCode: String, + val extraMessages: List ) : TaskStepResolution() } diff --git a/simplified-tests/build.gradle.kts b/simplified-tests/build.gradle.kts index 7fa5a32a5..05a153396 100644 --- a/simplified-tests/build.gradle.kts +++ b/simplified-tests/build.gradle.kts @@ -161,7 +161,6 @@ val dependencyObjects = listOf( libs.bouncycastle.bcprov, libs.bytebuddy, libs.bytebuddy.agent, - libs.commons.compress, libs.conscrypt, libs.firebase.analytics, libs.firebase.crashlytics, @@ -279,24 +278,8 @@ val dependencyObjects = listOf( libs.rxjava, libs.rxjava2, libs.rxjava2.extensions, - libs.service.wight.annotation, - libs.service.wight.core, libs.slf4j, libs.transifex.sdk, - libs.truecommons.cio, - libs.truecommons.io, - libs.truecommons.key.disable, - libs.truecommons.key.spec, - libs.truecommons.logging, - libs.truecommons.services, - libs.truecommons.shed, - libs.truevfs.access, - libs.truevfs.comp.zip, - libs.truevfs.comp.zipdriver, - libs.truevfs.driver.file, - libs.truevfs.driver.zip, - libs.truevfs.kernel.impl, - libs.truevfs.kernel.spec, libs.azam.ulidj, ) diff --git a/simplified-tests/src/test/java/org/nypl/simplified/tests/books/accounts/AccountProviderDescriptionRegistryContract.kt b/simplified-tests/src/test/java/org/nypl/simplified/tests/books/accounts/AccountProviderDescriptionRegistryContract.kt index 86c5a3f85..183252773 100644 --- a/simplified-tests/src/test/java/org/nypl/simplified/tests/books/accounts/AccountProviderDescriptionRegistryContract.kt +++ b/simplified-tests/src/test/java/org/nypl/simplified/tests/books/accounts/AccountProviderDescriptionRegistryContract.kt @@ -18,7 +18,6 @@ import org.nypl.simplified.accounts.registry.api.AccountProviderRegistryStatus.I import org.nypl.simplified.accounts.registry.api.AccountProviderRegistryStatus.Refreshing import org.nypl.simplified.accounts.source.spi.AccountProviderSourceType import org.nypl.simplified.accounts.source.spi.AccountProviderSourceType.SourceResult -import org.nypl.simplified.taskrecorder.api.TaskRecorder import org.nypl.simplified.taskrecorder.api.TaskResult import org.nypl.simplified.tests.mocking.MockAccountProviders import org.slf4j.Logger @@ -406,17 +405,6 @@ abstract class AccountProviderDescriptionRegistryContract { location = null ) - private fun fail(): TaskResult.Failure { - val taskRecorder = TaskRecorder.create() - val exception = Exception() - taskRecorder.currentStepFailed( - message = "x", - errorCode = "unexpectedException", - exception = exception - ) - return taskRecorder.finishFailure() - } - val description1 = AccountProviderDescription( id = URI.create("urn:1"), diff --git a/simplified-tests/src/test/java/org/nypl/simplified/tests/books/audio/AudioBookManifestStrategyTest.kt b/simplified-tests/src/test/java/org/nypl/simplified/tests/books/audio/AudioBookManifestStrategyTest.kt index b6de13de2..ca709716a 100644 --- a/simplified-tests/src/test/java/org/nypl/simplified/tests/books/audio/AudioBookManifestStrategyTest.kt +++ b/simplified-tests/src/test/java/org/nypl/simplified/tests/books/audio/AudioBookManifestStrategyTest.kt @@ -11,13 +11,14 @@ import org.librarysimplified.audiobook.manifest_fulfill.api.ManifestFulfillmentS import org.librarysimplified.audiobook.manifest_fulfill.basic.ManifestFulfillmentBasicParameters import org.librarysimplified.audiobook.manifest_fulfill.basic.ManifestFulfillmentBasicType import org.librarysimplified.audiobook.manifest_fulfill.spi.ManifestFulfilled -import org.librarysimplified.audiobook.manifest_fulfill.spi.ManifestFulfillmentErrorType +import org.librarysimplified.audiobook.manifest_fulfill.spi.ManifestFulfillmentError import org.librarysimplified.audiobook.manifest_fulfill.spi.ManifestFulfillmentStrategyType import org.librarysimplified.audiobook.manifest_parser.api.ManifestParsersType import org.librarysimplified.http.api.LSHTTPClientType import org.mockito.Mockito +import org.nypl.simplified.books.audio.AudioBookLink import org.nypl.simplified.books.audio.AudioBookManifestRequest -import org.nypl.simplified.books.audio.UnpackagedAudioBookManifestStrategy +import org.nypl.simplified.books.audio.AudioBookStrategy import org.nypl.simplified.books.book_database.api.BookFormats import org.nypl.simplified.taskrecorder.api.TaskResult import org.nypl.simplified.tests.MutableServiceDirectory @@ -34,7 +35,7 @@ class AudioBookManifestStrategyTest { private lateinit var context: Application private lateinit var basicStrategies: ManifestFulfillmentBasicType private lateinit var basicStrategy: ManifestFulfillmentStrategyType - private lateinit var fulfillError: ManifestFulfillmentErrorType + private lateinit var fulfillError: ManifestFulfillmentError private lateinit var httpClient: LSHTTPClientType private lateinit var manifestParsers: ManifestParsersType private lateinit var services: MutableServiceDirectory @@ -60,10 +61,11 @@ class AudioBookManifestStrategyTest { TestDirectories.temporaryDirectory() this.fulfillError = - object : ManifestFulfillmentErrorType { - override val message: String = "Download failed!" - override val serverData: ManifestFulfillmentErrorType.ServerData? = null - } + ManifestFulfillmentError( + message = "Download failed!", + extraMessages = listOf(), + serverData = null + ) this.services = MutableServiceDirectory() this.services.putService(LSHTTPClientType::class.java, this.httpClient) @@ -73,17 +75,18 @@ class AudioBookManifestStrategyTest { @Test fun testNoBasicStrategyAvailable() { val strategy = - UnpackagedAudioBookManifestStrategy( + AudioBookStrategy( context = this.context, request = AudioBookManifestRequest( - targetURI = URI.create("http://www.example.com"), + cacheDirectory = File(tempFolder, "cache"), contentType = BookFormats.audioBookGenericMimeTypes().first(), - userAgent = PlayerUserAgent("test"), credentials = null, - services = this.services, + httpClient = this.httpClient, isNetworkAvailable = { true }, + services = this.services, strategyRegistry = this.strategies, - cacheDirectory = File(tempFolder, "cache") + target = AudioBookLink.Manifest(URI.create("http://www.example.com")), + userAgent = PlayerUserAgent("test"), ) ) @@ -109,16 +112,17 @@ class AudioBookManifestStrategyTest { .thenReturn(Observable.never()) val fulfillmentResult = - PlayerResult.Failure(this.fulfillError) + PlayerResult.Failure(this.fulfillError) Mockito.`when`(this.basicStrategy.execute()) .thenReturn(fulfillmentResult) val strategy = - UnpackagedAudioBookManifestStrategy( + AudioBookStrategy( context = this.context, request = AudioBookManifestRequest( - targetURI = URI.create("http://www.example.com"), + target = AudioBookLink.Manifest(URI.create("http://www.example.com")), + httpClient = this.httpClient, contentType = BookFormats.audioBookGenericMimeTypes().first(), userAgent = PlayerUserAgent("test"), credentials = null, @@ -152,11 +156,12 @@ class AudioBookManifestStrategyTest { .thenReturn(Observable.never()) val fulfillmentResult = - PlayerResult.Success( + PlayerResult.Success( ManifestFulfilled( - BookFormats.audioBookGenericMimeTypes().first(), - null, - ByteArray(23) + source = URI.create("http://www.example.com"), + contentType = BookFormats.audioBookGenericMimeTypes().first(), + authorization = null, + data = ByteArray(23) ) ) @@ -164,10 +169,10 @@ class AudioBookManifestStrategyTest { .thenReturn(fulfillmentResult) val strategy = - UnpackagedAudioBookManifestStrategy( + AudioBookStrategy( context = this.context, request = AudioBookManifestRequest( - targetURI = URI.create("http://www.example.com"), + target = AudioBookLink.Manifest(URI.create("http://www.example.com")), contentType = BookFormats.audioBookGenericMimeTypes().first(), userAgent = PlayerUserAgent("test"), credentials = null, @@ -176,6 +181,7 @@ class AudioBookManifestStrategyTest { strategyRegistry = this.strategies, manifestParsers = AudioBookFailingParsers, extensions = emptyList(), + httpClient = this.httpClient, cacheDirectory = File(tempFolder, "cache") ) ) @@ -186,59 +192,6 @@ class AudioBookManifestStrategyTest { ) } - @Test - fun testNoBasicStrategyLicenseCheckFails() { - Mockito.`when`( - this.strategies.findStrategy( - this.any((ManifestFulfillmentBasicType::class.java)::class.java) - ) - ).thenReturn(this.basicStrategies) - - Mockito.`when`( - this.basicStrategies.create( - this.any(ManifestFulfillmentBasicParameters::class.java) - ) - ).thenReturn(this.basicStrategy) - - Mockito.`when`(this.basicStrategy.events) - .thenReturn(Observable.never()) - - val fulfillmentResult = - PlayerResult.Success( - ManifestFulfilled( - BookFormats.audioBookGenericMimeTypes().first(), - null, - ByteArray(23) - ) - ) - - Mockito.`when`(this.basicStrategy.execute()) - .thenReturn(fulfillmentResult) - - val strategy = - UnpackagedAudioBookManifestStrategy( - context = this.context, - request = AudioBookManifestRequest( - targetURI = URI.create("http://www.example.com"), - contentType = BookFormats.audioBookGenericMimeTypes().first(), - userAgent = PlayerUserAgent("test"), - credentials = null, - services = this.services, - isNetworkAvailable = { true }, - strategyRegistry = this.strategies, - manifestParsers = AudioBookSucceedingParsers, - extensions = emptyList(), - licenseChecks = listOf(AudioBookFailingLicenseChecks), - cacheDirectory = File(tempFolder, "cache") - ) - ) - - val failure = strategy.execute() as TaskResult.Failure - Assertions.assertTrue( - failure.resolutionOf(2).message.startsWith("One or more license checks failed") - ) - } - @Test fun testNoBasicStrategySucceeds() { Mockito.`when`( @@ -257,11 +210,12 @@ class AudioBookManifestStrategyTest { .thenReturn(Observable.never()) val fulfillmentResult = - PlayerResult.Success( + PlayerResult.Success( ManifestFulfilled( - BookFormats.audioBookGenericMimeTypes().first(), - null, - ByteArray(23) + source = URI.create("http://www.example.com"), + contentType = BookFormats.audioBookGenericMimeTypes().first(), + authorization = null, + data = ByteArray(23) ) ) @@ -269,10 +223,10 @@ class AudioBookManifestStrategyTest { .thenReturn(fulfillmentResult) val strategy = - UnpackagedAudioBookManifestStrategy( + AudioBookStrategy( context = this.context, request = AudioBookManifestRequest( - targetURI = URI.create("http://www.example.com"), + target = AudioBookLink.Manifest(URI.create("http://www.example.com")), contentType = BookFormats.audioBookGenericMimeTypes().first(), userAgent = PlayerUserAgent("test"), credentials = null, @@ -282,6 +236,7 @@ class AudioBookManifestStrategyTest { manifestParsers = AudioBookSucceedingParsers, extensions = emptyList(), licenseChecks = listOf(), + httpClient = this.httpClient, cacheDirectory = File(tempFolder, "cache") ) ) @@ -293,39 +248,41 @@ class AudioBookManifestStrategyTest { @Test fun testNoNetworkLoadFails() { val strategy = - UnpackagedAudioBookManifestStrategy( + AudioBookStrategy( context = this.context, request = AudioBookManifestRequest( - targetURI = URI.create("http://www.example.com"), + target = AudioBookLink.Manifest(URI.create("http://www.example.com")), contentType = BookFormats.audioBookGenericMimeTypes().first(), userAgent = PlayerUserAgent("test"), credentials = null, services = this.services, isNetworkAvailable = { false }, + httpClient = this.httpClient, cacheDirectory = File(tempFolder, "cache") ) ) val failure = strategy.execute() as TaskResult.Failure - Assertions.assertEquals("No fallback manifest data is provided", failure.resolutionOf(0).message) + Assertions.assertEquals("No network is available, and no fallback data is available", failure.resolutionOf(0).message) } @Test fun testNoNetworkLoadSucceeds() { val strategy = - UnpackagedAudioBookManifestStrategy( + AudioBookStrategy( context = this.context, request = AudioBookManifestRequest( - targetURI = URI.create("http://www.example.com"), + target = AudioBookLink.Manifest(URI.create("http://www.example.com")), contentType = BookFormats.audioBookGenericMimeTypes().first(), userAgent = PlayerUserAgent("test"), credentials = null, loadFallbackData = { ManifestFulfilled( - BookFormats.audioBookGenericMimeTypes().first(), - null, - ByteArray(23) + source = URI.create("http://www.example.com"), + contentType = BookFormats.audioBookGenericMimeTypes().first(), + authorization = null, + data = ByteArray(23) ) }, services = this.services, @@ -333,6 +290,7 @@ class AudioBookManifestStrategyTest { isNetworkAvailable = { false }, strategyRegistry = this.strategies, licenseChecks = listOf(), + httpClient = this.httpClient, cacheDirectory = File(tempFolder, "cache") ) ) diff --git a/simplified-tests/src/test/java/org/nypl/simplified/tests/books/audio/PackagedAudioBookManifestStrategyTest.kt b/simplified-tests/src/test/java/org/nypl/simplified/tests/books/audio/PackagedAudioBookManifestStrategyTest.kt deleted file mode 100644 index 7103b5f4b..000000000 --- a/simplified-tests/src/test/java/org/nypl/simplified/tests/books/audio/PackagedAudioBookManifestStrategyTest.kt +++ /dev/null @@ -1,129 +0,0 @@ -package org.nypl.simplified.tests.books.audio - -import android.app.Application -import org.junit.jupiter.api.Assertions -import org.junit.jupiter.api.BeforeEach -import org.junit.jupiter.api.Disabled -import org.junit.jupiter.api.Test -import org.junit.jupiter.api.io.TempDir -import org.librarysimplified.audiobook.api.PlayerUserAgent -import org.librarysimplified.audiobook.manifest_fulfill.api.ManifestFulfillmentStrategyRegistryType -import org.mockito.Mockito -import org.nypl.simplified.books.audio.AudioBookManifestRequest -import org.nypl.simplified.books.audio.PackagedAudioBookManifestStrategy -import org.nypl.simplified.books.formats.api.StandardFormatNames -import org.nypl.simplified.taskrecorder.api.TaskResult -import org.nypl.simplified.tests.MutableServiceDirectory -import java.io.File -import java.io.FileNotFoundException -import java.net.URI - -class PackagedAudioBookManifestStrategyTest { - private lateinit var context: Application - private lateinit var services: MutableServiceDirectory - private lateinit var strategies: ManifestFulfillmentStrategyRegistryType - - @TempDir - @JvmField - var tempDir: File? = null - - @BeforeEach - fun testSetup() { - this.context = - Mockito.mock(Application::class.java) - this.strategies = - Mockito.mock(ManifestFulfillmentStrategyRegistryType::class.java) - - this.services = MutableServiceDirectory() - this.services.putService(ManifestFulfillmentStrategyRegistryType::class.java, this.strategies) - } - - @Test - @Disabled("This test can no longer be run locally due to Readium's internal use of Android's Url class.") - fun succeeds_whenTargetURIExistsInPackage() { - val strategy = - PackagedAudioBookManifestStrategy( - context = this.context, - request = AudioBookManifestRequest( - file = getResource("bestnewhorror.zip"), - targetURI = URI.create("manifest.json"), - contentType = StandardFormatNames.lcpAudioBooks, - userAgent = PlayerUserAgent("test"), - credentials = null, - services = this.services, - isNetworkAvailable = { true }, - strategyRegistry = this.strategies, - cacheDirectory = File(tempDir, "cache") - ) - ) - - val success = strategy.execute() as TaskResult.Success - Assertions.assertEquals("Best New Horror", success.result.manifest.metadata.title) - } - - @Test - @Disabled("This test can no longer be run locally due to Readium's internal use of Android's Url class.") - fun fails_whenTargetURIDoesNotExistInPackage() { - val strategy = - PackagedAudioBookManifestStrategy( - context = this.context, - request = AudioBookManifestRequest( - file = getResource("bestnewhorror.zip"), - targetURI = URI.create("wrongfile.json"), - contentType = StandardFormatNames.lcpAudioBooks, - userAgent = PlayerUserAgent("test"), - credentials = null, - services = this.services, - isNetworkAvailable = { true }, - strategyRegistry = this.strategies, - cacheDirectory = File(tempDir, "cache") - ) - ) - - val failure = strategy.execute() as TaskResult.Failure - - Assertions.assertEquals( - "Unable to extract manifest from audio book file", - failure.resolutionOf(0).message - ) - } - - @Test - fun fails_whenFileIsNull() { - val strategy = - PackagedAudioBookManifestStrategy( - context = this.context, - request = AudioBookManifestRequest( - file = null, - targetURI = URI.create("manifest.json"), - contentType = StandardFormatNames.lcpAudioBooks, - userAgent = PlayerUserAgent("test"), - credentials = null, - services = this.services, - isNetworkAvailable = { true }, - strategyRegistry = this.strategies, - cacheDirectory = File(tempDir, "cache") - ) - ) - - val failure = strategy.execute() as TaskResult.Failure - - Assertions.assertEquals( - "No audio book file", - failure.resolutionOf(0).message - ) - } - - private fun getResource( - name: String - ): File { - val fileName = - "/org/nypl/simplified/tests/books/audio/$name" - - val url = - PackagedAudioBookManifestStrategyTest::class.java.getResource(fileName) - ?: throw FileNotFoundException("No such resource: $fileName") - - return File(url.toURI()) - } -} diff --git a/simplified-tests/src/test/java/org/nypl/simplified/tests/books/book_database/BookDRMInformationHandleLCPTest.kt b/simplified-tests/src/test/java/org/nypl/simplified/tests/books/book_database/BookDRMInformationHandleLCPTest.kt index 441c7c91d..8dbfbc83b 100644 --- a/simplified-tests/src/test/java/org/nypl/simplified/tests/books/book_database/BookDRMInformationHandleLCPTest.kt +++ b/simplified-tests/src/test/java/org/nypl/simplified/tests/books/book_database/BookDRMInformationHandleLCPTest.kt @@ -10,18 +10,21 @@ import org.nypl.simplified.books.book_database.api.BookFormats.BookFormatDefinit import org.nypl.simplified.files.DirectoryUtilities import org.nypl.simplified.tests.TestDirectories import java.io.File +import java.security.SecureRandom class BookDRMInformationHandleLCPTest { - private var updates: Int = 0 - private lateinit var directory1: File private lateinit var directory0: File + private lateinit var directory1: File + private lateinit var rng: SecureRandom + private var updates: Int = 0 @BeforeEach fun testSetup() { this.directory0 = TestDirectories.temporaryDirectory() this.directory1 = TestDirectories.temporaryDirectory() this.updates = 0 + this.rng = SecureRandom.getInstanceStrong() } @AfterEach @@ -83,8 +86,12 @@ class BookDRMInformationHandleLCPTest { onUpdate = this::countUpdateCalls ) - handle0.setHashedPassphrase("VGhlIFNpeHRlZW4gTWVuIE9mIFRhaW4K") + val licenseBytes = ByteArray(100) + this.rng.nextBytes(licenseBytes) + + handle0.setInfo("VGhlIFNpeHRlZW4gTWVuIE9mIFRhaW4K", licenseBytes) assertEquals("VGhlIFNpeHRlZW4gTWVuIE9mIFRhaW4K", handle0.info.hashedPassphrase) + assertEquals(licenseBytes, handle0.info.licenseBytes) assertEquals(1, this.updates) val handle1 = diff --git a/simplified-tests/src/test/java/org/nypl/simplified/tests/books/book_database/BookDatabaseContract.kt b/simplified-tests/src/test/java/org/nypl/simplified/tests/books/book_database/BookDatabaseContract.kt index ac02b53b9..e8722ab41 100644 --- a/simplified-tests/src/test/java/org/nypl/simplified/tests/books/book_database/BookDatabaseContract.kt +++ b/simplified-tests/src/test/java/org/nypl/simplified/tests/books/book_database/BookDatabaseContract.kt @@ -456,7 +456,7 @@ abstract class BookDatabaseContract { val file = copyToTempFile("/org/nypl/simplified/tests/books/basic-manifest.json") format.copyInManifestAndURI(file.readBytes(), URI.create("urn:invalid")) - format.deleteBookData() + format.deleteBookData(this.context()) } val format = databaseEntry.findFormatHandle(BookDatabaseEntryFormatHandleAudioBook::class.java) @@ -516,7 +516,7 @@ abstract class BookDatabaseContract { val file = copyToTempFile("/org/nypl/simplified/tests/books/empty.epub") format.copyInBook(file) - format.deleteBookData() + format.deleteBookData(this.context()) } val format = databaseEntry.findFormatHandle(BookDatabaseEntryFormatHandleEPUB::class.java) @@ -576,7 +576,7 @@ abstract class BookDatabaseContract { val file = copyToTempFile("/org/nypl/simplified/tests/books/empty.pdf") format.copyInBook(file) - format.deleteBookData() + format.deleteBookData(this.context()) } val format = databaseEntry.findFormatHandle(BookDatabaseEntryFormatHandlePDF::class.java) diff --git a/simplified-tests/src/test/java/org/nypl/simplified/tests/books/borrowing/BorrowAudioBookTest.kt b/simplified-tests/src/test/java/org/nypl/simplified/tests/books/borrowing/BorrowAudioBookTest.kt index 67fc0b50c..45ae2057c 100644 --- a/simplified-tests/src/test/java/org/nypl/simplified/tests/books/borrowing/BorrowAudioBookTest.kt +++ b/simplified-tests/src/test/java/org/nypl/simplified/tests/books/borrowing/BorrowAudioBookTest.kt @@ -218,7 +218,12 @@ class BorrowAudioBookTest { return AudioBookManifestData( manifest = manifest, - fulfilled = ManifestFulfilled(genericAudioBooks.first(), null, data) + licenseBytes = null, + fulfilled = ManifestFulfilled( + source = URI.create("urn:basic-manifest.json"), + contentType = genericAudioBooks.first(), + data = data + ) ) } diff --git a/simplified-tests/src/test/java/org/nypl/simplified/tests/books/borrowing/BorrowLCPTest.kt b/simplified-tests/src/test/java/org/nypl/simplified/tests/books/borrowing/BorrowLCPEpubTest.kt similarity index 98% rename from simplified-tests/src/test/java/org/nypl/simplified/tests/books/borrowing/BorrowLCPTest.kt rename to simplified-tests/src/test/java/org/nypl/simplified/tests/books/borrowing/BorrowLCPEpubTest.kt index 92b2df0b2..4e49178b3 100644 --- a/simplified-tests/src/test/java/org/nypl/simplified/tests/books/borrowing/BorrowLCPTest.kt +++ b/simplified-tests/src/test/java/org/nypl/simplified/tests/books/borrowing/BorrowLCPEpubTest.kt @@ -34,7 +34,7 @@ import org.nypl.simplified.books.book_database.api.BookDatabaseType import org.nypl.simplified.books.book_registry.BookRegistry import org.nypl.simplified.books.book_registry.BookRegistryType import org.nypl.simplified.books.borrowing.BorrowContextType -import org.nypl.simplified.books.borrowing.internal.BorrowLCP +import org.nypl.simplified.books.borrowing.internal.BorrowLCPEpub import org.nypl.simplified.books.borrowing.subtasks.BorrowSubtaskException import org.nypl.simplified.books.formats.api.StandardFormatNames import org.nypl.simplified.books.formats.api.StandardFormatNames.genericEPUBFiles @@ -71,7 +71,7 @@ import java.nio.file.Files import java.util.concurrent.TimeUnit import java.util.zip.ZipFile -class BorrowLCPTest { +class BorrowLCPEpubTest { private lateinit var androidContentResolver: ContentResolver private lateinit var downloadsDirectory: File @@ -88,7 +88,7 @@ class BorrowLCPTest { private lateinit var taskRecorder: TaskRecorderType private lateinit var webServer: MockWebServer - private val logger = LoggerFactory.getLogger(BorrowLCPTest::class.java) + private val logger = LoggerFactory.getLogger(BorrowLCPEpubTest::class.java) @TempDir @JvmField @@ -393,7 +393,7 @@ class BorrowLCPTest { .setHeader("Content-Type", "application/epub+zip") .setBody( Buffer().readFrom( - BorrowLCPTest::class.java.getResourceAsStream( + BorrowLCPEpubTest::class.java.getResourceAsStream( "/org/nypl/simplified/tests/books/minimal.epub" ) ) @@ -410,7 +410,7 @@ class BorrowLCPTest { // Execute the task. It is expected to halt early. - val task = BorrowLCP.createSubtask() + val task = BorrowLCPEpub.createSubtask() Assertions.assertThrows(BorrowSubtaskException.BorrowSubtaskHaltedEarly::class.java) { task.execute(context) @@ -595,7 +595,7 @@ class BorrowLCPTest { .setHeader("Content-Type", "application/audiobook+lcp") .setBody( Buffer().readFrom( - BorrowLCPTest::class.java.getResourceAsStream( + BorrowLCPEpubTest::class.java.getResourceAsStream( "/org/nypl/simplified/tests/books/minimal.lcpa" ) ) @@ -612,7 +612,7 @@ class BorrowLCPTest { // Execute the task. It is expected to halt early. - val task = BorrowLCP.createSubtask() + val task = BorrowLCPEpub.createSubtask() Assertions.assertThrows(BorrowSubtaskException.BorrowSubtaskHaltedEarly::class.java) { task.execute(context) @@ -709,7 +709,7 @@ class BorrowLCPTest { // Execute the task. It is expected to halt early. - val task = BorrowLCP.createSubtask() + val task = BorrowLCPEpub.createSubtask() Assertions.assertThrows(BorrowSubtaskException.BorrowSubtaskFailed::class.java) { task.execute(context) @@ -783,7 +783,7 @@ class BorrowLCPTest { acquisitionPath, ) - val task = BorrowLCP.createSubtask() + val task = BorrowLCPEpub.createSubtask() Assertions.assertThrows(BorrowSubtaskException.BorrowSubtaskFailed::class.java) { task.execute(context) @@ -868,7 +868,7 @@ class BorrowLCPTest { acquisitionPath, ) - val task = BorrowLCP.createSubtask() + val task = BorrowLCPEpub.createSubtask() Assertions.assertThrows(BorrowSubtaskException.BorrowSubtaskFailed::class.java) { task.execute(context) @@ -900,7 +900,7 @@ class BorrowLCPTest { logger.debug("copyToTempFile: {} -> {}", name, file) FileOutputStream(file).use { output -> - BorrowLCPTest::class.java.getResourceAsStream(name)!!.use { input -> + BorrowLCPEpubTest::class.java.getResourceAsStream(name)!!.use { input -> val buffer = ByteArray(4096) while (true) { diff --git a/simplified-tests/src/test/java/org/nypl/simplified/tests/books/borrowing/BorrowTaskTest.kt b/simplified-tests/src/test/java/org/nypl/simplified/tests/books/borrowing/BorrowTaskTest.kt index 7180ae461..b68894135 100644 --- a/simplified-tests/src/test/java/org/nypl/simplified/tests/books/borrowing/BorrowTaskTest.kt +++ b/simplified-tests/src/test/java/org/nypl/simplified/tests/books/borrowing/BorrowTaskTest.kt @@ -740,7 +740,9 @@ class BorrowTaskTest { taskRecorder.finishSuccess( AudioBookManifestData( manifest = playerManifest, + licenseBytes = null, fulfilled = ManifestFulfilled( + source = URI.create("urn:example.json"), contentType = genericAudioBooks.first(), data = playerManifest.originalBytes ) diff --git a/simplified-tests/src/test/java/org/nypl/simplified/tests/errorpage/ErrorPageParametersTest.kt b/simplified-tests/src/test/java/org/nypl/simplified/tests/errorpage/ErrorPageParametersTest.kt index 8a93ea1bb..7243053a6 100644 --- a/simplified-tests/src/test/java/org/nypl/simplified/tests/errorpage/ErrorPageParametersTest.kt +++ b/simplified-tests/src/test/java/org/nypl/simplified/tests/errorpage/ErrorPageParametersTest.kt @@ -33,7 +33,8 @@ class ErrorPageParametersTest { resolution = TaskStepResolution.TaskStepFailed( message = "Oh no, it failed.", errorCode = "FAIL", - exception = Exception() + exception = Exception(), + extraMessages = listOf() ) ) ) @@ -68,7 +69,8 @@ class ErrorPageParametersTest { resolution = TaskStepResolution.TaskStepFailed( message = "Nooooooo!", errorCode = "FAIL", - exception = Exception() + exception = Exception(), + extraMessages = listOf() ) ) ) @@ -188,7 +190,8 @@ class ErrorPageParametersTest { resolution = TaskStepResolution.TaskStepFailed( message = "Nooooooo!", errorCode = "FAIL", - exception = Exception() + exception = Exception(), + extraMessages = listOf() ) ) ) @@ -222,7 +225,8 @@ class ErrorPageParametersTest { resolution = TaskStepResolution.TaskStepFailed( message = "Nooooooo!", errorCode = "FAIL", - exception = Exception() + exception = Exception(), + extraMessages = listOf() ) ) ) diff --git a/simplified-tests/src/test/java/org/nypl/simplified/tests/http/refresh_token/borrow/BorrowBookRefreshTokenTest.kt b/simplified-tests/src/test/java/org/nypl/simplified/tests/http/refresh_token/borrow/BorrowBookRefreshTokenTest.kt index 749487d7f..4bfc4f6ba 100644 --- a/simplified-tests/src/test/java/org/nypl/simplified/tests/http/refresh_token/borrow/BorrowBookRefreshTokenTest.kt +++ b/simplified-tests/src/test/java/org/nypl/simplified/tests/http/refresh_token/borrow/BorrowBookRefreshTokenTest.kt @@ -44,7 +44,7 @@ import org.nypl.simplified.books.book_registry.BookStatusEvent import org.nypl.simplified.books.borrowing.internal.BorrowACSM import org.nypl.simplified.books.borrowing.internal.BorrowAxisNow import org.nypl.simplified.books.borrowing.internal.BorrowDirectDownload -import org.nypl.simplified.books.borrowing.internal.BorrowLCP +import org.nypl.simplified.books.borrowing.internal.BorrowLCPEpub import org.nypl.simplified.books.borrowing.internal.BorrowLoanCreate import org.nypl.simplified.books.borrowing.subtasks.BorrowSubtaskException import org.nypl.simplified.books.borrowing.subtasks.BorrowSubtaskType @@ -553,7 +553,7 @@ class BorrowBookRefreshTokenTest { this.webServer.enqueue(downloadResponse) try { - BorrowLCP.createSubtask().execute(this.context) + BorrowLCPEpub.createSubtask().execute(this.context) Assertions.fail() } catch (e: BorrowSubtaskException.BorrowSubtaskHaltedEarly) { this.logger.debug("correctly halted early: ", e) diff --git a/simplified-tests/src/test/java/org/nypl/simplified/tests/mocking/MockAccountProviderRegistry.kt b/simplified-tests/src/test/java/org/nypl/simplified/tests/mocking/MockAccountProviderRegistry.kt index f240fb097..53abb6d0d 100644 --- a/simplified-tests/src/test/java/org/nypl/simplified/tests/mocking/MockAccountProviderRegistry.kt +++ b/simplified-tests/src/test/java/org/nypl/simplified/tests/mocking/MockAccountProviderRegistry.kt @@ -109,7 +109,11 @@ class MockAccountProviderRegistry( val provider = this.resolvedProviders[description.id] return if (provider == null) { this.logger.debug("no provider in map") - taskRecorder.currentStepFailed("Failed", "unexpectedException") + taskRecorder.currentStepFailed( + message = "Failed", + errorCode = "unexpectedException", + extraMessages = listOf() + ) taskRecorder.finishFailure() } else { this.logger.debug("took provider from map") diff --git a/simplified-tests/src/test/java/org/nypl/simplified/tests/mocking/MockAudioBookManifestStrategies.kt b/simplified-tests/src/test/java/org/nypl/simplified/tests/mocking/MockAudioBookManifestStrategies.kt index 15d2369fa..177df8c77 100644 --- a/simplified-tests/src/test/java/org/nypl/simplified/tests/mocking/MockAudioBookManifestStrategies.kt +++ b/simplified-tests/src/test/java/org/nypl/simplified/tests/mocking/MockAudioBookManifestStrategies.kt @@ -3,16 +3,16 @@ package org.nypl.simplified.tests.mocking import android.app.Application import org.nypl.simplified.books.audio.AudioBookManifestRequest import org.nypl.simplified.books.audio.AudioBookManifestStrategiesType -import org.nypl.simplified.books.audio.AudioBookManifestStrategyType +import org.nypl.simplified.books.audio.AudioBookStrategyType class MockAudioBookManifestStrategies : AudioBookManifestStrategiesType { - var strategy = MockAudioBookManifestStrategy() + var strategy = MockAudioBookStrategy() override fun createStrategy( context: Application, request: AudioBookManifestRequest - ): AudioBookManifestStrategyType { + ): AudioBookStrategyType { return this.strategy } } diff --git a/simplified-tests/src/test/java/org/nypl/simplified/tests/mocking/MockAudioBookManifestStrategy.kt b/simplified-tests/src/test/java/org/nypl/simplified/tests/mocking/MockAudioBookStrategy.kt similarity index 65% rename from simplified-tests/src/test/java/org/nypl/simplified/tests/mocking/MockAudioBookManifestStrategy.kt rename to simplified-tests/src/test/java/org/nypl/simplified/tests/mocking/MockAudioBookStrategy.kt index e311f2bae..3ab13fbb1 100644 --- a/simplified-tests/src/test/java/org/nypl/simplified/tests/mocking/MockAudioBookManifestStrategy.kt +++ b/simplified-tests/src/test/java/org/nypl/simplified/tests/mocking/MockAudioBookStrategy.kt @@ -2,11 +2,12 @@ package org.nypl.simplified.tests.mocking import io.reactivex.Observable import io.reactivex.subjects.PublishSubject +import org.librarysimplified.audiobook.manifest_fulfill.spi.ManifestFulfillmentStrategyType import org.nypl.simplified.books.audio.AudioBookManifestData -import org.nypl.simplified.books.audio.AudioBookManifestStrategyType +import org.nypl.simplified.books.audio.AudioBookStrategyType import org.nypl.simplified.taskrecorder.api.TaskResult -class MockAudioBookManifestStrategy : AudioBookManifestStrategyType { +class MockAudioBookStrategy : AudioBookStrategyType { var onExecute: () -> TaskResult = { TaskResult.fail("Failed", "Failed", "failed") @@ -21,4 +22,8 @@ class MockAudioBookManifestStrategy : AudioBookManifestStrategyType { override fun execute(): TaskResult { return this.onExecute.invoke() } + + override fun toManifestStrategy(): ManifestFulfillmentStrategyType { + TODO("Not yet implemented") + } } diff --git a/simplified-tests/src/test/java/org/nypl/simplified/tests/mocking/MockBookDatabaseEntryFormatHandleAudioBook.kt b/simplified-tests/src/test/java/org/nypl/simplified/tests/mocking/MockBookDatabaseEntryFormatHandleAudioBook.kt index 03cf01f3f..077e714c2 100644 --- a/simplified-tests/src/test/java/org/nypl/simplified/tests/mocking/MockBookDatabaseEntryFormatHandleAudioBook.kt +++ b/simplified-tests/src/test/java/org/nypl/simplified/tests/mocking/MockBookDatabaseEntryFormatHandleAudioBook.kt @@ -1,5 +1,6 @@ package org.nypl.simplified.tests.mocking +import android.app.Application import org.nypl.simplified.books.api.BookDRMInformation import org.nypl.simplified.books.api.BookDRMKind import org.nypl.simplified.books.api.BookFormat @@ -41,7 +42,7 @@ class MockBookDatabaseEntryFormatHandleAudioBook( override val format: BookFormat.BookFormatAudioBook get() = this.formatField - override fun copyInManifestAndURI(data: ByteArray, manifestURI: URI) { + override fun copyInManifestAndURI(data: ByteArray, manifestURI: URI?) { this.formatField = this.formatField.copy( manifest = BookFormat.AudioBookManifestReference( manifestURI, @@ -50,26 +51,6 @@ class MockBookDatabaseEntryFormatHandleAudioBook( ) } - override fun copyInBook(file: File) { - this.bookData = file.readText() - this.bookFile = File(this.directory, "book.epub") - - Files.copy(file.toPath(), this.bookFile!!.toPath(), StandardCopyOption.REPLACE_EXISTING) - - this.formatField = this.formatField.copy(file = this.bookFile) - check(this.formatField.isDownloaded) - } - - override fun moveInBook(file: File) { - this.bookData = file.readText() - this.bookFile = File(this.directory, "book.zip") - - file.renameTo(this.bookFile) - - this.formatField = this.formatField.copy(file = this.bookFile) - check(this.formatField.isDownloaded) - } - override fun setLastReadLocation(bookmark: SerializedBookmark?) { this.formatField = this.formatField.copy( lastReadLocation = bookmark @@ -96,7 +77,9 @@ class MockBookDatabaseEntryFormatHandleAudioBook( override fun setDRMKind(kind: BookDRMKind) { } - override fun deleteBookData() { + override fun deleteBookData( + context: Application + ) { this.bookData = null this.bookFile = null this.formatField = this.formatField.copy(manifest = null) diff --git a/simplified-tests/src/test/java/org/nypl/simplified/tests/mocking/MockBookDatabaseEntryFormatHandleEPUB.kt b/simplified-tests/src/test/java/org/nypl/simplified/tests/mocking/MockBookDatabaseEntryFormatHandleEPUB.kt index 19240a969..1d4e182e0 100644 --- a/simplified-tests/src/test/java/org/nypl/simplified/tests/mocking/MockBookDatabaseEntryFormatHandleEPUB.kt +++ b/simplified-tests/src/test/java/org/nypl/simplified/tests/mocking/MockBookDatabaseEntryFormatHandleEPUB.kt @@ -1,5 +1,6 @@ package org.nypl.simplified.tests.mocking +import android.app.Application import org.nypl.simplified.books.api.BookDRMInformation import org.nypl.simplified.books.api.BookDRMKind import org.nypl.simplified.books.api.BookFormat @@ -73,7 +74,7 @@ class MockBookDatabaseEntryFormatHandleEPUB( override fun setDRMKind(kind: BookDRMKind) { } - override fun deleteBookData() { + override fun deleteBookData(context: Application) { this.bookData = null this.bookFile = null this.formatField = this.formatField.copy(file = this.bookFile) diff --git a/simplified-tests/src/test/java/org/nypl/simplified/tests/mocking/MockBookDatabaseEntryFormatHandlePDF.kt b/simplified-tests/src/test/java/org/nypl/simplified/tests/mocking/MockBookDatabaseEntryFormatHandlePDF.kt index 3a96a800f..bb6aaab91 100644 --- a/simplified-tests/src/test/java/org/nypl/simplified/tests/mocking/MockBookDatabaseEntryFormatHandlePDF.kt +++ b/simplified-tests/src/test/java/org/nypl/simplified/tests/mocking/MockBookDatabaseEntryFormatHandlePDF.kt @@ -1,5 +1,6 @@ package org.nypl.simplified.tests.mocking +import android.app.Application import com.io7m.junreachable.UnimplementedCodeException import org.nypl.simplified.books.api.BookDRMInformation import org.nypl.simplified.books.api.BookDRMKind @@ -69,7 +70,7 @@ class MockBookDatabaseEntryFormatHandlePDF( throw UnimplementedCodeException() } - override fun deleteBookData() { + override fun deleteBookData(context: Application) { this.bookData = null this.bookFile = null this.formatField = this.formatField.copy(file = this.bookFile) diff --git a/simplified-tests/src/test/java/org/nypl/simplified/tests/mocking/MockedAudioEngineProvider.kt b/simplified-tests/src/test/java/org/nypl/simplified/tests/mocking/MockedAudioEngineProvider.kt index cbdff8b2c..69bdc2d8c 100644 --- a/simplified-tests/src/test/java/org/nypl/simplified/tests/mocking/MockedAudioEngineProvider.kt +++ b/simplified-tests/src/test/java/org/nypl/simplified/tests/mocking/MockedAudioEngineProvider.kt @@ -1,9 +1,11 @@ package org.nypl.simplified.tests.mocking +import android.app.Application import org.librarysimplified.audiobook.api.PlayerAudioBookProviderType import org.librarysimplified.audiobook.api.PlayerAudioEngineProviderType import org.librarysimplified.audiobook.api.PlayerAudioEngineRequest import org.librarysimplified.audiobook.api.PlayerVersion +import org.librarysimplified.audiobook.api.extensions.PlayerExtensionType import org.slf4j.LoggerFactory /** @@ -22,8 +24,17 @@ class MockedAudioEngineProvider : PlayerAudioEngineProviderType { return "mocked" } + override fun tryDeleteRequest( + context: Application, + extensions: List, + request: PlayerAudioEngineRequest + ): Boolean { + this.logger.debug("Trying deletion request: {}", request) + return true + } + override fun tryRequest(request: PlayerAudioEngineRequest): PlayerAudioBookProviderType? { - this.logger.debug("trying request: {}", request) + this.logger.debug("Trying request: {}", request) val next = onNextRequest if (next != null) { @@ -37,7 +48,6 @@ class MockedAudioEngineProvider : PlayerAudioEngineProviderType { } companion object { - var onNextRequest: ((PlayerAudioEngineRequest) -> PlayerAudioBookProviderType?)? = null } } diff --git a/simplified-ui-accounts/src/main/java/org/nypl/simplified/ui/accounts/saml20/AccountSAML20Fragment.kt b/simplified-ui-accounts/src/main/java/org/nypl/simplified/ui/accounts/saml20/AccountSAML20Fragment.kt index 2628d556e..ac8527e67 100644 --- a/simplified-ui-accounts/src/main/java/org/nypl/simplified/ui/accounts/saml20/AccountSAML20Fragment.kt +++ b/simplified-ui-accounts/src/main/java/org/nypl/simplified/ui/accounts/saml20/AccountSAML20Fragment.kt @@ -105,8 +105,10 @@ class AccountSAML20Fragment : Fragment(R.layout.account_saml20) { return when (event) { is AccountSAML20InternalEvent.WebViewClientReady -> this.onWebViewClientReady() + is AccountSAML20InternalEvent.Failed -> this.onSAMLEventFailed(event) + is AccountSAML20InternalEvent.AccessTokenObtained -> this.onSAMLEventAccessTokenObtained() } @@ -137,7 +139,11 @@ class AccountSAML20Fragment : Fragment(R.layout.account_saml20) { ): List { val taskRecorder = TaskRecorder.create() taskRecorder.beginNewStep("Started SAML 2.0 login...") - taskRecorder.currentStepFailed(message, "samlAccountCreationFailed") + taskRecorder.currentStepFailed( + message = message, + errorCode = "samlAccountCreationFailed", + extraMessages = listOf() + ) return taskRecorder.finishFailure().steps } diff --git a/simplified-ui-catalog/src/main/java/org/librarysimplified/ui/catalog/CatalogFeedViewModel.kt b/simplified-ui-catalog/src/main/java/org/librarysimplified/ui/catalog/CatalogFeedViewModel.kt index 513ee885c..81e58f8cb 100644 --- a/simplified-ui-catalog/src/main/java/org/librarysimplified/ui/catalog/CatalogFeedViewModel.kt +++ b/simplified-ui-catalog/src/main/java/org/librarysimplified/ui/catalog/CatalogFeedViewModel.kt @@ -146,6 +146,7 @@ class CatalogFeedViewModel( this.reloadFeed() } } + is AccountEventLoginStateChanged -> this.onLoginStateChanged(event.accountID, event.state) } @@ -171,6 +172,7 @@ class CatalogFeedViewModel( this.reloadFeed() } } + CatalogFeedOwnership.CollectedFromAccounts -> { if ( accountState is AccountLoginState.AccountLoggedIn || @@ -201,6 +203,7 @@ class CatalogFeedViewModel( } } } + is ProfileUpdated.Failed -> { // do nothing } @@ -246,6 +249,7 @@ class CatalogFeedViewModel( this.reloadFeed() } } + is BookStatus.DownloadExternalAuthenticationInProgress, is BookStatus.DownloadWaitingForExternalAuthentication, is BookStatus.Downloading, @@ -307,6 +311,7 @@ class CatalogFeedViewModel( is CatalogFeedArgumentsLocalBooks -> { this.syncAccounts(arguments) } + is CatalogFeedArgumentsRemote -> { } } @@ -343,6 +348,7 @@ class CatalogFeedViewModel( return when (arguments) { is CatalogFeedArgumentsRemote -> this.doLoadRemoteFeed(arguments) + is CatalogFeedArgumentsLocalBooks -> this.doLoadLocalFeed(arguments) } @@ -523,9 +529,11 @@ class CatalogFeedViewModel( when (val feed = result.feed) { is Feed.FeedWithoutGroups -> this.onReceivedFeedWithoutGroups(arguments, feed) + is Feed.FeedWithGroups -> this.onReceivedFeedWithGroups(arguments, feed) } + is FeedLoaderResult.FeedLoaderFailure -> this.onReceivedFeedFailure(arguments, result) } @@ -544,6 +552,7 @@ class CatalogFeedViewModel( is FeedLoaderResult.FeedLoaderFailure.FeedLoaderFailedGeneral -> { // Display the error. } + is FeedLoaderResult.FeedLoaderFailure.FeedLoaderFailedAuthentication -> { when (val ownership = this.state.arguments.ownership) { is CatalogFeedOwnership.OwnedByAccount -> { @@ -563,6 +572,7 @@ class CatalogFeedViewModel( this.listener.post(CatalogFeedEvent.LoginRequired(ownership.accountId)) } } + CatalogFeedOwnership.CollectedFromAccounts -> { // Nothing we can do here! We don't know which account owns the feed. } @@ -669,6 +679,7 @@ class CatalogFeedViewModel( this.profilesController.profileCurrent() .account(ownership.accountId) .provider + is CatalogFeedOwnership.CollectedFromAccounts -> null } @@ -739,7 +750,12 @@ class CatalogFeedViewModel( val taskRecorder = TaskRecorder.create() taskRecorder.beginNewStep(this.resources.getString(R.string.catalogFeedLoading)) taskRecorder.addAttributes(failure.attributes) - taskRecorder.currentStepFailed(failure.message, "feedLoadingFailed", failure.exception) + taskRecorder.currentStepFailed( + failure.message, + "feedLoadingFailed", + exception = failure.exception, + extraMessages = listOf() + ) val taskFailure = taskRecorder.finishFailure() return ErrorPageParameters( @@ -807,6 +823,7 @@ class CatalogFeedViewModel( title = currentArguments.title ) } + is FeedSearch.FeedSearchOpen1_1 -> { CatalogFeedArgumentsRemote( feedURI = search.search.getQueryURIForTerms(query), @@ -831,6 +848,7 @@ class CatalogFeedViewModel( updateHolds = currentArguments.updateHolds ) } + is FeedSearch.FeedSearchOpen1_1 -> { CatalogFeedArgumentsLocalBooks( filterAccount = currentArguments.filterAccount, diff --git a/simplified-ui-catalog/src/main/java/org/librarysimplified/ui/catalog/saml20/CatalogSAML20ViewModel.kt b/simplified-ui-catalog/src/main/java/org/librarysimplified/ui/catalog/saml20/CatalogSAML20ViewModel.kt index ab67ebd4f..6dda4a6e7 100644 --- a/simplified-ui-catalog/src/main/java/org/librarysimplified/ui/catalog/saml20/CatalogSAML20ViewModel.kt +++ b/simplified-ui-catalog/src/main/java/org/librarysimplified/ui/catalog/saml20/CatalogSAML20ViewModel.kt @@ -118,6 +118,7 @@ class CatalogSAML20ViewModel( return when (event) { is WebClientEvent.WebViewClientReady -> this.onWebViewClientReady() + is WebClientEvent.Succeeded -> this.onSAMLEventSucceeded() } @@ -140,7 +141,11 @@ class CatalogSAML20ViewModel( ): List { val taskRecorder = TaskRecorder.create() taskRecorder.beginNewStep("Started SAML 2.0 book download login...") - taskRecorder.currentStepFailed(message, "samlBookDownloadLoginFailed") + taskRecorder.currentStepFailed( + message = message, + errorCode = "samlBookDownloadLoginFailed", + extraMessages = listOf() + ) return taskRecorder.finishFailure().steps } diff --git a/simplified-ui-errorpage/build.gradle.kts b/simplified-ui-errorpage/build.gradle.kts index 033ee6aa5..61c1f43ef 100644 --- a/simplified-ui-errorpage/build.gradle.kts +++ b/simplified-ui-errorpage/build.gradle.kts @@ -9,6 +9,7 @@ dependencies { implementation(libs.androidx.appcompat) implementation(libs.androidx.cardview) implementation(libs.androidx.constraintlayout) + implementation(libs.androidx.constraintlayout.core) implementation(libs.androidx.coordinatorlayout) implementation(libs.androidx.core) implementation(libs.androidx.core.splashscreen) diff --git a/simplified-ui-errorpage/src/main/java/org/nypl/simplified/ui/errorpage/ErrorPageStepsListAdapter.kt b/simplified-ui-errorpage/src/main/java/org/nypl/simplified/ui/errorpage/ErrorPageStepsListAdapter.kt index 7cc325bd1..41535f01c 100644 --- a/simplified-ui-errorpage/src/main/java/org/nypl/simplified/ui/errorpage/ErrorPageStepsListAdapter.kt +++ b/simplified-ui-errorpage/src/main/java/org/nypl/simplified/ui/errorpage/ErrorPageStepsListAdapter.kt @@ -32,15 +32,25 @@ class ErrorPageStepsListAdapter(private val steps: List) : override fun onBindViewHolder(holder: ViewHolder, position: Int) { val step = this.steps[position] - if (step.resolution is TaskStepResolution.TaskStepFailed) { + val resolution = step.resolution + if (resolution is TaskStepResolution.TaskStepFailed) { holder.icon.setImageResource(R.drawable.error_small) + + val multiline = StringBuilder() + multiline.append(resolution.message) + for (extra in resolution.extraMessages) { + multiline.append('\n') + multiline.append(extra) + } + + holder.resolution.text = multiline.toString() } else { holder.icon.setImageResource(R.drawable.ok_small) + holder.resolution.text = resolution.message } holder.stepNumber.text = String.format("%d.", position + 1) holder.description.text = step.description - holder.resolution.text = step.resolution.message } inner class ViewHolder(parent: View) : RecyclerView.ViewHolder(parent) { diff --git a/simplified-ui-splash/src/main/java/org/librarysimplified/ui/splash/MigrationProgressFragment.kt b/simplified-ui-splash/src/main/java/org/librarysimplified/ui/splash/MigrationProgressFragment.kt deleted file mode 100644 index 1fb6cded0..000000000 --- a/simplified-ui-splash/src/main/java/org/librarysimplified/ui/splash/MigrationProgressFragment.kt +++ /dev/null @@ -1,54 +0,0 @@ -package org.librarysimplified.ui.splash - -import android.os.Bundle -import android.view.View -import android.widget.ProgressBar -import android.widget.TextView -import androidx.fragment.app.Fragment -import androidx.fragment.app.setFragmentResult -import androidx.fragment.app.viewModels -import io.reactivex.disposables.CompositeDisposable -import org.nypl.simplified.migration.spi.MigrationEvent - -class MigrationProgressFragment : Fragment(R.layout.splash_migration_progress) { - - private lateinit var progressBar: ProgressBar - private lateinit var textView: TextView - - private val subscriptions = CompositeDisposable() - private val viewModel: MigrationViewModel by viewModels( - ownerProducer = this::requireParentFragment - ) - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - - viewModel.startMigrationsIfNotStarted() - viewModel.migrationReport.observe(this) { - setFragmentResult("", Bundle()) - } - } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - - textView = view.findViewById(R.id.splashMigrationProgressText) - progressBar = view.findViewById(R.id.splashMigrationProgress) - } - - override fun onStart() { - super.onStart() - viewModel.migrationEvents - .subscribe(this::onMigrationEvent) - .let { subscriptions.add(it) } - } - - override fun onStop() { - super.onStop() - subscriptions.clear() - } - - private fun onMigrationEvent(event: MigrationEvent) { - textView.text = event.message - } -} diff --git a/simplified-ui-splash/src/main/java/org/librarysimplified/ui/splash/MigrationReportEmail.kt b/simplified-ui-splash/src/main/java/org/librarysimplified/ui/splash/MigrationReportEmail.kt deleted file mode 100644 index 4f00e3967..000000000 --- a/simplified-ui-splash/src/main/java/org/librarysimplified/ui/splash/MigrationReportEmail.kt +++ /dev/null @@ -1,70 +0,0 @@ -package org.librarysimplified.ui.splash - -import android.content.Context -import org.librarysimplified.reports.Reports -import org.nypl.simplified.buildconfig.api.BuildConfigurationServiceType -import org.nypl.simplified.migration.spi.MigrationEvent -import org.nypl.simplified.migration.spi.MigrationReport -import java.util.ServiceLoader - -internal data class MigrationReportEmail( - val subject: String, - val body: String, - val email: String -) { - - fun send(context: Context) { - Reports.sendReportsDefault( - context = context, - address = email, - subject = subject, - body = body - ) - } - - companion object { - - fun fromMigrationReport(report: MigrationReport): MigrationReportEmail { - return MigrationReportEmail( - subject = reportEmailSubject(report), - body = reportEmailBody(report), - email = getBuildConfigService().supportErrorReportEmailAddress - ) - } - - private fun reportEmailBody(report: MigrationReport): String { - val errors = report.events.filterIsInstance().size - - return StringBuilder(128) - .append("On ${report.timestamp}, a migration of ${report.application} occurred.") - .append("\n") - .append("There were $errors errors.") - .append("\n") - .append("The attached log files give details of the migration.") - .append("\n") - .toString() - } - - private fun reportEmailSubject(report: MigrationReport): String { - val errors = - report.events.any { e -> e is MigrationEvent.MigrationStepError } - val outcome = - if (errors) { - "error" - } else { - "success" - } - - return "[simplye-android-migration] ${report.application} $outcome" - } - - private fun getBuildConfigService(): BuildConfigurationServiceType { - return ServiceLoader - .load(BuildConfigurationServiceType::class.java) - .firstOrNull() - ?: throw IllegalStateException( - "No available services of type ${BuildConfigurationServiceType::class.java.canonicalName}" - ) - } - } -} diff --git a/simplified-ui-splash/src/main/java/org/librarysimplified/ui/splash/MigrationReportFragment.kt b/simplified-ui-splash/src/main/java/org/librarysimplified/ui/splash/MigrationReportFragment.kt deleted file mode 100644 index 97609d90a..000000000 --- a/simplified-ui-splash/src/main/java/org/librarysimplified/ui/splash/MigrationReportFragment.kt +++ /dev/null @@ -1,83 +0,0 @@ -package org.librarysimplified.ui.splash - -import android.os.Bundle -import android.view.View -import android.widget.Button -import android.widget.TextView -import androidx.fragment.app.Fragment -import androidx.fragment.app.setFragmentResult -import androidx.fragment.app.viewModels -import androidx.recyclerview.widget.LinearLayoutManager -import androidx.recyclerview.widget.RecyclerView -import androidx.recyclerview.widget.SimpleItemAnimator -import org.nypl.simplified.migration.spi.MigrationEvent - -class MigrationReportFragment : Fragment(R.layout.splash_migration_report) { - - companion object { - private const val SEND_BUTTON_IS_ENABLED = "SEND_BUTTON_IS_ENABLED" - } - - private lateinit var title: TextView - private lateinit var list: RecyclerView - private lateinit var sendButton: Button - private lateinit var okButton: Button - - private val viewModel: MigrationViewModel by viewModels( - ownerProducer = this::requireParentFragment - ) - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - - title = view.findViewById(R.id.splashMigrationReportTitle) - list = view.findViewById(R.id.splashMigrationReportList) - sendButton = view.findViewById(R.id.splashMigrationReportSend) - okButton = view.findViewById(R.id.splashMigrationReportOK) - - if (savedInstanceState != null) { - sendButton.isEnabled = savedInstanceState.getBoolean(SEND_BUTTON_IS_ENABLED) - } - - val report = checkNotNull(viewModel.migrationReport.value) - - val eventsToShow = - report.events.filterNot { e -> e is MigrationEvent.MigrationStepInProgress } - - list.apply { - adapter = MigrationReportListAdapter(eventsToShow) - setHasFixedSize(false) - layoutManager = LinearLayoutManager(this.context) - (itemAnimator as SimpleItemAnimator).supportsChangeAnimations = false - adapter!!.notifyDataSetChanged() - } - - val failure = - report.events.any { e -> e is MigrationEvent.MigrationStepError } - - title.setText( - if (failure) { - R.string.migrationFailure - } else { - R.string.migrationSuccess - } - ) - } - - override fun onStart() { - super.onStart() - sendButton.setOnClickListener { - sendButton.isEnabled = false - viewModel.sendReport() - } - - okButton.setOnClickListener { - setFragmentResult("", Bundle()) - } - } - - override fun onSaveInstanceState(outState: Bundle) { - super.onSaveInstanceState(outState) - outState.putBoolean(SEND_BUTTON_IS_ENABLED, sendButton.isEnabled) - } -} diff --git a/simplified-ui-splash/src/main/java/org/librarysimplified/ui/splash/MigrationReportListAdapter.kt b/simplified-ui-splash/src/main/java/org/librarysimplified/ui/splash/MigrationReportListAdapter.kt deleted file mode 100644 index 9f150a179..000000000 --- a/simplified-ui-splash/src/main/java/org/librarysimplified/ui/splash/MigrationReportListAdapter.kt +++ /dev/null @@ -1,89 +0,0 @@ -package org.librarysimplified.ui.splash - -import android.graphics.Color -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import android.widget.ImageView -import android.widget.TextView -import androidx.recyclerview.widget.RecyclerView -import org.nypl.simplified.migration.spi.MigrationEvent - -/** - * A recycler view adapter for migration reports. - */ - -class MigrationReportListAdapter( - private val events: List -) : - RecyclerView.Adapter() { - - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { - val inflater = - LayoutInflater.from(parent.context) - val item = - inflater.inflate(R.layout.splash_migration_report_item, parent, false) - - return this.ViewHolder(item) - } - - override fun getItemCount(): Int = - this.events.size - - private val matrixOK = - SplashColorMatrix.getImageFilterMatrix(Color.BLACK) - private val matrixError = - SplashColorMatrix.getImageFilterMatrix(Color.RED) - - override fun onBindViewHolder(holder: ViewHolder, position: Int) { - val event = this.events[position] - - when (event) { - is MigrationEvent.MigrationStepInProgress -> { - // Ignored, not present! - } - - is MigrationEvent.MigrationStepSucceeded -> { - when (event.subject) { - MigrationEvent.Subject.ACCOUNT -> { - holder.icon.visibility = View.VISIBLE - holder.icon.colorFilter = this.matrixOK - holder.icon.setImageResource(R.drawable.migration_account) - } - MigrationEvent.Subject.BOOK -> { - holder.icon.visibility = View.VISIBLE - holder.icon.colorFilter = this.matrixOK - holder.icon.setImageResource(R.drawable.migration_book) - } - MigrationEvent.Subject.BOOKMARK -> { - holder.icon.visibility = View.VISIBLE - holder.icon.colorFilter = this.matrixOK - holder.icon.setImageResource(R.drawable.migration_bookmark) - } - MigrationEvent.Subject.PROFILE, - null -> { - holder.icon.visibility = View.INVISIBLE - } - } - - holder.description.setTextColor(Color.BLACK) - holder.description.text = event.message - } - - is MigrationEvent.MigrationStepError -> { - holder.icon.visibility = View.VISIBLE - holder.icon.colorFilter = this.matrixError - holder.icon.setImageResource(R.drawable.migration_error) - holder.description.setTextColor(Color.RED) - holder.description.text = event.message - } - } - } - - inner class ViewHolder(parent: View) : RecyclerView.ViewHolder(parent) { - val icon = - parent.findViewById(R.id.splashMigrationReportItemIcon) - val description = - parent.findViewById(R.id.splashMigrationReportItemText) - } -} diff --git a/simplified-ui-splash/src/main/java/org/librarysimplified/ui/splash/MigrationViewModel.kt b/simplified-ui-splash/src/main/java/org/librarysimplified/ui/splash/MigrationViewModel.kt deleted file mode 100644 index f31704b21..000000000 --- a/simplified-ui-splash/src/main/java/org/librarysimplified/ui/splash/MigrationViewModel.kt +++ /dev/null @@ -1,68 +0,0 @@ -package org.librarysimplified.ui.splash - -import android.app.Application -import androidx.lifecycle.AndroidViewModel -import androidx.lifecycle.MutableLiveData -import com.google.common.util.concurrent.MoreExecutors -import hu.akarnokd.rxjava2.subjects.UnicastWorkSubject -import io.reactivex.android.schedulers.AndroidSchedulers -import io.reactivex.disposables.CompositeDisposable -import org.librarysimplified.services.api.Services -import org.nypl.simplified.migration.api.MigrationsType -import org.nypl.simplified.migration.spi.MigrationEvent -import org.nypl.simplified.migration.spi.MigrationReport -import java.util.concurrent.Future - -class MigrationViewModel(application: Application) : AndroidViewModel(application) { - - private val migrations = - Services - .serviceDirectory() - .requireService(MigrationsType::class.java) - - private val subscriptions = - CompositeDisposable() - - init { - migrations.events - .observeOn(AndroidSchedulers.mainThread()) - .subscribe(this::onMigrationEvent) - .let { subscriptions.add(it) } - } - - private fun onMigrationEvent(event: MigrationEvent) { - migrationEvents.onNext(event) - } - - override fun onCleared() { - super.onCleared() - subscriptions.clear() - } - - val migrationEvents: UnicastWorkSubject = - UnicastWorkSubject.create() - - val migrationReport: MutableLiveData = - MutableLiveData() - - fun startMigrationsIfNotStarted(): Future { - val future = migrations.start() - - future.addListener( - { migrationReport.postValue(future.get()) }, - MoreExecutors.directExecutor() - ) - - return future - } - - fun anyMigrationNeedToRun(): Boolean { - return migrations.anyNeedToRun() - } - - fun sendReport() { - val report = checkNotNull(migrationReport.value) - MigrationReportEmail.fromMigrationReport(report) - .send(getApplication()) - } -} diff --git a/simplified-ui-splash/src/main/java/org/librarysimplified/ui/splash/SplashFragment.kt b/simplified-ui-splash/src/main/java/org/librarysimplified/ui/splash/SplashFragment.kt index 9ad8692b9..cefde3eec 100644 --- a/simplified-ui-splash/src/main/java/org/librarysimplified/ui/splash/SplashFragment.kt +++ b/simplified-ui-splash/src/main/java/org/librarysimplified/ui/splash/SplashFragment.kt @@ -3,8 +3,6 @@ package org.librarysimplified.ui.splash import android.os.Bundle import androidx.fragment.app.Fragment import androidx.fragment.app.FragmentResultListener -import androidx.fragment.app.commit -import androidx.lifecycle.ViewModelProvider import org.nypl.simplified.listeners.api.FragmentListenerType import org.nypl.simplified.listeners.api.fragmentListeners import org.slf4j.LoggerFactory @@ -12,12 +10,12 @@ import org.slf4j.LoggerFactory class SplashFragment : Fragment(R.layout.splash_fragment), FragmentResultListener { private val logger = LoggerFactory.getLogger(SplashFragment::class.java) - private val listener: FragmentListenerType by fragmentListeners() + private val listener: FragmentListenerType by this.fragmentListeners() override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - childFragmentManager.setFragmentResultListener( + this.childFragmentManager.setFragmentResultListener( "", this, this::onFragmentResult @@ -25,61 +23,12 @@ class SplashFragment : Fragment(R.layout.splash_fragment), FragmentResultListene } override fun onFragmentResult(requestKey: String, result: Bundle) { - when (childFragmentManager.fragments.last()) { - is BootFragment -> onBootCompleted() - is MigrationProgressFragment -> onMigrationCompleted() - is MigrationReportFragment -> onMigrationReportFinished() + when (this.childFragmentManager.fragments.last()) { + is BootFragment -> this.onBootCompleted() } } private fun onBootCompleted() { - val migrationViewModel = - ViewModelProvider(this) - .get(MigrationViewModel::class.java) - - if (migrationViewModel.anyMigrationNeedToRun()) { - showMigrationRunning() - } else { - this.logger.debug("no migration to run") - onMigrationReportFinished() - } - } - - private fun onMigrationCompleted() { - val migrationViewModel = - ViewModelProvider(this) - .get(MigrationViewModel::class.java) - - if (migrationViewModel.migrationReport.value != null) { - showMigrationReport() - } else { - this.logger.debug("no report to show") - onMigrationReportFinished() - } - } - - private fun onMigrationReportFinished() { this.listener.post(SplashEvent.SplashCompleted) } - - private fun showEula() { - this.logger.debug("showEula") - childFragmentManager.commit { - replace(R.id.splash_fragment_container, EulaFragment::class.java, Bundle()) - } - } - - private fun showMigrationRunning() { - this.logger.debug("showMigrationRunning") - childFragmentManager.commit { - replace(R.id.splash_fragment_container, MigrationProgressFragment::class.java, Bundle()) - } - } - - private fun showMigrationReport() { - this.logger.debug("showMigrationReport") - childFragmentManager.commit { - replace(R.id.splash_fragment_container, MigrationReportFragment::class.java, Bundle()) - } - } } diff --git a/simplified-viewer-audiobook/src/main/java/org/librarysimplified/viewer/audiobook/AudioBookPlayerActivity2.kt b/simplified-viewer-audiobook/src/main/java/org/librarysimplified/viewer/audiobook/AudioBookPlayerActivity2.kt index 12a851c1d..f91bcfbdd 100644 --- a/simplified-viewer-audiobook/src/main/java/org/librarysimplified/viewer/audiobook/AudioBookPlayerActivity2.kt +++ b/simplified-viewer-audiobook/src/main/java/org/librarysimplified/viewer/audiobook/AudioBookPlayerActivity2.kt @@ -220,6 +220,7 @@ class AudioBookPlayerActivity2 : AppCompatActivity(R.layout.audio_book_player_ba PlayerBookmarkModel.setBookmarks(newBookmarks.toList()) } + PlayerBookmarkKind.LAST_READ -> { // Nothing to do here. } @@ -234,6 +235,7 @@ class AudioBookPlayerActivity2 : AppCompatActivity(R.layout.audio_book_player_ba Toast.makeText(this, R.string.audio_book_player_bookmark_added, Toast.LENGTH_SHORT) .show() } + PlayerBookmarkKind.LAST_READ -> { // Nothing to do here. } @@ -339,7 +341,7 @@ class AudioBookPlayerActivity2 : AppCompatActivity(R.layout.audio_book_player_ba manifest = state.manifest, fetchAll = true, initialPosition = initialPosition, - bookFile = bookParameters.file, + bookSource = state.bookSource, bookCredentials = bookParameters.drmInfo.playerCredentials() ) } @@ -383,14 +385,15 @@ class AudioBookPlayerActivity2 : AppCompatActivity(R.layout.audio_book_player_ba val task = TaskRecorder.create() task.beginNewStep("Parsing manifest…") - state.failure.mapIndexed { index, error -> - this.logger.error("{}:{}: {}", error.line, error.column, error.message) - task.addAttribute( - "Parse Error [$index]", - "${error.line}:${error.column}: ${error.message}" - ) - } - task.currentStepFailed("Parsing failed.", "error-manifest-parse") + + val extraMessages = + state.failure.map { error -> "${error.line}:${error.column}: ${error.message}" } + + task.currentStepFailed( + message = "Parsing failed.", + errorCode = "error-manifest-parse", + extraMessages = extraMessages + ) val alert = MaterialAlertDialogBuilder(this) alert.setTitle(R.string.audio_book_player_error_book_open) @@ -426,7 +429,12 @@ class AudioBookPlayerActivity2 : AppCompatActivity(R.layout.audio_book_player_ba val task = TaskRecorder.create() task.beginNewStep("Opening book…") - task.currentStepFailed(state.message, "error-book-open") + task.currentStepFailed( + message = state.message, + errorCode = "error-book-open", + exception = state.exception, + extraMessages = listOf() + ) val alert = MaterialAlertDialogBuilder(this) alert.setTitle(R.string.audio_book_player_error_book_open) @@ -450,11 +458,16 @@ class AudioBookPlayerActivity2 : AppCompatActivity(R.layout.audio_book_player_ba task.beginNewStep("Downloading manifest…") val serverData = state.failure.serverData if (serverData != null) { + task.addAttributes(serverData.problemReport?.toMap() ?: mapOf()) task.addAttribute("URI", serverData.uri.toString()) task.addAttribute("Code", serverData.code.toString()) task.addAttribute("ContentType", serverData.receivedContentType) } - task.currentStepFailed(state.failure.message, "error-manifest-download") + task.currentStepFailed( + message = state.failure.message, + errorCode = "error-manifest-download", + extraMessages = listOf() + ) val alert = MaterialAlertDialogBuilder(this) alert.setTitle(R.string.audio_book_player_error_book_open) @@ -476,7 +489,11 @@ class AudioBookPlayerActivity2 : AppCompatActivity(R.layout.audio_book_player_ba val task = TaskRecorder.create() task.beginNewStep("Checking license…") - task.currentStepFailed("License checks failed.", "error-manifest-license") + task.currentStepFailed( + message = "License checks failed.", + errorCode = "error-manifest-license", + extraMessages = state.messages + ) val alert = MaterialAlertDialogBuilder(this) alert.setTitle(R.string.audio_book_player_error_book_open) @@ -538,7 +555,13 @@ class AudioBookPlayerActivity2 : AppCompatActivity(R.layout.audio_book_player_ba val status = e.status if (status is PlayerDownloadTaskStatus.Failed) { task.beginNewStep("Downloading ${e.playbackURI}...") - task.currentStepFailed(status.message, "error-download", status.exception) + task.currentStepFailed( + message = status.message, + errorCode = "error-download", + exception = status.exception, + extraMessages = + book.downloadTasks.filterIsInstance() + .map { s -> s.message }) } } } catch (e: Exception) { diff --git a/simplified-viewer-audiobook/src/main/java/org/librarysimplified/viewer/audiobook/AudioBookPlayerParameters.kt b/simplified-viewer-audiobook/src/main/java/org/librarysimplified/viewer/audiobook/AudioBookPlayerParameters.kt index 661a324d3..d5eb6d10f 100644 --- a/simplified-viewer-audiobook/src/main/java/org/librarysimplified/viewer/audiobook/AudioBookPlayerParameters.kt +++ b/simplified-viewer-audiobook/src/main/java/org/librarysimplified/viewer/audiobook/AudioBookPlayerParameters.kt @@ -1,20 +1,9 @@ package org.librarysimplified.viewer.audiobook -import android.app.Application -import one.irradia.mime.vanilla.MIMEParser -import org.librarysimplified.audiobook.api.PlayerUserAgent -import org.librarysimplified.audiobook.manifest_fulfill.spi.ManifestFulfilled -import org.librarysimplified.services.api.Services -import org.nypl.simplified.accounts.api.AccountAuthenticationCredentials import org.nypl.simplified.accounts.api.AccountID import org.nypl.simplified.books.api.BookDRMInformation import org.nypl.simplified.books.api.BookID -import org.nypl.simplified.books.audio.AudioBookCredentials -import org.nypl.simplified.books.audio.AudioBookManifestRequest -import org.nypl.simplified.books.audio.AudioBookManifestStrategiesType -import org.nypl.simplified.books.audio.AudioBookManifestStrategyType import org.nypl.simplified.opds.core.OPDSAcquisitionFeedEntry -import java.io.File import java.io.Serializable import java.net.URI @@ -24,37 +13,12 @@ import java.net.URI class AudioBookPlayerParameters( - /** - * The audio book file, if this is a packaged audio book. This must be null for unpackaged audio - * books. - */ - - val file: File?, - /** * The user agent string used to make manifest requests. */ val userAgent: String, - /** - * The current manifest content type. - */ - - val manifestContentType: String, - - /** - * The current manifest file. - */ - - val manifestFile: File, - - /** - * A URI that can be used to fetch a more up-to-date copy of the manifest. - */ - - val manifestURI: URI, - /** * The account to which the book belongs. */ @@ -84,79 +48,4 @@ class AudioBookPlayerParameters( */ val drmInfo: BookDRMInformation -) : Serializable { - - /** - * Create a manifest strategy for the current parameters. - */ - - fun toManifestStrategy( - application: Application, - strategies: AudioBookManifestStrategiesType, - isNetworkAvailable: () -> Boolean, - credentials: AccountAuthenticationCredentials?, - cacheDirectory: File - ): AudioBookManifestStrategyType { - val manifestContentType = - MIMEParser.parseRaisingException(this.manifestContentType) - val userAgent = - PlayerUserAgent(this.userAgent) - - val audioBookCredentials = - when (credentials) { - is AccountAuthenticationCredentials.Basic -> { - if (credentials.password.value.isBlank()) { - AudioBookCredentials.UsernameOnly( - userName = credentials.userName.value - ) - } else { - AudioBookCredentials.UsernamePassword( - userName = credentials.userName.value, - password = credentials.password.value - ) - } - } - is AccountAuthenticationCredentials.BasicToken -> { - if (credentials.password.value.isBlank()) { - AudioBookCredentials.UsernameOnly( - userName = credentials.userName.value - ) - } else { - AudioBookCredentials.UsernamePassword( - userName = credentials.userName.value, - password = credentials.password.value - ) - } - } - is AccountAuthenticationCredentials.OAuthWithIntermediary -> { - AudioBookCredentials.BearerToken(credentials.accessToken) - } - is AccountAuthenticationCredentials.SAML2_0 -> { - AudioBookCredentials.BearerToken(credentials.accessToken) - } - null -> { - null - } - } - - val request = - AudioBookManifestRequest( - file = this.file, - targetURI = this.manifestURI, - contentType = manifestContentType, - userAgent = userAgent, - credentials = audioBookCredentials, - services = Services.serviceDirectory(), - isNetworkAvailable = isNetworkAvailable, - loadFallbackData = { - ManifestFulfilled(manifestContentType, null, this.manifestFile.readBytes()) - }, - cacheDirectory = cacheDirectory - ) - - return strategies.createStrategy( - context = application, - request = request - ) - } -} +) : Serializable diff --git a/simplified-viewer-audiobook/src/main/java/org/librarysimplified/viewer/audiobook/AudioBookViewer.kt b/simplified-viewer-audiobook/src/main/java/org/librarysimplified/viewer/audiobook/AudioBookViewer.kt index 0d1a4c4c9..5c0c1fee8 100644 --- a/simplified-viewer-audiobook/src/main/java/org/librarysimplified/viewer/audiobook/AudioBookViewer.kt +++ b/simplified-viewer-audiobook/src/main/java/org/librarysimplified/viewer/audiobook/AudioBookViewer.kt @@ -6,15 +6,18 @@ import one.irradia.mime.api.MIMEType import org.librarysimplified.audiobook.api.PlayerUserAgent import org.librarysimplified.audiobook.feedbooks.FeedbooksPlayerExtension import org.librarysimplified.audiobook.license_check.spi.SingleLicenseCheckProviderType +import org.librarysimplified.audiobook.manifest_fulfill.spi.ManifestFulfilled import org.librarysimplified.audiobook.manifest_parser.extension_spi.ManifestParserExtensionType import org.librarysimplified.audiobook.views.PlayerModel import org.librarysimplified.http.api.LSHTTPClientType import org.librarysimplified.services.api.ServiceDirectoryType import org.librarysimplified.services.api.Services import org.nypl.simplified.books.api.Book +import org.nypl.simplified.books.api.BookDRMInformation import org.nypl.simplified.books.api.BookFormat import org.nypl.simplified.books.audio.AudioBookFeedbooksSecretServiceType -import org.nypl.simplified.books.audio.AudioBookManifestFulfillmentAdapter +import org.nypl.simplified.books.audio.AudioBookLink +import org.nypl.simplified.books.audio.AudioBookManifestRequest import org.nypl.simplified.books.audio.AudioBookManifestStrategiesType import org.nypl.simplified.books.formats.api.StandardFormatNames import org.nypl.simplified.networkconnectivity.api.NetworkConnectivityType @@ -106,59 +109,139 @@ class AudioBookViewer : ViewerProviderType { val manifest = formatAudio.manifest + PlayerModel.setStreamingPermitted(true) PlayerModel.bookAuthor = book.entry.authorsCommaSeparated PlayerModel.bookTitle = book.entry.title + val account = + profiles.profileCurrent() + .account(book.account) + val accountCredentials = + account.loginState + .credentials + this.loadAndConfigureFeedbooks(services) - if (manifest != null) { - val parameters = - AudioBookPlayerParameters( - accountID = book.account, - accountProviderID = accountProviderId, - bookID = book.id, - file = file, - drmInfo = formatAudio.drmInformation, - manifestContentType = format.contentType.fullType, - manifestFile = manifest.manifestFile, - manifestURI = manifest.manifestURI, - opdsEntry = book.entry, - userAgent = httpClient.userAgent() - ) + val userAgent = + PlayerUserAgent(httpClient.userAgent()) + val bookCredentials = + formatAudio.drmInformation.playerCredentials() + + /* + * We now have to deal with backwards compatibility: + */ + + val drmInformation = + format.drmInformation + + AudioBookViewerModel.parameters = + AudioBookPlayerParameters( + userAgent = userAgent.userAgent, + accountID = book.account, + bookID = book.id, + opdsEntry = book.entry, + accountProviderID = account.provider.id, + drmInfo = drmInformation + ) - AudioBookViewerModel.parameters = parameters + /* + * We might only have a book file if the user is coming from a previous version of the app + * that used "packaged" audiobook files. If this is the case, then the packaged audiobook + * needs to be passed in to the audiobook library. + */ - val accountCredentials = - profiles.profileCurrent() - .account(book.account) - .loginState - .credentials + if (file != null) { + PlayerModel.downloadLocalPackagedAudiobook( + bookCredentials = bookCredentials, + bookFile = file, + cacheDir = activity.cacheDir, + context = activity.application, + licenseChecks = licenseChecks, + parserExtensions = parserExtensions, + userAgent = userAgent, + ) + this.openActivity(activity) + return + } - val strategy = - parameters.toManifestStrategy( - application = activity.application, - strategies = strategies, - isNetworkAvailable = { networkConnectivity.isNetworkAvailable }, - credentials = accountCredentials, - cacheDirectory = activity.cacheDir + /* + * If we don't have a book file, then the user might have just checked out a book using + * the current version of the app. Therefore, we might have a license file. If we do, then + * the book needs to be opened using the license. If we have a license file, this implies + * that we also have a manifest, because the version of the app that started saving license + * files also started saving manifests. + */ + + if (drmInformation is BookDRMInformation.LCP) { + val licenseBytes = drmInformation.licenseBytes + if (licenseBytes != null && manifest != null) { + PlayerModel.parseAndCheckLCPLicense( + bookCredentials = bookCredentials, + licenseBytes = licenseBytes, + manifestBytes = manifest.manifestFile.readBytes(), + cacheDir = activity.cacheDir, + licenseChecks = licenseChecks, + parserExtensions = parserExtensions, + userAgent = userAgent, ) + this.openActivity(activity) + return + } + } - val strategyAdapted = - AudioBookManifestFulfillmentAdapter(strategy) + /* + * Otherwise, we must only have a manifest file, but we might also have a manifest source URI. + * If we have a manifest source URI, then a new version of the manifest should be downloaded. + */ + + check(manifest != null) { "Manifest must be present" } + + val manifestURI = manifest.manifestURI + if (manifestURI != null && manifestURI.isAbsolute) { + val manifestRequest = + AudioBookManifestRequest( + httpClient = httpClient, + target = AudioBookLink.Manifest(manifestURI), + contentType = format.contentType, + userAgent = userAgent, + cacheDirectory = activity.cacheDir, + credentials = accountCredentials, + services = services + ) PlayerModel.downloadParseAndCheckManifest( - sourceURI = manifest.manifestURI, - userAgent = PlayerUserAgent(httpClient.userAgent()), + sourceURI = manifestURI, + userAgent = userAgent, cacheDir = activity.cacheDir, licenseChecks = licenseChecks, parserExtensions = parserExtensions, - strategy = strategyAdapted, - bookCredentials = formatAudio.drmInformation.playerCredentials() + strategy = strategies.createStrategy( + context = activity.application, + request = manifestRequest + ).toManifestStrategy(), + bookCredentials = bookCredentials ) - } else { - AudioBookViewerModel.parameters = null + this.openActivity(activity) + return } + PlayerModel.parseAndCheckManifest( + cacheDir = activity.cacheDir, + manifest = ManifestFulfilled( + source = null, + contentType = format.contentType, + authorization = null, + data = manifest.manifestFile.readBytes() + ), + licenseChecks = licenseChecks, + userAgent = userAgent, + parserExtensions = parserExtensions, + bookCredentials = bookCredentials + ) + this.openActivity(activity) + } + + private fun openActivity(activity: Activity) { activity.startActivity(Intent(activity, AudioBookPlayerActivity2::class.java)) } } From 31d4968a0e730475767ff8fb531aa61e348c1c79 Mon Sep 17 00:00:00 2001 From: Mark Raynsford Date: Fri, 25 Oct 2024 12:57:06 +0000 Subject: [PATCH 2/2] Use palace LCP artifacts. --- settings.gradle.kts | 37 +++++--------------------- simplified-app-palace/build.gradle.kts | 2 +- 2 files changed, 8 insertions(+), 31 deletions(-) diff --git a/settings.gradle.kts b/settings.gradle.kts index 2f0f1b44d..114aa0e1e 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -93,6 +93,13 @@ dependencyResolutionManagement { val credentialsPath = propertyOptional("org.thepalaceproject.app.credentials.palace") + if (lcpDRMEnabled && !s3RepositoryEnabled) { + throw GradleException( + "If the org.thepalaceproject.lcp.enabled property is set to true, " + + "the org.thepalaceproject.s3.depend property must also be set to true." + ) + } + /* * The set of repositories used to resolve library dependencies. The order is significant! */ @@ -184,36 +191,6 @@ dependencyResolutionManagement { } } - /* - * Enable access to various credentials-gated elements. - */ - - if (lcpDRMEnabled) { - val filePath: String = - when (val lcpProfile = property("org.thepalaceproject.lcp.profile")) { - "prod", "test" -> { - "${credentialsPath}/LCP/Android/build_lcp_${lcpProfile}.properties" - } - else -> { - throw GradleException("Unrecognized LCP profile: $lcpProfile") - } - } - - val lcpProperties = Properties() - lcpProperties.load(File(filePath).inputStream()) - - ivy { - name = "LCP" - url = uri(lcpProperties.getProperty("org.thepalaceproject.lcp.repositoryURI")) - patternLayout { - artifact(lcpProperties.getProperty("org.thepalaceproject.lcp.repositoryLayout")) - } - metadataSources { - artifact() - } - } - } - /* * Obsolete dependencies. */ diff --git a/simplified-app-palace/build.gradle.kts b/simplified-app-palace/build.gradle.kts index 45f3c4a65..4514c2723 100644 --- a/simplified-app-palace/build.gradle.kts +++ b/simplified-app-palace/build.gradle.kts @@ -343,7 +343,7 @@ dependencies { */ if (lcpDRM) { - implementation(libs.readium.lcp) { + implementation(libs.palace.liblcp) { artifact { type = "aar" }