Skip to content

fix(lcp): Marketplace LCP-wrapped PDFs fail to open — port recursive predicate (PP-4454)#1008

Merged
mauricecarrier7 merged 15 commits into
developfrom
fix/PP-4454-lcp-pdf-marketplace
May 27, 2026
Merged

fix(lcp): Marketplace LCP-wrapped PDFs fail to open — port recursive predicate (PP-4454)#1008
mauricecarrier7 merged 15 commits into
developfrom
fix/PP-4454-lcp-pdf-marketplace

Conversation

@mauricecarrier7
Copy link
Copy Markdown
Contributor

@mauricecarrier7 mauricecarrier7 commented May 26, 2026

Summary

Closes PP-4454. Direct structural mirror of PR #958 (PP-4407 audiobook fix) ported to PDFs.

Marketplace OPDS 2.0 JSON feeds wrap LCP PDFs as opds-publication+json → [LCP license → application/pdf] with the LCP MIME nested one level deep in indirectAcquisitions. LCPPDFs.canOpenBook only inspected defaultAcquisition.type, so it returned false for Marketplace-shape PDFs (e.g. Family Matters by Gregory C. Elliott from the ticket). BookFileManager.pathExtension then routed the file to .epub instead of .zip, breaking the LCP extract pass — patrons saw "an error message" instead of the PDF rendering.

Fix

  1. LCPPDFs.hasLCPAcquisition (new @objc static) — recursive predicate over the full acquisition chain, gated by defaultBookContentType == .pdf so LCP audiobooks/EPUBs don't false-match.
  2. LCPPDFs.indirectChainContainsLCP (new private helper) — recursive walker, identical structure to the audiobook equivalent.
  3. BookFileManager.pathExtension — swap both LCP branches (audiobook + PDF) from canOpenBook to hasLCPAcquisition. The PDF side is the PP-4454 fix; the audiobook side completes the forward-port that PR fix(3.1.0): Marketplace audiobook open — pathExtension + symlink recovery + diagnostics #958 made on its hotfix branch but never landed on develop.

Tests

New LCPPDFAcquisitionPredicateTests (4 tests) — direct structural mirror of LCPAcquisitionPredicateTests. The Marketplace-shape test asserts canOpenBook=false AND hasLCPAcquisition=true on the same fixture — the divergence is the deterministic proof the recursive predicate is doing real work, not duplicating canOpenBook.

21/21 LCP suites pass green on iPhone 16 Pro sim (4 new + 4 audiobook + 13 LCPPDFs).

Scope

File LOC
Palace/PDF/LCP/LCPPDFs.swift +35
Palace/MyBooks/BookFileManager.swift +12 -2
PalaceTests/LCP/LCPPDFAcquisitionPredicateTests.swift +167 (new)
Palace.xcodeproj/project.pbxproj pbxproj entry for test file

~55 production LOC, 167 test LOC.

ForgeOS changeset: cs_8e01deae.

Test plan

  • LCPPDFAcquisitionPredicateTests — 4/4 pass (Marketplace nested chain kill point, top-level XML, no-LCP negative, content-type-gate negative)
  • LCPAcquisitionPredicateTests (audiobook PP-4407 regression) — 4/4 still green
  • LCPPDFsTests — 13/13 still green
  • Build green on iPhone 16 Pro sim
  • End-to-end simdrive repro on Family Matters (Gregory C. Elliott) — blocked: all copies out on A1QA Test Library (Place Hold only). The unit-test fixture encodes the exact OPDS shape and is the load-bearing evidence; live repro can be re-attempted when a copy frees up.

Not done / Deferred

Deferred (separate follow-up): The LCP PDF rendering wiring is broken at a deeper layer than this PR fixes. TPPPDFDocument.init(encryptedData:decryptor:) exists but is never called on develop — BookService.presentPDF and BookCellModel.didSelectRead pass the on-disk URL to TPPPDFDocument(url:) (the plain PDFKit path), and LCPFulfillmentHandler.fulfillLCPLicense writes the extract to a temp URL no caller reads. The acquisition-layer fix in this PR is necessary but not sufficient for Family Matters to actually open in the reader. The proper architectural fix lives on feature/readium-pdf-navigator-migration (commit 9d042ab38 — Readium PDFNavigator); that branch is 373 commits behind develop and needs adaptation. Tracked separately.

🤖 Generated with Claude Code

…ace open (PP-4454)

Marketplace OPDS 2.0 JSON feeds wrap LCP PDFs as
`opds-publication+json → [LCP license → application/pdf]` with the LCP MIME
nested one level deep in `indirectAcquisitions`. `LCPPDFs.canOpenBook` only
inspected `defaultAcquisition.type`, so it returned false for Marketplace-
shape PDFs (e.g. Family Matters by Gregory C. Elliott from the ticket).
`BookFileManager.pathExtension` then routed the file to `.epub` instead of
`.zip`, breaking the LCP extract pass. Patrons saw "an error message"
instead of the PDF rendering.

Direct structural mirror of PR #958 / 3.0.3 hotfix `ca2ff13b6` (PP-4407)
which closed the same regression class for audiobooks.

THE FIX:
(1) LCPPDFs.hasLCPAcquisition (new @objc static) — recursive predicate over
    the full acquisition chain (top-level type + nested
    `indirectAcquisitions[*].type`) looking for the LCP license MIME, gated
    by `defaultBookContentType == .pdf` so LCP-typed audiobooks/EPUBs don't
    false-match.
(2) LCPPDFs.indirectChainContainsLCP (new private helper) — recursive
    walker, identical structure to LCPAudiobooks's equivalent.
(3) BookFileManager.pathExtension — swap both LCP branches from
    `canOpenBook` to `hasLCPAcquisition`. The PDF side is the PP-4454 fix;
    the audiobook side completes the forward-port that PR #958 made on its
    hotfix branch but never landed on develop. New downloads of Marketplace
    LCP PDFs now save with `.zip` extension directly.

TESTS: New LCPPDFAcquisitionPredicateTests (4 tests), direct structural
mirror of LCPAcquisitionPredicateTests. The Marketplace-shape test asserts
`canOpenBook=false AND hasLCPAcquisition=true` on the same fixture — the
divergence is the deterministic proof the recursive predicate is doing
real work, not duplicating canOpenBook. 21/21 LCP suites pass green
(4 new + 4 audiobook + 13 LCPPDFs).

ForgeOS changeset: cs_8e01deae (init_e7603d4e — current initiative).

**Scope:** 2 production files (LCPPDFs.swift +35 / BookFileManager.swift
+12 -2) + 1 test file (LCPPDFAcquisitionPredicateTests.swift +167) + 1
pbxproj entry. ~55 production LOC.

**Not done:** End-to-end simdrive repro against the exact ticket-target
Family Matters book. The book has 0 copies available on A1QA Test Library
(Place Hold only). Repro evidence on Desktop/PP-4454 shows the book
detail page reached + A1QA's Palace Marketplace lane confirmed reachable.
The unit-test fixture (`testHasLCPAcquisition_nestedLCPInIndirectChain_returnsTrue`)
encodes the exact OPDS shape from the bug report and asserts both halves
deterministically — that is the load-bearing evidence.

**Deferred:** The LCP PDF rendering wiring is broken at a deeper layer
than this PR fixes. `TPPPDFDocument.init(encryptedData:decryptor:)` exists
but is never called on develop — `BookService.presentPDF` and
`BookCellModel.didSelectRead` pass the on-disk URL to
`TPPPDFDocument(url:)` (the plain PDFKit path), and
`LCPFulfillmentHandler.fulfillLCPLicense` writes the extract to a temp URL
no caller reads. The acquisition-layer fix is necessary but not sufficient
for Family Matters to actually open in the reader. The proper
architectural fix lives on `feature/readium-pdf-navigator-migration`
(commit 9d042ab — Readium PDFNavigator); that branch is 373 commits
behind develop and needs adaptation. Tracked separately.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@github-actions
Copy link
Copy Markdown

github-actions Bot commented May 26, 2026

🏗️ CodeAtlas Ledger Analysis

✅ All Checks Passed


♿ Accessibility (via AccessLint)

💡 Static analysis against WCAG 2.1 AA guidelines for iOS accessibility.

No accessibility issues detected


🧪 Test Coverage (via QAAtlas)

⚠️ No coverage data available - QAAtlas requires an OpenAI API key.
Set OPENAI_API_KEY in repository secrets to enable AI-powered test coverage analysis.


🏛️ Architecture Analysis

Metric Value ℹ️ What This Means
Components 19 Distinct modules/layers detected in your codebase
Dependency Cycles 0 Circular dependencies (A→B→C→A). Goal: 0
Layer Violations 0 Dependencies that break architectural boundaries
Hotspots 0 Files with high complexity + frequent changes
Avg Coupling 0.47 How interconnected modules are (lower is better, <1.0 is good)

Architecture looks healthy!


🔍 Reachability Analysis

💡 Detects code that cannot be reached from entry points (dead code).

No dead code detected


📊 0 files analyzed | 📦 Download Full Report

Powered by CodeAtlas Ledger
• Accessibility: AccessLint

@github-actions
Copy link
Copy Markdown

github-actions Bot commented May 26, 2026

🧪 Unit Test Results

📊 View Full Interactive Report

✅ ALL TESTS PASSED

6693 tests | 6600 passed | 0 failed | 93 skipped | ⏱️ 10m 58s | 📊 98.6% | 📈 46.7% coverage

Tests by Class — 755 classes (click to expand)
Class Tests Passed Failed Duration
✅ AccessLintComplianceTests 11 11 0 83ms
✅ AccessibilityAnnouncementCenterTests 20 20 0 1.59s
✅ AccessibilityLabelTests 9 9 0 28ms
✅ AccessibilityPreferencesTests 26 26 0 62ms
✅ AccessibilityServiceTests 11 11 0 109ms
✅ AccountAuthDocCarryoverTests 5 5 0 804ms
✅ AccountAwareNetworkTests 10 10 0 249ms
✅ AccountDetailCredentialStateTests 7 0 0 173ms
✅ AccountDetailPINVisibilityTests 25 0 0 734ms
✅ AccountDetailSignOutConfirmationTests 2 0 0 11ms
✅ AccountDetailViewModelGapTests 1 1 0 119ms
✅ AccountDetailViewModelTests 19 0 0 128ms
✅ AccountDetailsNeedsAuthAggregateTests 10 10 0 38ms
✅ AccountDetailsURLTests 17 17 0 82ms
✅ AccountModelGapTests 9 9 0 790ms
✅ AccountModelTests 20 20 0 113ms
✅ AccountProfileDocumentTests 3 3 0 20ms
✅ AccountStateMachineTests 7 7 0 28ms
✅ AccountSwitchCleanupTests 8 8 0 204ms
✅ AccountSwitchIntegrationTests 8 8 0 222ms
✅ AccountSwitchLifecycleTests 9 0 0 61ms
✅ AccountsManagerCacheTests 16 16 0 452ms
✅ AccountsManagerGapTests 3 3 0 38ms
✅ AccountsManagerHelpersTests 12 12 0 20ms
✅ AccountsManagerStateMachineWiringTests 10 10 0 5.72s
✅ AccountsManagerTests 51 51 0 8.91s
✅ AdobeActivationTests 6 6 0 15ms
✅ AdobeCertificateGapTests 7 7 0 48ms
✅ AdobeDRMCharacterizationTests 21 21 0 64ms
✅ AdobeDRMErrorGapTests 3 3 0 77ms
✅ AdobeDRMHandlerTests 12 12 0 95ms
✅ AdobeDRMServiceGapTests 2 2 0 8ms
✅ AlertModelCoverageTests 6 6 0 10ms
✅ AlertModelRetryTests 7 7 0 26ms
✅ AlertModelTests 2 2 0 5ms
✅ AlertUtilsTests 20 20 0 349ms
✅ AnnotationContractTests 3 3 0 12ms
✅ AnnotationDeviceIDTests 2 2 0 4ms
✅ AnnotationPostResponseContractTests 1 1 0 3ms
✅ AnnouncementChainTests 5 5 0 35ms
✅ AnnouncementTests 3 3 0 6ms
✅ AnonymousBorrowBaselineFixtureTests 13 13 0 54ms
✅ AnonymousBorrowCandidateFixtureTests 6 6 0 127ms
✅ AnonymousBorrowDeltaTests 2 2 0 32ms
✅ AppContainerAudiobookFactoryTests 3 3 0 9ms
✅ AppContainerImageLoaderInjectionTests 4 4 0 96ms
✅ AppContainerTests 4 4 0 8ms
✅ AppHealthViewModelTests 8 8 0 1.27s
✅ AppLaunchTrackerExtendedTests 16 16 0 412ms
✅ AppLaunchTrackerTests 10 10 0 337ms
✅ AppRouteTests 5 5 0 11ms
✅ AppTabHostViewBadgeCountTests 10 10 0 55ms
✅ AppTabRouterCoverageTests 4 4 0 16ms
✅ AppTabRouterGapTests 3 3 0 113ms
✅ ArrayExtensionsTests 6 6 0 52ms
✅ AudioBookmarkGapTests 6 6 0 20ms
✅ AudioEngineWrapperTests 8 8 0 302ms
✅ AudioInterruptionLogicTests 6 6 0 18ms
✅ AudiobookAccessibilityTests 7 7 0 33ms
✅ AudiobookBackgroundAudioTests 2 2 0 26ms
✅ AudiobookBookmarkBusinessLogicPositionWriteTests 6 6 0 112ms
✅ AudiobookBookmarkBusinessLogicTests 21 21 0 3.99s
✅ AudiobookChapterTOCNormalizationTests 6 6 0 14ms
✅ AudiobookDataManagerEmptyQueueTests 1 1 0 6ms
✅ AudiobookDataManagerErrorHandlingTests 5 5 0 10.20s
✅ AudiobookDataManagerModelsTests 20 20 0 80ms
✅ AudiobookDataManagerNetworkSyncTests 5 5 0 5.14s
✅ AudiobookDataManagerSaveTests 4 4 0 23ms
✅ AudiobookDataManagerStoreRecoveryTests 5 5 0 2.06s
✅ AudiobookFileLoggerTests 14 14 0 304ms
✅ AudiobookLoadFailureSAMLReauthTests 10 10 0 909ms
✅ AudiobookLoaderDispatchTests 7 7 0 199ms
✅ AudiobookLoaderFinalizeBuildTests 9 9 0 220ms
✅ AudiobookLoaderOPDSShapeMatrixTests 6 6 0 164ms
✅ AudiobookLoaderPredicateTests 11 11 0 24ms
✅ AudiobookLoaderTests 2 2 0 320ms
✅ AudiobookNetworkValidationTests 3 3 0 5ms
✅ AudiobookOpenStateRaceTests 3 3 0 428ms
✅ AudiobookPhoneAlertContentTests 3 3 0 10ms
✅ AudiobookPlaybackStateTests 3 3 0 49ms
✅ AudiobookPlaybackTests 26 26 0 544ms
✅ AudiobookPositionAdapterContractTests 3 3 0 166ms
✅ AudiobookPositionPolicyValidatorTests 14 14 0 24ms
✅ AudiobookSAMLReauthTests 6 6 0 30ms
✅ AudiobookSessionErrorDescriptionTests 4 4 0 9ms
✅ AudiobookSessionErrorExtTests 4 4 0 13ms
✅ AudiobookSessionErrorTests 2 2 0 3ms
✅ AudiobookSessionManagerErrorMappingTests 6 6 0 18ms
✅ AudiobookSessionManagerShutdownTests 8 8 0 55ms
✅ AudiobookSessionStateTests 6 6 0 19ms
✅ AudiobookSessionStateTransitionTests 18 18 0 148ms
✅ AudiobookSleepTimerIntegrationTests 5 5 0 261ms
✅ AudiobookStorageLocationTests 3 3 0 21ms
✅ AudiobookTOCTests 18 18 0 259ms
✅ AudiobookTimeEntryTests 6 6 0 46ms
✅ AudiobookTimeTrackerEdgeTests 8 8 0 132ms
✅ AudiobookTimeTrackerLifecycleTests 5 5 0 1.18s
✅ AudiobookTimeTrackerTests 9 9 0 120ms
✅ AudiobookTrackCompletionTests 2 2 0 26ms
✅ AudiobookTypeRoutingTests 5 5 0 31ms
✅ AudiobookVendorAdapterTests 5 5 0 449ms
✅ AudiobookmarkTests 4 4 0 37ms
✅ AuthDocumentContractTests 2 2 0 6ms
✅ AuthDocumentVariantsContractTests 5 5 0 81ms
✅ AuthErrorCategoryTests 12 12 0 32ms
✅ AuthErrorProblemDocSeamTests 6 6 0 24ms
✅ AuthFlowSecurityTests 3 0 0 28ms
✅ AuthReducerTests 21 21 0 304ms
✅ AuthTypeTests 7 7 0 21ms
✅ AuthenticationTests 16 16 0 37ms
✅ BackgroundDownloadHandlerTests 28 28 0 186ms
✅ BackgroundListenerTests 2 2 0 39ms
✅ BackupExclusionMigrationTests 3 3 0 44ms
✅ BadgeDefinitionTests 33 33 0 74ms
✅ BadgeServiceTests 16 16 0 1.21s
✅ BadgesViewModelTests 14 14 0 137ms
✅ BasicAuthEmptyCredentialTests 4 4 0 29ms
✅ BearerTokenAdapterTests 5 4 0 46ms
✅ BearerTokenFulfillFlowTests 4 4 0 25ms
✅ BearerTokenRefreshTests 4 4 0 13ms
✅ BearerTokenResponseDetectionTests 7 7 0 23ms
✅ BeginningPositionPolicyTests 8 8 0 14ms
✅ BookAvailabilityFormatterTests 18 18 0 68ms
✅ BookButtonMapperHoldReadyTests 10 10 0 45ms
✅ BookButtonMapperTests 14 14 0 86ms
✅ BookButtonMapperViewModelTests 18 18 0 36ms
✅ BookButtonStateTests 8 8 0 34ms
✅ BookButtonTypeTests 13 13 0 58ms
✅ BookCellModelActionTests 18 18 0 656ms
✅ BookCellModelCacheInvalidationTests 8 8 0 127ms
✅ BookCellModelCachePrefetchSafetyTests 9 9 0 287ms
✅ BookCellModelCacheTests 22 22 0 414ms
✅ BookCellModelComputedPropertyTests 19 19 0 238ms
✅ BookCellModelOfflineTests 9 9 0 337ms
✅ BookCellModelRegistryBindingTests 4 4 0 294ms
✅ BookCellModelStateTests 16 16 0 221ms
✅ BookCellStateComprehensiveTests 14 14 0 24ms
✅ BookContentResetServiceTests 2 2 0 12ms
✅ BookDetailMetadataHydrationTests 6 6 0 46ms
✅ BookDetailViewModelTests 85 85 0 1.49s
✅ BookFileManagerTests 8 8 0 43ms
✅ BookListViewAccessibilityTests 9 9 0 37ms
✅ BookPreviewTests 4 4 0 19ms
✅ BookRegistryStoreTests 26 26 0 811ms
✅ BookRegistrySyncReadinessTests 3 2 0 297ms
✅ BookRegistrySyncTests 23 23 0 157ms
✅ BookReturnServiceContractTests 5 5 0 611ms
✅ BookReturnServiceTests 8 8 0 219ms
✅ BookSignInRedirectHandlerTests 8 8 0 245ms
✅ BookStateIntegrationTests 8 8 0 76ms
✅ BookmarkBusinessLogicExtendedTests 6 6 0 451ms
✅ BookmarkDeletionLogTests 3 3 0 246ms
✅ BookmarkDeviceIdMatchingTests 3 3 0 317ms
✅ BookmarkExistenceTests 4 4 0 316ms
✅ BookmarkManagerTests 24 24 0 278ms
✅ BookmarkSortingTests 1 1 0 110ms
✅ BookmarkSyncTests 3 3 0 609ms
✅ BorrowAndDownloadIntegrationTests 7 7 0 13.10s
✅ BorrowErrorMessageTests 13 13 0 30ms
✅ BorrowErrorPresenterTests 6 6 0 536ms
✅ BorrowOperationContractTests 6 6 0 661ms
✅ BorrowOperationTests 12 12 0 2.79s
✅ BorrowOperationTimeoutTests 3 3 0 118ms
✅ BorrowReducerContractTests 2 2 0 20ms
✅ BorrowReducerTests 21 21 0 47ms
✅ BundledRegistrySnapshotTests 5 5 0 625ms
✅ ButtonStateTests 16 16 0 137ms
✅ ButtonStyleTypeTests 2 2 0 161ms
✅ C64ConversionTests 6 6 0 25ms
✅ CarPlayAuthHelperReadinessTests 3 3 0 307ms
✅ CarPlayChapterListTests 3 3 0 15ms
✅ CarPlayIntegrationTests 2 2 0 9ms
✅ CarPlayLibraryRefreshTests 3 3 0 5ms
✅ CarPlayNowPlayingTemplateTests 4 4 0 525ms
✅ CarPlayOpenAppAlertTests 4 4 0 18ms
✅ CarPlayPlaybackErrorTests 8 8 0 34ms
✅ CarPlayTests 12 12 0 55ms
✅ CarPlayTimeTrackingTests 3 3 0 394ms
✅ CatalogAPIDedupeTests 3 3 0 524ms
✅ CatalogAPIEntryPointTests 1 1 0 4ms
✅ CatalogAccessibilityTests 8 8 0 25ms
✅ CatalogCacheKeyAndIsolationTests 12 12 0 576ms
✅ CatalogCacheMetadataExactBoundaryTests 4 4 0 14ms
✅ CatalogCacheMetadataTests 21 21 0 51ms
✅ CatalogFeedModelTests 4 4 0 9ms
✅ CatalogFilterGroupModelTests 17 17 0 67ms
✅ CatalogFilterModelTests 17 17 0 45ms
✅ CatalogFilterServiceTests 29 29 0 2.99s
✅ CatalogFilterTests 1 1 0 2ms
✅ CatalogLaneAssemblyTests 7 7 0 32ms
✅ CatalogLaneModelStructTests 18 18 0 686ms
✅ CatalogLaneModelTests 1 1 0 3ms
✅ CatalogLaneMoreFilterStateTests 8 8 0 25ms
✅ CatalogLaneMoreViewModelTests 42 42 0 744ms
✅ CatalogLaneRowViewAccessibilityTests 11 11 0 110ms
✅ CatalogLaneSortingTests 4 4 0 10.25s
✅ CatalogLoadIntegrationTests 6 6 0 370ms
✅ CatalogOPDS2NegotiationTests 12 12 0 423ms
✅ CatalogPreloaderTests 6 6 0 23ms
✅ CatalogProblemDocumentTests 6 6 0 15ms
✅ CatalogRepositoryCoreTests 9 9 0 363ms
✅ CatalogRepositoryStaleWhileRevalidateTests 12 12 0 385ms
✅ CatalogRepositoryTests 19 19 0 428ms
✅ CatalogSearchViewModelRegistryUpdateTests 5 5 0 118ms
✅ CatalogSearchViewModelTests 67 67 0 7.23s
✅ CatalogSelectorsTests 2 2 0 5ms
✅ CatalogSortServiceTests 14 14 0 125ms
✅ CatalogStateTests 7 7 0 30ms
✅ CatalogViewModelStateMachineTests 10 10 0 36ms
✅ ChaosFaultInjectionTests 5 5 0 78ms
✅ ChapterChangeDetectorTests 5 5 0 135ms
✅ ChapterTOCNormalizerTests 7 7 0 28ms
✅ CirculationAnalyticsTests 4 4 0 16ms
✅ ColdStartResumeIntegrationTests 10 10 0 1.05s
✅ ColorExtensionTests 5 5 0 9ms
✅ ConcurrentBookStateTests 3 3 0 26ms
✅ ConcurrentDownloadStateTests 3 3 0 25ms
✅ ConcurrentTokenRefreshTests 2 2 0 22ms
✅ ContinuousPlaybackTrackingTests 3 3 0 652ms
✅ CookiePersistenceTests 10 10 0 777ms
✅ CrawlStateTests 16 16 0 36ms
✅ CrawlableFeedAnalysisTests 17 17 0 40ms
✅ CrawlerFallbackTests 12 12 0 98ms
✅ CredentialEdgeCaseTests 6 6 0 23ms
✅ CredentialPrivacyTests 4 4 0 13ms
✅ CredentialPromptCoordinatorTests 4 4 0 408ms
✅ CrossDeviceBookmarkSyncTests 12 12 0 47ms
✅ CrossDeviceSyncE2ETests 5 5 0 605ms
✅ CrossDomain401Tests 8 8 0 30ms
✅ CrossFormatMappingTests 14 14 0 51ms
✅ DPLAErrorTests 3 3 0 17ms
✅ DRMAdversarialTests 4 1 0 43ms
✅ DRMFulfilledPublicationTests 6 6 0 16ms
✅ DataBase64Tests 3 3 0 8ms
✅ DataReceptionComparisonTests 2 2 0 47ms
✅ DateExtensionTests 9 9 0 33ms
✅ DateFormattingTests 4 4 0 18ms
✅ Date_NYPLAdditionsTests 7 7 0 2.51s
✅ DebugSettingsTests 27 27 0 157ms
✅ DefaultCatalogAPITests 31 31 0 276ms
✅ DeriveInitialStateTests 4 4 0 22ms
✅ DeviceLogCollectorGapTests 2 2 0 23.61s
✅ DeviceLogCollectorTests 9 9 0 2m 0s
✅ DeviceOrientationTests 7 7 0 20ms
✅ DeviceSpecificErrorMonitorTests 11 11 0 44ms
✅ DictionaryExtensionsTests 5 5 0 11ms
✅ DiskBudgetManagerTests 7 7 0 31ms
✅ DiskBudgetTests 2 2 0 6ms
✅ DownloadAlertPresenterTests 8 8 0 266ms
✅ DownloadAnnouncementServiceTests 12 12 0 674ms
✅ DownloadAuthRetryHandlerTests 11 11 0 1.24s
✅ DownloadCancellationHandlerTests 5 5 0 281ms
✅ DownloadCompletionParserTests 9 9 0 145ms
✅ DownloadCoordinatorIntegrationTests 10 10 0 58ms
✅ DownloadCoordinatorTests 11 11 0 36ms
✅ DownloadDiskSpaceTests 2 2 0 4ms
✅ DownloadErrorInfoTests 3 3 0 8ms
✅ DownloadErrorRecoveryPolicyTests 11 11 0 108ms
✅ DownloadErrorRecoveryTests 3 3 0 15ms
✅ DownloadFreeSpaceExhaustionTests 11 11 0 94ms
✅ DownloadInfoTests 5 5 0 18ms
✅ DownloadIntegrityTests 10 10 0 163ms
✅ DownloadOnlyOnWiFiTests 10 10 0 32ms
✅ DownloadPersistenceStoreTests 5 5 0 74ms
✅ DownloadProgressPublisherCoreTests 19 19 0 641ms
✅ DownloadProgressPublisherTests 2 2 0 8ms
✅ DownloadQueueIntegrationTests 3 3 0 1.49s
✅ DownloadQueueOrchestratorTests 9 9 0 217ms
✅ DownloadRMSDKHandoffTests 1 1 0 3ms
✅ DownloadRedirectTests 7 7 0 14ms
✅ DownloadResumeAfterKillTests 7 7 0 124ms
✅ DownloadSlotManagementTests 5 5 0 34ms
✅ DownloadStartCoordinatorContractTests 5 5 0 91ms
✅ DownloadStartCoordinatorTests 9 9 0 191ms
✅ DownloadStartDispatcherTests 14 14 0 170ms
✅ DownloadStateMachineIntegrationTests 15 15 0 81ms
✅ DownloadStateMachineTests 5 5 0 25ms
✅ DownloadStateManagerTests 16 16 0 133ms
✅ DownloadTaskLifecycleServiceTests 9 9 0 202ms
✅ DownloadThrottlingServiceTests 10 10 0 409ms
✅ DownloadWatchdogTests 3 3 0 15ms
✅ EPUBKeyCommandsPP4289Tests 4 4 0 36ms
✅ EPUBModuleTests 4 4 0 16ms
✅ EPUBPositionTests 8 8 0 23ms
✅ EPUBSearchViewModelTests 18 18 0 65ms
✅ EPUBToolbarToggleTests 11 11 0 38ms
✅ EmailAddressTests 16 16 0 143ms
✅ EpubSampleFactoryTests 5 5 0 32ms
✅ ErrorActivityTrackerTests 12 12 0 120ms
✅ ErrorDetailTests 12 12 0 76ms
✅ ErrorDetailViewControllerGapTests 3 3 0 174ms
✅ ErrorDetailViewControllerTests 14 14 0 86ms
✅ ErrorLogExporterTests 5 5 0 16.30s
✅ ExpiredLoanStringsTests 5 5 0 8ms
✅ FacetEnumTests 3 3 0 10ms
✅ FacetToolbarAccessibilityTests 5 5 0 10ms
✅ FacetViewModelLogoDelegateTests 4 4 0 28ms
✅ FacetViewModelTests 18 18 0 79ms
✅ FetchManifestWithBearerTokenLCPSafetyTests 1 1 0 11ms
✅ FetchManifestWithBearerTokenTests 9 9 0 87ms
✅ FetchOpenAccessManifestLCPSafetyTests 4 4 0 16ms
✅ FileURLGenerationTests 3 3 0 26ms
✅ FindawayChapterStatusGuardTests 1 1 0 3ms
✅ FloatTPPAdditionsTests 5 5 0 10ms
✅ FocusIndicationTests 7 7 0 31ms
✅ FontManagerTests 17 17 0 195ms
✅ ForceResetTests 6 6 0 29ms
✅ GeneralCacheTests 20 20 0 3.79s
✅ GroupEnumTests 1 1 0 8ms
✅ HTMLTextViewTests 70 70 0 22.99s
✅ HoldNotificationClassificationTests 2 2 0 5ms
✅ HoldsBadgeCountTests 9 9 0 89ms
✅ HoldsBookViewModelTests 8 8 0 36ms
✅ HoldsReducerTests 11 11 0 120ms
✅ HoldsSyncFailureTests 12 12 0 318ms
✅ HoldsViewModelTests 23 23 0 1.05s
✅ ImageCacheTypeTests 1 1 0 5ms
✅ ImageLoaderTests 11 11 0 82ms
✅ IntExtensionsTests 4 4 0 14ms
✅ KeyboardNavigationFKATests 11 11 0 38ms
✅ KeyboardNavigationHandlerTests 16 16 0 50ms
✅ KeyboardVoiceOverTests 5 5 0 73ms
✅ LCPAcquisitionPredicateTests 4 4 0 13ms
✅ LCPAdapterTests 8 8 0 148ms
✅ LCPAudiobookURLSchemeTests 4 4 0 8ms
✅ LCPAudiobooksTests 21 21 0 123ms
✅ LCPBotanCRLGuardTests 5 5 0 35ms
✅ LCPCharacterizationTests 31 31 0 136ms
✅ LCPClientTests 8 8 0 35ms
✅ LCPFulfillmentHandlerTests 8 8 0 732ms
✅ LCPLibraryServiceTests 20 20 0 498ms
✅ LCPLicenseDocumentDetectionTests 5 5 0 10ms
✅ LCPLicenseFilePathTests 3 3 0 6ms
✅ LCPOrphanedDownloadRegistryTests 4 4 0 96ms
✅ LCPPDFAcquisitionPredicateTests 5 5 0 60ms
✅ LCPPDFDiskExtractTests 5 5 0 57ms
✅ LCPPDFOpenProgressTests 13 13 0 91ms
✅ LCPPassphraseReadinessTests 2 2 0 8ms
✅ LCPSessionIdentifierTests 3 3 0 10ms
✅ LegacySAMLProblemDocumentPropagationTests 7 7 0 745ms
✅ LibraryCatalogMergerTests 9 9 0 23ms
✅ LibraryRegistryCrawlerTests 14 14 0 213ms
✅ LicensesServiceTests 4 4 0 19ms
✅ LiveCrawlableParsingTest 4 0 0 29ms
✅ LocalBookContentServiceTests 7 7 0 58ms
✅ LocalFileAdapterTests 6 5 0 44ms
✅ LogTests 14 14 0 359ms
✅ LoginKeyboardTests 8 8 0 115ms
✅ MainActorHelpersTests 22 22 0 696ms
✅ MappedCatalogBridgeTests 3 3 0 41ms
✅ MappedCatalogModelTests 11 11 0 438ms
✅ MockBackendExpiredCredentialsTests 3 3 0 64ms
✅ MockBackendIntegrationTests 4 4 0 64ms
✅ MockBackendLoanLimitTests 2 2 0 16ms
✅ MockBackendRouteMatchingTests 4 4 0 11ms
✅ MockBackendServerDownTests 1 1 0 23ms
✅ MockIsolationLintTests 3 3 0 24ms
✅ MultiLibraryTokenIsolationTests 14 14 0 153ms
✅ MyBooksDownloadCenterAdeptGapTests 3 3 0 13ms
✅ MyBooksDownloadCenterConcurrencyTests 21 21 0 368ms
✅ MyBooksDownloadCenterEvictionTests 7 7 0 250ms
✅ MyBooksDownloadCenterOfflineTests 8 8 0 607ms
✅ MyBooksDownloadSessionInvalidationTests 3 3 0 18ms
✅ MyBooksSimplifiedBearerTokenTests 17 17 0 90ms
✅ MyBooksViewModelBooksPublisherTests 3 3 0 42ms
✅ MyBooksViewModelConcurrencyTests 4 4 0 38ms
✅ MyBooksViewModelDownloadStateTests 3 3 0 25ms
✅ MyBooksViewModelEmptyArrayTests 3 3 0 9ms
✅ MyBooksViewModelEmptyStateTests 4 4 0 70ms
✅ MyBooksViewModelExtendedTests 15 15 0 87ms
✅ MyBooksViewModelFacetIntegrationTests 4 4 0 35ms
✅ MyBooksViewModelFacetPublisherTests 3 3 0 9ms
✅ MyBooksViewModelFilterSortInteractionTests 2 2 0 22ms
✅ MyBooksViewModelFilterTests 9 9 0 75ms
✅ MyBooksViewModelGuardConditionsTests 2 2 0 16ms
✅ MyBooksViewModelLargeDatasetTests 2 2 0 494ms
✅ MyBooksViewModelLoadAccountTests 2 2 0 200ms
✅ MyBooksViewModelLoginStateTests 4 4 0 350ms
✅ MyBooksViewModelMultipleAuthorSortingTests 3 3 0 29ms
✅ MyBooksViewModelNotificationTests 4 4 0 322ms
✅ MyBooksViewModelOfflineFilteringTests 3 3 0 38ms
✅ MyBooksViewModelPublisherTests 7 7 0 31ms
✅ MyBooksViewModelSearchEdgeCaseTests 6 6 0 68ms
✅ MyBooksViewModelSearchQueryTests 3 3 0 26ms
✅ MyBooksViewModelSortPersistenceTests 3 3 0 123ms
✅ MyBooksViewModelSortingIntegrationTests 5 5 0 30ms
✅ MyBooksViewModelSortingTests 6 6 0 65ms
✅ MyBooksViewModelStateTransitionTests 3 3 0 382ms
✅ MyBooksViewModelUIBindingTests 3 3 0 13ms
✅ NSErrorAdditionsTests 7 7 0 17ms
✅ NSNotificationTPPTests 3 3 0 8ms
✅ NavigationCoordinatorTests 17 17 0 47ms
✅ NavigationFreezePreventionTests 5 5 0 24ms
✅ NetworkExecutorCredentialGuardTests 8 8 0 66ms
✅ NetworkExecutorResponseRegressionTests 4 4 0 100ms
✅ NetworkExecutorTaskTypeTests 3 3 0 45ms
✅ NetworkOfflineDetectionTests 3 3 0 6ms
✅ NetworkQueueTests 11 11 0 833ms
✅ NetworkRequestQueueTests 2 2 0 10.12s
✅ NetworkRetryLogicTests 7 7 0 36ms
✅ NetworkTimeoutTests 2 2 0 5ms
✅ NotificationEventTypeContractTests 7 7 0 20ms
✅ NotificationPayloadContractTests 10 10 0 62ms
✅ NotificationServiceStateMachineTests 9 9 0 1.51s
✅ NotificationServiceTests 16 16 0 44ms
✅ NotificationServiceTokenTests 13 13 0 97ms
✅ NotificationSyncThrottleTests 5 5 0 17ms
✅ NotificationTokenDataTests 4 4 0 7ms
✅ NotificationTokenRegistrationTests 10 10 0 17ms
✅ NowPlayingCoordinatorBackgroundTests 6 6 0 780ms
✅ NowPlayingCoordinatorTests 19 19 0 197ms
✅ OAuthSAMLRedirectRegressionTests 4 4 0 587ms
✅ OIDCAuthDocumentParsingTests 4 4 0 2.13s
✅ OIDCAuthTypeTests 5 5 0 10ms
✅ OIDCAuthenticationPropertyTests 8 8 0 982ms
✅ OIDCCallbackEdgeCaseTests 9 9 0 1.17s
✅ OIDCCallbackHandlingTests 5 5 0 489ms
✅ OIDCCallbackSchemeTests 3 3 0 9ms
✅ OIDCIsolationRegressionTests 6 6 0 661ms
✅ OIDCLoginRoutingTests 3 3 0 667ms
✅ OIDCMakeRequestTests 3 3 0 260ms
✅ OIDCNSCodingTests 1 1 0 96ms
✅ OIDCNetworkLayer401Tests 5 5 0 423ms
✅ OIDCReauthOnExpiredTokenTests 5 5 0 505ms
✅ OIDCRedirectURIConstructionTests 6 6 0 625ms
✅ OIDCRegressionTests 9 9 0 729ms
✅ OIDCSelectedAuthenticationTests 2 2 0 216ms
✅ OIDCSignOutRegressionTests 6 6 0 1.33s
✅ OIDCTokenRefreshRegressionTests 6 6 0 634ms
✅ OIDCUpdateUserAccountTests 5 5 0 465ms
✅ OIDCViewModelRegressionTests 1 1 0 84ms
✅ OIDCViewModelSignInTests 2 2 0 58ms
✅ OPDS1BorrowEntryContractTests 4 4 0 29ms
✅ OPDS1CatalogGroupedContractTests 3 3 0 20ms
✅ OPDS1HoldEntriesContractTests 4 4 0 43ms
✅ OPDS1LoansFeedContractTests 6 6 0 92ms
✅ OPDS1ParsingTests 34 34 0 213ms
✅ OPDS1RevokeResponseContractTests 2 2 0 17ms
✅ OPDS2AuthenticationDocumentTests 18 18 0 78ms
✅ OPDS2AvailabilityTests 4 4 0 13ms
✅ OPDS2BookBridgeTests 44 44 0 802ms
✅ OPDS2BorrowResponseContractTests 3 3 0 9ms
✅ OPDS2CatalogWiringTests 17 17 0 121ms
✅ OPDS2CatalogsFeedTests 3 3 0 221ms
✅ OPDS2ContributorTests 2 2 0 9ms
✅ OPDS2EmptyFeedContractTests 1 1 0 22ms
✅ OPDS2FeedContractTests 4 4 0 35ms
✅ OPDS2FeedParsingTests 11 11 0 364ms
✅ OPDS2FeedTests 13 13 0 38ms
✅ OPDS2FullMetadataTests 4 4 0 65ms
✅ OPDS2FullPublicationTests 13 13 0 132ms
✅ OPDS2IntegrationTests 18 18 0 175ms
✅ OPDS2LinkArrayTests 5 5 0 16ms
✅ OPDS2LinkComputedPropertyTests 20 20 0 43ms
✅ OPDS2LinkRelTests 1 1 0 2ms
✅ OPDS2LinkTests 2 2 0 226ms
✅ OPDS2ParsingTests 38 38 0 162ms
✅ OPDS2PublicationExtendedTests 46 46 0 265ms
✅ OPDS2PublicationImageTests 6 6 0 16ms
✅ OPDS2PublicationNarratorTests 3 3 0 30ms
✅ OPDS2PublicationTests 2 2 0 220ms
✅ OPDS2SamlIDPTests 6 6 0 11ms
✅ OPDS2SearchResultsContractTests 3 3 0 24ms
✅ OPDS2SubjectTests 2 2 0 10ms
✅ OPDS2SupportingTypesTests 5 5 0 34ms
✅ OPDSAcquisitionPathExpandedTests 15 15 0 679ms
✅ OPDSFeedCacheTests 14 14 0 37ms
✅ OPDSFeedMigrationTests 11 11 0 80ms
✅ OPDSFeedParsingTests 2 2 0 190ms
✅ OPDSFeedServiceStateMachineTests 3 3 0 784ms
✅ OPDSFeedServiceTests 2 2 0 6ms
✅ OPDSFormatTests 13 13 0 46ms
✅ OPDSParserCoreTests 4 4 0 10ms
✅ OPDSParserTests 4 4 0 8ms
✅ OPDSParsingTests 54 54 0 2.21s
✅ OfflineActionTests 29 29 0 76ms
✅ OfflineQueueServiceExtendedTests 13 13 0 5.24s
✅ OfflineQueueServiceTests 17 17 0 7.03s
✅ OpenAccessAdapterTests 6 6 0 79ms
✅ OverdriveDeferredFulfillmentTests 6 6 0 19ms
✅ OverdriveDownloadHandlerTests 9 9 0 288ms
✅ OverdriveFulfillmentTests 5 5 0 65ms
✅ PDFExtensionsTests 20 20 0 38ms
✅ PDFReaderTests 12 12 0 35ms
✅ PP3596RegressionTests 3 3 0 73ms
✅ Palace 2 2 0 <1ms
✅ PalaceCheckPropertyTests 8 8 0 306ms
✅ PalaceErrorCategoryTests 20 20 0 65ms
✅ PalaceErrorExtendedTests 23 23 0 129ms
✅ PalaceErrorTests 11 11 0 71ms
✅ ParserFuzzTests 4 4 0 35.20s
✅ PatronProfileContractTests 4 4 0 13ms
✅ PerformanceMonitorTests 14 14 0 222ms
✅ PerformanceReportTests 14 14 0 65ms
✅ PersistentLoggerTests 9 9 0 2.22s
✅ PlaybackBootstrapperTests 8 8 0 60ms
✅ PlaybackFailureRecordTests 5 5 0 19ms
✅ PlaybackRateTests 16 16 0 43ms
✅ PlaybackTrackingRegressionTests 5 5 0 108ms
✅ PositionPersistenceLogicTests 6 6 0 10ms
✅ PositionPersistenceTests 2 2 0 7ms
✅ PositionSyncServiceTests 13 13 0 191ms
✅ PositionSyncTests 5 5 0 12ms
✅ PositionWriterContractTests 6 6 0 95ms
✅ PostUpdateMigrationTests 5 5 0 48ms
✅ ProblemDocumentContractTests 4 4 0 10ms
✅ ProblemDocumentLoanExpiryTests 5 5 0 9ms
✅ ProblemDocumentTests 12 12 0 43ms
✅ ProblemReportEmailTests 8 8 0 28ms
✅ ReachabilityTests 2 2 0 6ms
✅ Reader2PositionAdapterContractTests 4 3 0 315ms
✅ ReaderAccessibilityTests 7 7 0 11ms
✅ ReaderErrorTests 5 5 0 15ms
✅ ReaderNavBarVoiceOverTests 2 2 0 10ms
✅ ReaderServiceSyncTests 3 3 0 14ms
✅ ReaderThemeTests 24 24 0 357ms
✅ ReadingPositionTests 22 22 0 61ms
✅ ReadingSessionTrackerTests 13 13 0 145ms
✅ ReadingStatsServiceTests 12 12 0 209ms
✅ ReadingStatsStoreTests 9 9 0 102ms
✅ RedirectHandlingIntegrationTests 4 4 0 89ms
✅ RedirectPolicyTests 9 9 0 85ms
✅ RemoteFeatureFlagsGapTests 4 4 0 51ms
✅ RemoteFeatureFlagsTests 13 13 0 41ms
✅ RetryClassificationTests 17 17 0 61ms
✅ ReturnFlowTests 1 1 0 6ms
✅ RightsManagementDetectionTests 5 5 0 17ms
✅ RightsManagementDispatcherTests 10 10 0 124ms
✅ SAMLCookieSyncTests 5 5 0 13ms
✅ SAMLLogoutCallbackDetectionTests 4 4 0 9ms
✅ SAMLLogoutLinkParsingTests 5 5 0 107ms
✅ SAMLLogoutURLTests 4 4 0 19ms
✅ SAMLPlusBiblioBoardExpirationTests 8 8 0 7.64s
✅ SEMigrationsTests 6 6 0 138ms
✅ SafeDictionaryTests 21 21 0 259ms
✅ SamplePlayerErrorTests 5 5 0 21ms
✅ SampleTypeTests 8 8 0 41ms
✅ SceneDelegateTests 1 1 0 3ms
✅ SearchAccessibilityTests 6 6 0 10ms
✅ SearchFlowIntegrationTests 8 8 0 78ms
✅ SettingsViewModelComputedPropertyTests 6 6 0 158ms
✅ SettingsViewModelEdgeCaseTests 7 7 0 48ms
✅ SettingsViewModelGapTests 1 1 0 3ms
✅ SettingsViewModelSyncTests 14 14 0 37ms
✅ SettingsViewModelTests 33 33 0 360ms
✅ SignInModalPredicateTests 7 7 0 14ms
✅ SignInModalSAMLOIDCTests 6 6 0 29ms
✅ SignInOAuthErrorPropagationTests 8 8 0 853ms
✅ SignInToReadFlowIntegrationTests 5 5 0 7.46s
✅ SignInWebSheetIntegrationTests 3 3 0 2.35s
✅ SignInWebSheetViewModelTests 31 31 0 81ms
✅ SignOutCacheClearingTests 3 3 0 10ms
✅ StatsViewModelTests 10 10 0 265ms
✅ StatusAnnouncementTests 22 22 0 95ms
✅ StopPositionSaveTests 2 2 0 4ms
✅ StoreTests 5 5 0 22ms
✅ StringExtensionTests 8 8 0 17ms
✅ StringExtensionsTests 3 3 0 566ms
✅ StringHTMLEntitiesTests 7 7 0 13ms
✅ StringNYPLAdditionsTests 4 4 0 31ms
✅ String_NYPLAdditionsTests 4 4 0 17ms
✅ SyncConflictResolutionTests 3 3 0 9ms
✅ SyncDeletionGuardTests 5 5 0 9ms
✅ SyncDeletionRatioTests 6 6 0 20ms
✅ SyncPermissionTests 5 5 0 329ms
✅ TPPAccountAuthStateEnumTests 5 5 0 12ms
✅ TPPAccountListDataSourceTests 3 3 0 7ms
✅ TPPAdobeActivationSkipTests 6 6 0 635ms
✅ TPPAgeCheckCompletionTests 5 5 0 428ms
✅ TPPAgeCheckIsValidTests 5 5 0 22ms
✅ TPPAgeCheckStateMachineTests 3 3 0 723ms
✅ TPPAgeCheckTests 6 6 0 1.70s
✅ TPPAgeCheckVerifyDecisionTests 5 5 0 416ms
✅ TPPAlertUtilsTests 45 45 0 729ms
✅ TPPAnnotationsHermeticTests 15 15 0 69ms
✅ TPPAnnotationsOverrideTests 4 4 0 480ms
✅ TPPAnnotationsTests 29 29 0 5.46s
✅ TPPAnnouncementManagerTests 3 3 0 9ms
✅ TPPAuthDocumentContractTests 3 3 0 54ms
✅ TPPBackgroundExecutorTests 3 3 0 19ms
✅ TPPBadgeImageGapTests 2 2 0 21ms
✅ TPPBaseReaderViewControllerInitialLocationTests 5 5 0 38ms
✅ TPPBasicAuthTests 11 11 0 23ms
✅ TPPBookAccessibilityLabelTests 8 8 0 37ms
✅ TPPBookAuthorCoverageTests 3 3 0 5ms
✅ TPPBookAuthorTests 6 6 0 12ms
✅ TPPBookBearerTokenTests 9 8 0 90ms
✅ TPPBookButtonsStateTests 7 7 0 35ms
✅ TPPBookContentMetadataFilesHelperTests 9 9 0 18ms
✅ TPPBookContentTypeConverterTests 4 4 0 9ms
✅ TPPBookContentTypeExtendedTests 4 4 0 8ms
✅ TPPBookContentTypeTests 14 14 0 60ms
✅ TPPBookCoverRegistryTests 14 14 0 621ms
✅ TPPBookCreationTests 7 7 0 147ms
✅ TPPBookExtensionsTests 21 21 0 124ms
✅ TPPBookLocationCoverageTests 7 7 0 24ms
✅ TPPBookLocationEdgeCaseTests 27 27 0 83ms
✅ TPPBookLocationKeyTests 3 3 0 10ms
✅ TPPBookLocationTests 11 11 0 19ms
✅ TPPBookModelGapTests 4 4 0 37ms
✅ TPPBookRegistryAsyncReadinessTests 3 3 0 441ms
✅ TPPBookRegistryAtomicWriteTests 7 7 0 6.03s
✅ TPPBookRegistryBookRetrievalTests 7 7 0 44ms
✅ TPPBookRegistryBookmarkTests 7 7 0 81ms
✅ TPPBookRegistryCorruptedDataTests 5 5 0 70ms
✅ TPPBookRegistryDataTests 4 4 0 13ms
✅ TPPBookRegistryDependencyTests 4 4 0 1.82s
✅ TPPBookRegistryFulfillmentIdTests 4 4 0 21ms
✅ TPPBookRegistryLargeCorpusTests 5 5 0 2m 3s
✅ TPPBookRegistryLoadReentrancyTests 2 2 0 5ms
✅ TPPBookRegistryLocationTests 4 4 0 36ms
✅ TPPBookRegistryMigrationTests 16 16 0 14.59s
✅ TPPBookRegistryPersistenceTests 10 10 0 13.54s
✅ TPPBookRegistryProcessingTests 2 2 0 4ms
✅ TPPBookRegistryPublisherTests 6 6 0 59ms
✅ TPPBookRegistryRecordPersistenceTests 3 3 0 26ms
✅ TPPBookRegistryRecordTests 10 10 0 39ms
✅ TPPBookRegistryStateManagementTests 11 11 0 47ms
✅ TPPBookRegistryThreadSafetyTests 3 3 0 295ms
✅ TPPBookRegistryUpdateAndRemoveTests 1 1 0 40ms
✅ TPPBookRequiresAdobeDRMTests 6 6 0 32ms
✅ TPPBookSerializationTests 13 13 0 57ms
✅ TPPBookStateInitializationTests 4 4 0 9ms
✅ TPPBookStateTests 4 4 0 12ms
✅ TPPBookTests 83 83 0 394ms
✅ TPPBookmarkDeletionLogTests 11 11 0 128ms
✅ TPPBookmarkFactoryInitTests 2 2 0 9ms
✅ TPPBookmarkFactoryServerAnnotationEdgeCaseTests 5 5 0 20ms
✅ TPPBookmarkFactoryTests 15 15 0 112ms
✅ TPPBookmarkR3ConversionTests 5 5 0 26ms
✅ TPPBookmarkR3LocationTests 13 13 0 24ms
✅ TPPBookmarkSpecTests 1 1 0 6ms
✅ TPPCachingTests 3 3 0 8ms
✅ TPPCapturedCredentialsTests 5 5 0 420ms
✅ TPPConfigurationTests 22 22 0 111ms
✅ TPPContentTypeTests 7 7 0 17ms
✅ TPPCredentialConcurrencyTests 3 3 0 8ms
✅ TPPCredentialIsolationE2ETests 5 0 0 192ms
✅ TPPCredentialPersistenceTests 6 6 0 685ms
✅ TPPCredentialSnapshotCoherenceTests 3 0 0 184ms
✅ TPPCredentialSnapshotTests 8 8 0 18ms
✅ TPPCredentialsCoverageTests 9 9 0 18ms
✅ TPPCredentialsTests 26 26 0 151ms
✅ TPPCrossLibrarySignOutTests 6 6 0 716ms
✅ TPPDRMFailureCredentialPreservationTests 4 4 0 381ms
✅ TPPErrorLoggerTests 27 27 0 459ms
✅ TPPIdleSignOutRegressionTests 13 13 0 1.44s
✅ TPPJWKConversionTest 1 1 0 13ms
✅ TPPKeychainManagerTests 5 5 0 29ms
✅ TPPLastReadPositionPosterTests 13 13 0 434ms
✅ TPPLastReadPositionSynchronizerIntegrationTests 5 5 0 30ms
✅ TPPLastReadPositionSynchronizerTests 23 23 0 79ms
✅ TPPLastReadPositionSynchronizer_BehaviorDocumentationTests 5 5 0 15ms
✅ TPPLastReadPositionSynchronizer_BookLocationTests 9 9 0 21ms
✅ TPPLastReadPositionSynchronizer_ConcurrencyTests 3 3 0 9ms
✅ TPPLastReadPositionSynchronizer_ReadiumBookmarkTests 9 9 0 40ms
✅ TPPLastReadPositionSynchronizer_SyncLogicTests 10 10 0 58ms
✅ TPPLastReadPositionSynchronizer_WriterDelegationTests 4 4 0 24ms
✅ TPPLoginNoActivationTests 3 3 0 430ms
✅ TPPMainThreadCheckerTests 4 4 0 10ms
✅ TPPMigrationManagerTests 15 15 0 63ms
✅ TPPNetworkExecutorAPITests 14 14 0 137ms
✅ TPPNetworkExecutorStubbedTests 17 17 0 290ms
✅ TPPNetworkExecutorTests 3 3 0 17ms
✅ TPPNetworkResponderTests 12 12 0 47ms
✅ TPPOPDSAcquisitionPathTests 2 2 0 19ms
✅ TPPOPDSEntryTests 5 5 0 50ms
✅ TPPOPDSFeedTests 3 3 0 203ms
✅ TPPOPDSGroupSwiftTests 3 3 0 6ms
✅ TPPOPDSLinkTests 7 7 0 24ms
✅ TPPOpenSearchDescriptionExpandedTests 10 10 0 30ms
✅ TPPOpenSearchDescriptionTests 1 1 0 3ms
✅ TPPPDFDocumentMetadataTests 15 15 0 91ms
✅ TPPPDFDocumentTests 8 8 0 32ms
✅ TPPPDFLocationCoverageTests 7 7 0 23ms
✅ TPPPDFLocationTests 10 10 0 35ms
✅ TPPPDFPageBookmarkTests 9 9 0 52ms
✅ TPPPDFPageTests 5 5 0 13ms
✅ TPPPDFReaderModeTests 6 6 0 10ms
✅ TPPPerAccountIsolationTests 8 0 0 367ms
✅ TPPPreferredAuthSelectionTests 8 8 0 669ms
✅ TPPProblemDocumentCacheManagerTests 12 12 0 101ms
✅ TPPProblemDocumentTests 21 21 0 104ms
✅ TPPReaderAppearanceTests 4 4 0 14ms
✅ TPPReaderBookmarksBusinessLogicTests 12 12 0 1.28s
✅ TPPReaderBookmarksReadinessTests 2 2 0 237ms
✅ TPPReaderFontTests 4 4 0 7ms
✅ TPPReaderPreferencesLoadTests 3 3 0 27ms
✅ TPPReaderSettingsTests 28 28 0 114ms
✅ TPPReaderTOCBusinessLogicTests 15 15 0 3.07s
✅ TPPReaderTOCFlattenTests 2 2 0 1.01s
✅ TPPReadiumBookmarkLocationMatchingTests 5 5 0 12ms
✅ TPPReadiumBookmarkTests 21 21 0 54ms
✅ TPPReauthenticatorMockTests 2 2 0 5ms
✅ TPPReauthenticatorTests 4 4 0 11ms
✅ TPPReturnPromptHelperTests 5 5 0 18ms
✅ TPPSAMLCookieExpirationTests 5 5 0 81ms
✅ TPPSAMLFlowTests 10 10 0 45ms
✅ TPPSAMLReauthFlowTests 2 2 0 303ms
✅ TPPSAMLRegressionTests 4 4 0 334ms
✅ TPPSAMLSignInTests 26 26 0 2.27s
✅ TPPSAMLStateIsolationTests 4 4 0 265ms
✅ TPPSAMLStateMachineTests 6 6 0 441ms
✅ TPPSettingsTests 6 6 0 40ms
✅ TPPSignInAdobeSkipTests 14 14 0 1.39s
✅ TPPSignInAuthStateTransitionTests 3 3 0 271ms
✅ TPPSignInBusinessLogicExtendedTests 58 58 0 7.26s
✅ TPPSignInBusinessLogicOAuthTests 11 11 0 893ms
✅ TPPSignInBusinessLogicSignOutTests 11 11 0 1.16s
✅ TPPSignInBusinessLogicStateMachineTests 9 9 0 1.65s
✅ TPPSignInBusinessLogicTests 15 15 0 1.52s
✅ TPPSignInBusinessLogicTokenFlowTests 3 3 0 337ms
✅ TPPSignInBusinessLogicValidationCallbackOrderTests 2 2 0 340ms
✅ TPPSignInErrorHandlingTests 2 2 0 229ms
✅ TPPSignInProfileDocEdgeCaseTests 3 3 0 383ms
✅ TPPSignedInStateProviderTests 3 3 0 6ms
✅ TPPUserAccountAuthStateTests 6 6 0 22ms
✅ TPPUserAccountGapTests 4 4 0 13ms
✅ TPPUserFriendlyErrorTests 11 11 0 22ms
✅ TPPUserNotificationsTests 10 10 0 91ms
✅ TPPXMLSwiftTests 16 16 0 61ms
✅ TPPXMLTests 3 3 0 9ms
✅ TimeEntryTests 3 3 0 8ms
✅ TokenRefreshAndRetryQueueTests 9 9 0 6.22s
✅ TokenRefreshIntegrationTests 2 2 0 180ms
✅ TokenRefreshInterceptorTests 22 22 0 5.66s
✅ TokenRefreshOnForegroundTests 10 10 0 1.34s
✅ TokenRefreshTests 25 25 0 127ms
✅ TokenRequestCredentialGuardTests 13 13 0 113ms
✅ TokenRequestTests 11 11 0 40ms
✅ TokenResponseTests 21 21 0 50ms
✅ TypographyPresetTests 21 21 0 171ms
✅ TypographyServiceTests 31 31 0 1.14s
✅ TypographySettingsViewModelTests 27 27 0 536ms
✅ UIAlertCACommitGuardTests 8 8 0 242ms
✅ UIColor_NYPLAdditionsTests 1 1 0 4ms
✅ URLBackupExclusionTests 3 3 0 12ms
✅ URLExtensionTests 16 16 0 36ms
✅ URLExtensionsTests 6 6 0 18ms
✅ URLRequestExtensionsCoverageTests 3 3 0 9ms
✅ URLRequestExtensionsTests 11 11 0 38ms
✅ URLRequestNYPLAdditionsTests 11 11 0 85ms
✅ URLRequest_NYPLTests 1 1 0 2ms
✅ URLResponseAuthenticationTests 10 10 0 25ms
✅ URLResponseNYPLTests 14 14 0 28ms
✅ URLSessionCredentialStorageTests 3 3 0 6ms
✅ URLTypeTests 2 2 0 18ms
✅ URLValidationTests 5 5 0 9ms
✅ UnifiedOPDSServiceStateMachineTests 2 2 0 340ms
✅ UserAccountPublisherAuthStateTests 5 5 0 27ms
✅ UserAccountPublisherTests 11 11 0 799ms
✅ UserAccountValidationTests 11 11 0 1.47s
✅ UserProfileDocumentTests 7 7 0 56ms
✅ UserRetryTrackerTests 10 10 0 39ms
✅ iPadOnMacRMSDKGuardTests 1 1 0 2ms

📊 Testing Coverage Breakdown

Unit Test Line Coverage (testable surfaces): 46.7%

Total coverage incl. UI/lifecycle: 44.9% (17 files excluded from testable denominator — see scripts/coverage-exclude.json)

Target Lines Covered
Palace.app 44.9%

Why two coverage numbers? Testable coverage subtracts files that can't be exercised from xcodebuild — SwiftUI views, UIKit VCs, lifecycle (see scripts/coverage-exclude.json) — so raising it means more testable logic is tested, not that we shipped less UI. Total coverage is kept for continuity. The excluded paths are covered by simdrive E2E journeys (see chaos-replay-on-pr.yml).

📈 Trends

Test count change: -5


🔗 Interactive HTML Report | CI Run Details

📦 Downloadable Artifacts
Artifact Description
test-report 📄 Markdown + HTML reports
test-data 📊 JSON data for tooling
test-results 🔍 Full xcresult (open in Xcode)

@mauricecarrier7 mauricecarrier7 self-assigned this May 27, 2026
@mauricecarrier7
Copy link
Copy Markdown
Contributor Author

Live sim repro captured. Power Rangers Unlimited: Edge of Darkness on A1QA Test Library reproduces the bug deterministically.

Action Result
Borrow + Download Works (file lands on disk)
Tap Read ❌ "Unable to load PDF file"

Palace error log at failure (from xcrun simctl spawn ... log show):

Error: access(ReadiumShared.AccessError.fileSystem(
              ReadiumShared.FileSystemError.fileNotFound(nil)))
LOG_ERROR: Error Domain=Error reading PDF path
  currentAccountName=A1QA Test Library
  NSUnderlyingError=ReadiumShared.ReadError

That's LCPPDFs.getPdfHref() failing because publicationOpener.open() is invoked on a file that landed with the wrong extension (.epub instead of .zip) — exactly the path closed by this PR's recursive hasLCPAcquisition predicate + BookFileManager.pathExtension swap.

Critical cross-check confirming the bug is acquisition-shape-specific: Power Rangers #2 (a different LCP comic in the same A1QA library) opens fine with full PDF reader chrome ("Page 1 of 28"). Same LCP system, same library, same reader code path — only the OPDS acquisition shape differs. That's the deterministic signature of an OPDS-layer predicate bug, exactly mirroring how PP-4407 manifested for audiobooks.

Evidence bundle on local desktop (~/Desktop/PP-4454/):

  • 07-BUG-power-rangers-unlimited-edge-of-darkness-FAILS.png
  • 06-PR-power-rangers-2-OPENS-FINE.png (cross-check)
  • 08-palace-logs-bug-repro.txt

🤖 Generated with Claude Code

mauricecarrier7 and others added 2 commits May 26, 2026 22:51
Cherry-picked from feature/readium-pdf-navigator-migration commit 9d042ab
(orphan branch, never merged). Adapted to current develop (~373 commits of
drift): the migration's `.shared` singletons are translated to AppContainer
DI, `MyBooksDownloadCenter.fulfillLCPLicense` keeps its Phase 7
`LCPFulfillmentHandler` extraction, and the no-op-after-migration PDF
extract pass is dropped from `LCPFulfillmentHandler`.

ROOT CAUSE this addresses (PP-4454, validated against the David Wilcox /
Mark Raynsford / Jonathan Green Slack thread today):

The home-grown LCP-PDF read flow on develop never worked for real LCP-
protected PDFs:

  download LCP zip → try manifest.json lookup → extract inner PDF to temp
  dir → PDFKit mmap the temp file

PROBLEMS:
  - The manifest lookup throws fileNotFound on every real Palace
    Marketplace archive (`LCPPDFs.getPdfHref` → ReadiumShared.AccessError
    .fileSystem.fileNotFound), so the temp extract silently failed via
    `_ = try? await LCPPDFs(url:)?.extract(...)` and the user saw "Unable
    to load PDF file."
  - Even when extraction worked, the inner PDF bytes were still LCP-
    encrypted — Archive.extract just unzips raw bytes with no decryption.
    PDFKit then saw encrypted noise and refused to render.
  - Temp + archive + PDFKit mapping meant ~3× disk footprint — on large
    LCP textbooks iOS would purge the temp extract under memory pressure.

Confirmed live on iPhone 16 Pro sim against A1QA Test Library:
  - Power Rangers Unlimited: Edge of Darkness (LCP-wrapped, 829MB) →
    "Unable to load PDF file" + matching log
    (ReadiumShared.AccessError.fileSystem.fileNotFound from
    LCPPDFs.getPdfHref)
  - Power Rangers #2 (open-access PDF, no LCP) → opens fine
  - Bug is acquisition-shape-specific: only LCP-wrapped PDFs fail.

FIX: Adopt Readium 3's `PDFNavigatorViewController`, which streams
decrypted pages on demand via the shared `GCDHTTPServer` that the EPUB
reader already uses. Decryption happens inside Readium's resource layer
through the LCP content protection; no temp extract, no duplicated
bytes, no manifest-lookup dance.

Changes (after adaptation):

Added
  - Palace/PDF/ReadiumPDF/ReadiumPDFViewController.swift — UIKit
    container around PDFNavigatorViewController with a legacy-page
    restore shim that reads the 0-indexed pageNumber from
    TPPBookRegistry and resolves it to a Locator via
    positionsByReadingOrder, so existing users don't lose their place.
  - Palace/PDF/ReadiumPDF/ReadiumPDFReaderView.swift — SwiftUI host
    that bridges Readium's locationDidChange(locator:) back into the
    existing TPPPDFDocumentMetadata.currentPage contract so bookmarks,
    reading-position sync, and TOC UI keep working unchanged.
  - ReaderService.openPDF(_:) — entry point that calls into
    TPPR3Owner.libraryService.openBook(...) and pushes the Readium PDF
    route onto the NavigationCoordinator (parallel to openEPUB).
  - NavigationCoordinator.storeReadiumPDF / resolveReadiumPDF — route
    storage for Readium-backed PDF publications.
  - NavigationHostView .pdf case — branches on
    coordinator.resolveReadiumPDF (Readium path) before falling back to
    the legacy TPPPDFReaderView (open-access PDFs).

Removed (now dead under the new pipeline)
  - LCPPDFs.swift home-grown machinery: extract(url:),
    temporaryUrlForPDF(url:), deletePdfContent(url:), getPdfHref(),
    decryptData/decryptRawData/dataCache, the publicationOpener init.
    LCPPDFs.swift is now a 75-line shell with two static predicates:
    canOpenBook (legacy /loans/ XML shape) and hasLCPAcquisition
    (recursive walker for Marketplace /groups/ JSON shape — PP-4454
    initial scope, retained for defense in depth even though the
    extension-routing it fixes is now less load-bearing under the
    Readium pipeline).
  - TPPEncryptedPDFDataProvider, TPPEncryptedPDFDocument,
    TPPEncryptedPDFView, TPPEncryptedPDFViewController,
    TPPEncryptedPDFViewer, TPPEncryptedPDFPageViewController,
    TPPPDFViewController — the CGPDF-based encrypted-page-by-page
    rendering path. Unused after PDFNavigator takes over.
  - LCPFulfillmentHandler PDF extract pass — Readium decrypts on
    demand, no eager extract needed.
  - LocalBookContentService LCPPDFs.deletePdfContent call — no temp
    file to clean up post-migration.

Modified
  - BookService.presentPDF and BookCellModel.didSelectRead: route
    LCP PDFs through ReaderService.openPDF before falling back to the
    open-access PDFKit path. AppContainer DI (current convention),
    not .shared.
  - TPPPDFDocument: simplified — only the non-encrypted PDFKit path
    remains (open-access PDFs).
  - TPPPDFReaderView: minor binding tweak for the LCP-route case.
  - pbxproj: new ReadiumPDF group + dropped obsolete encrypted-PDF
    files from both Sources phases.

**Scope:** 14 production files modified, 6 deleted (Encrypted* +
TPPPDFViewController), 2 added (ReadiumPDF/*). Net: +356 / -1117 LOC.

**Not done:** End-to-end sim verify against Edge of Darkness with the
migrated pipeline — that's the next step and is what motivated cherry-
picking this. Will follow up in a subsequent commit / PR comment with
sim evidence.

**Deferred:** Snapshot tests for the new ReadiumPDFReaderView /
ReadiumPDFViewController. The legacy TPPPDF* snapshot tests will
naturally become stale (some assert on the obsolete EncryptedPDF path);
those should be removed in a follow-up cleanup.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…memory + loading indicator

Builds on the Readium PDFNavigator migration (cac4e7b) with the
end-to-end fixes surfaced when validating Power Rangers Unlimited: Edge
of Darkness on A1QA Test Library:

(1) RECURSIVE PREDICATE — extend `hasLCPAcquisition` to walk all
    `book.acquisitions` (not just `defaultAcquisition`) plus each one's
    `indirectAcquisitions` chain. Edge of Darkness exposes two top-level
    acquisitions — `application/atom+xml;…opds-catalog` AND
    `application/vnd.readium.lcp.license.v1.0+json` — and the LCP MIME
    is on the SIBLING, not the default. A predicate that only walked
    `defaultAcquisition.indirectAcquisitions` missed the LCP entirely
    and routed the book to the plain-PDFKit fallback in
    `BookService.presentPDF`, which failed on encrypted bytes.

    New test: `testHasLCPAcquisition_siblingLCPAcquisition_returnsTrue`
    encodes the Edge-of-Darkness shape and asserts the canOpenBook ⇄
    hasLCPAcquisition divergence — canOpenBook is FALSE (only inspects
    default), hasLCPAcquisition is TRUE (walks siblings).

(2) ROUTE-TO-READIUM SWITCH — `BookService.presentPDF` and
    `BookCellModel.didSelectRead` now use `hasLCPAcquisition(book)`
    rather than `canOpenBook(book)` as the LCP-route guard, so the
    sibling-shape case actually reaches `ReaderService.openPDF`.

(3) LOADING INDICATOR — plumb `onFinish` through the entire LCP open
    chain (BookDetailViewModel → BookService → presentPDF → openPDF →
    libraryService.openBook) so `processingButtons` / cell `isLoading`
    stays TRUE until the publication is opened and the route is
    pushed. LCP open is heavy (1-3s on real Marketplace containers);
    without the held spinner the user sees nothing happening for that
    window. `BookDetailViewModel.openBook` no longer clears
    `processingButtons` synchronously before `presentPDF` — it does
    so in the open completion instead.

(4) MEMORY LEAK FIX — `NavigationCoordinator.readiumPDFById` was not in
    the cleanup-threshold accounting nor in `performCleanup`, so every
    LCP open accumulated a `Publication` (with its LCP content-
    protection state, GCDHTTPServer endpoint, and decrypted page
    caches) and the app eventually OOMed. New `removeReadiumPDF(forBookId:)`
    helper called from `ReadiumPDFReaderView.onDisappear` drops the
    publication on back-out; cleanup tracking and performCleanup now
    include `readiumPDFById`/`readiumPDFTOCById`.

(5) NAVIGATION CHROME RESTORED — `ReadiumPDFReaderView` now wraps
    `ReadiumPDFContainer` in `TPPPDFNavigation` exactly like the
    legacy `TPPPDFReaderView` does for open-access PDFs. Back button,
    TOC button, previews/bookmarks segmented picker, and search +
    bookmark toolbar buttons are visible again. The side panels —
    `TPPPDFTOCView`, `TPPPDFPreviewGrid`, bookmarkView — see a
    publication-backed `TPPPDFDocument` shim that proxies their TOC,
    pageCount, and label calls to Readium without forking the UI.

(6) PUBLICATION-BACKED TPPPDFDocument — new `init(tableOfContents:
    pageCount:)` initializer accepts pre-loaded snapshots so the side
    panels stay synchronous. Readium 3's `tableOfContents()` and
    `positions()` are async; `ReaderService.openPDF` awaits both
    before pushing the route. Snapshot is stored on
    `NavigationCoordinator.readiumPDFTOCById` and resolved by
    `NavigationHostView` when constructing the view.

(7) TEST FILE PRUNING — `LCPPDFsTests.swift` and
    `TPPEncryptedPDFDataProviderTests.swift` referenced the
    LCPPDFs.extract/decrypt/temporaryUrlForPDF/deletePdfContent +
    TPPEncryptedPDFDocument APIs that the migration deleted. Removed
    along with their pbxproj entries. `TPPPDFModelTests` trimmed to
    only the tests that survive the publication-backed redesign.

(8) HREF COMPARISON FOR TOC PAGE NUMBERS — `AnyURL.string` is internal
    to ReadiumShared. The Readium-Link → page-number resolver in
    `ReaderService.pageNumber(for:positions:)` compares via
    `String(describing:)` rather than the inaccessible `.string`
    accessor; falls back to page 0 (cover) on no match.

VERIFIED END-TO-END: Power Rangers Unlimited: Edge of Darkness opens
on iPhone 16 Pro sim against A1QA Test Library. Logs show
ReadiumNavigator.PDFNavigatorViewController instantiated +
ReadiumGCDWebServer streaming pages. OCR captures author credits and
title page. No "Unable to load PDF file" error. Power Rangers #2
(open-access PDF) continues to open through the legacy PDFKit path
unchanged.

**Scope:** 14 production files touched, 2 obsolete test files
deleted. Net change vs the pre-migration baseline now includes the
chrome restoration delta.

**Not done — perf:** First open of an 829MB LCP container takes
~5–60s on real devices. AssetRetriever + LCP key derivation +
PublicationOpener is the hot path; needs profiling to identify
specifically what's expensive. The loading indicator at least
surfaces the wait to the user. Tracked separately.

**Not done — publication-mode search:** Search button in the
publication path is wired into TPPPDFNavigation chrome but the
underlying `document.search(text:)` is a no-op for publication mode.
Bridging to Readium 3's `SearchService` (`publication.search(query:)`)
is follow-up scope.

**Not done — publication-mode thumbnails:** Preview-grid cells show
page-number labels only (no thumbnail image) in publication mode.
Readium's PDFNavigator renders pages internally but doesn't expose a
public thumbnail API; rendering off-screen would require reading the
decrypted PDF bytes per-page, which defeats the streaming benefit.
Follow-up.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@mauricecarrier7
Copy link
Copy Markdown
Contributor Author

Pushed: end-to-end LCP PDF reader hardening (commit 54c518f)

Edge of Darkness validation surfaced four real issues beyond the OPDS-layer predicate. All addressed in this push, build green, 10 pre-push test classes pass.

1. Sibling-acquisition shape — Edge of Darkness was failing for a different reason than Family Matters. Its registry shows TWO top-level acquisitions: [0] application/atom+xml;…opds-catalog + [1] application/vnd.readium.lcp.license.v1.0+json. The LCP MIME is on the SIBLING, not the default. The original hasLCPAcquisition only walked defaultAcquisition.indirectAcquisitions, so it missed Edge of Darkness entirely → BookService.presentPDF fell through to plain PDFKit → "Unable to load PDF file."

Fix: walk book.acquisitions[*] AND each one's indirectAcquisitions chain. New test testHasLCPAcquisition_siblingLCPAcquisition_returnsTrue encodes this shape with assertion divergence (canOpenBook=false, hasLCPAcquisition=true). 5/5 predicate tests green.

2. Memory leak — NavigationCoordinator.readiumPDFById accumulated forever. Not in cleanup threshold accounting, not cleared in performCleanup. Each LCP open leaked the entire Publication (LCP content-protection state, GCDHTTPServer endpoint, decrypted page caches). Multi-open OOM crash you observed.

Fix: added to cleanup tracking + new removeReadiumPDF(forBookId:) called from ReadiumPDFReaderView.onDisappear so the publication releases on back-out.

3. Loading indicator wasn't showing. BookDetailViewModel.openBook cleared processingButtons synchronously before presentPDF even started, and presentPDF was called without a completion. Spinner cleared in microseconds.

Fix: onFinish plumbed through BookDetailViewModel.openBook → presentPDF → BookService.open → dispatchOpen → ReaderService.openPDF → libraryService.openBook. Spinner now holds until the publication opens.

4. Nav chrome restored. ReadiumPDFReaderView now wraps ReadiumPDFContainer in TPPPDFNavigation exactly like the legacy TPPPDFReaderView — back button, TOC, previews, bookmarks, search, bookmark-toggle all visible. Side panels (TPPPDFTOCView, TPPPDFPreviewGrid, bookmarkView) get a publication-backed TPPPDFDocument shim (new init(tableOfContents:pageCount:)) so they keep their existing UI. TOC + page count pre-loaded async in ReaderService.openPDF before the route pushes (Readium 3's tableOfContents() / positions() are async; consumer side panels expect sync getters).

Deferred to follow-up (noted in commit body):

  • Perf — first LCP open on Marketplace is 5–60s. AssetRetriever + LCP key derivation + PublicationOpener is the hot path; needs profiling.
  • Publication-mode search — button is now visible in chrome but document.search(text:) is a no-op in publication mode. Bridging to Readium 3's SearchService is follow-up.
  • Publication-mode thumbnails — preview grid shows page numbers only, no images. Readium PDFNavigator renders pages internally; off-screen thumbnail extraction would defeat the streaming benefit. Follow-up.

🤖 Generated with Claude Code

mauricecarrier7 and others added 12 commits May 26, 2026 23:43
… survives back-out

Three perf+UX wins building on the chrome restoration:

(1) ROUTE PUSHES IMMEDIATELY — `ReaderService.openPDF` now pushes the
    `.pdf` route before kicking off `libraryService.openBook`. Previously
    the open was async-but-awaited before the route push, leaving the
    user staring at the book-detail page for 30–60s on large LCP
    containers wondering if anything was happening. New
    `ReadiumPDFLoadingView` shows a full-screen spinner + book title
    while the open is in flight; `NavigationCoordinator.readiumPDFPending`
    is `@Published`, so when the publication lands the host view swaps
    the loader for the real reader without a polling/timer dance.

(2) TOC + POSITIONS LOAD MOVED OFF THE FIRST-PAGE-RENDER PATH — the
    eager AES decrypt that dominates LCP open time (the hundreds of
    `Successfully decrypted 2064 -> 2048` log lines you see on Edge of
    Darkness) is triggered by `publication.positions()` and
    `publication.tableOfContents()` because Readium has to walk the
    PDF cross-reference table + outline through the LCP content
    protection layer. Previously both ran inline before the publication
    was stored, blocking the navigator from rendering page 1 until the
    decrypt loop finished.

    Now: as soon as the publication opens, it's stored in the
    coordinator → navigator starts rendering page 1. A low-priority
    `Task.detached(priority: .utility)` loads TOC + page count in the
    background. When ready it's stored on the coordinator's
    `@Published readiumPDFTOCById` → side panels (TOC view, preview
    grid) re-render with populated data. The navigator itself stays
    mounted (UIViewControllerRepresentable's `updateUIViewController`
    fires but the controller persists), so the user keeps page 1.

(3) TOC CACHE SURVIVES BACK-OUT — `removeReadiumPDF` no longer drops
    the `readiumPDFTOCById` snapshot. The `Publication` and its heavy
    LCP/HTTP-server state still release on the reader's `onDisappear`
    (memory-leak fix from the earlier hardening commit), but the TOC
    metadata (<100 entries × ~200 bytes) stays. Re-opening the same
    book in the same app session skips the entire async TOC load —
    side panels populate instantly; only the publication itself has to
    re-open. `ReaderService.openPDF` checks
    `resolveReadiumPDFTableOfContents` and skips the background TOC
    Task when the cache is warm.

(4) FAILURE PATH POPS THE ROUTE — on `libraryService.openBook` failure
    we now `removeReadiumPDF(forBookId:)` + pop the navigation path
    so the user isn't left sitting on a forever-loading spinner.

Build green. Existing 5/5 LCPPDFAcquisitionPredicateTests still pass.

**Scope:** 4 production files (NavigationCoordinator +18 lines,
ReaderService +27 lines, NavigationHostView +9 lines,
Strings.swift +1 line) + 1 new file (ReadiumPDFLoadingView.swift, 45
lines).

**Not done — disk persistence of TOC.** Cold-app re-open still has to
re-decrypt the PDF cross-ref to compute TOC + positions. Writing the
snapshot to `<account>/registry/<bookId>.toc.json` would solve it.
Follow-up in this push series.

**Not done — Readium PDFNavigator content-cache tuning.** Default
decrypted-resource cache size may be over-allocated. Needs Readium
API research. Follow-up.

**Not done — GCDHTTPServer endpoint teardown audit.** Need to confirm
`httpServer.serve(at:publication:)` (called in LibraryService.preparePresentation)
has a corresponding stop on publication release. Follow-up.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…mark log + perf timings

Closes the 5-item perf/memory dial-in pass on top of the chrome
restoration and immediate-route-push commits. All five items
addressed; one had nothing to tune; two are pure mitigation logic;
two add new infrastructure.

(1) STRUCTURED TIMING LOGS — added `[PERF] [LCP-PDF] T0/T1/T2/T3`
    markers to ReaderService.openPDF covering the four user-visible
    stages: Read tap → route push, route push → publication open,
    publication open → TOC+positions load complete. Each marker
    logs cumulative-from-T0 and stage-specific elapsed-ms. Grep
    `[PERF] [LCP-PDF]` in a Console log after a test open and you
    get a per-stage breakdown without Instruments. Used in lieu of
    WDA-based Instruments runs since Xcode account login for
    device profiling is currently blocked.

(2) TOC + PAGECOUNT DISK PERSISTENCE — new
    `ReadiumPDFTOCCache` writes the publication's TOC + page count
    snapshot to `<accountDir>/registry/pdf-toc/<sha256(bookId)>.json`
    on first successful load. Schema-versioned JSON; non-fatal
    failures. ReaderService.openPDF checks disk BEFORE pushing the
    route — cache hit populates the coordinator snapshot
    immediately so a cold-app re-open shows TOC + preview-grid
    page count the instant the navigator appears. Cache is
    invalidated by LocalBookContentService.deleteLocalContent on
    book return / re-borrow so stale TOC isn't reused against
    new content.

(3) READIUM PDFNAVIGATOR CACHE AUDIT — no action. Audited
    `PDFNavigatorViewController.Configuration` (only exposes
    preferences/defaults/editingActions), `PDFDocumentHolder`
    (single PDFKit.PDFDocument ref, no cache knob), and the LCP
    decryption path (`CBCLCPResource` wraps with
    `BufferingResource(size: 8192)` — small + fixed). PDFKit's
    page cache is Apple-internal and responds to system memory
    warnings automatically. Memory hotspots already covered by
    earlier readiumPDFById cleanup + GCDHTTPServer endpoint
    deregistration (this commit, item 4). No Readium-level knob
    available to turn.

(4) GCDHTTPSERVER ENDPOINT TEARDOWN —
    `LibraryService.preparePresentation` calls
    `httpServer.serve(at: "/publications/\(id)", publication:)`
    on every open but had no corresponding `remove(at:)` call.
    Each publication open leaked a handler + resource transformer
    in `GCDHTTPServer.handlers` / `.transformers` even after the
    publication itself released. Added
    `LibraryService.releaseServedPublication(forBookIdentifier:)`
    + `ReaderService.releaseReadiumPDF(forBookIdentifier:)`
    wrapper; `ReadiumPDFReaderView.onDisappear` now routes through
    the wrapper which drops both the coordinator's publication ref
    AND the HTTP-server endpoint. Net: per-open registrations now
    have matching deregistrations.

(5) BOOKMARK PARSE-FAILURE LOG — investigated the
    "298 items failed to parse" warning. Not a parse failure: the
    server's `/annotations/` endpoint returns ALL of a user's
    bookmarks across every book they've read. `TPPBookmarkFactory.make`
    correctly filters by `source != bookID` and returns nil for
    other-book bookmarks. The "failed to parse" log was misleading.
    Downgraded to info with explanatory comment + flagged the
    server-architectural cost: bookmark cold-open re-downloads the
    entire user-wide bookmark list. A CM-side per-book filter
    parameter would be the right fix; client-side there's no
    cheap mitigation.

OPEN-PATH STATE AFTER ALL 5 ITEMS:
- Read tap → reader screen visible (loading state): <1 frame
- Reader screen → first page rendered: bounded by Readium
  publication open (LCP key derive + cross-ref decrypt). Timing
  markers now make this measurable.
- Side panels (TOC, preview-grid, bookmarks):
    * Disk cache hit → populated at T1 (instant)
    * Disk cache miss → populated at T3 (background task)
- Reader dismissed: Publication + HTTP server endpoint + decrypted
  page caches all release; TOC + pageCount snapshot stays in
  memory + on disk for the next open.

**Scope:** 6 production files modified + 1 new file
(ReadiumPDFTOCCache.swift). LCPPDFAcquisitionPredicateTests still
5/5 green.

**Not done — Instruments-based perf profiling on Moes Max.**
Blocked on Xcode → Settings → Accounts sign-in for Synctek dev cert
(needed by `simdrive bootstrap-device` to build the WDA test
runner). With the new timing logs the same data is available from
Console output, so this is no longer blocking the dial-in work.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…rlapping opens

The previous open flow hid the ReadiumPDFLoadingView the moment the
publication object landed in NavigationCoordinator (T2, ~100ms on a
fast device). But the user-perceived "blank reader" window extends
through PDFNavigator's first cross-ref walk through the LCP content
protection layer — hundreds of `decrypt 2064 -> 2048` calls, which
on large Marketplace containers takes seconds-to-minutes. The user
saw a frozen blank page instead of a progress indicator.

This switches the pending → rendered handoff to the navigator's
first `locationDidChange` emission (the real "page 1 painted"
signal). The host view now stacks the loading overlay ON TOP of the
ReadiumPDFContainer until that signal fires, so the user sees
"Loading…" continuously from tap to first paint.

Also adds a per-book generation counter on ReaderService.openPDF
that bumps on `releaseReadiumPDF` and is captured by the async
`libraryService.openBook` completion. A stale completion (e.g. user
backed out before the first decrypt walk finished, then re-tapped
the same book) no-ops instead of writing the OLD publication on top
of the NEW one. Same dict also coalesces a double-tap on Read into
a single open — without it, two concurrent decrypt walks ran in
parallel after the second tap.

**Not done:** PDFNavigator's internal decrypt Tasks for a released
publication still run to completion — Readium holds them and there
is no public cancellation seam. The generation guard prevents the
result of that stale work from landing in the coordinator, but the
CPU/IO cost has already been paid. Mitigation lives upstream in
Readium.

**Scope:** loading-indicator timing fix + overlapping-open guard.
TOC disk cache, GCDHTTPServer teardown, and PERF markers already
shipped in 33be65b.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…memory release

The plain spinner gave the user no signal that the open was making
forward progress — on a multi-second cross-ref decrypt walk, the
question "is this stuck?" stays open until page 1 paints.

Replaces the spinner with a dark, cinematic overlay:
- gradient near-black background
- book title rendered LARGE and faded behind everything as a
  watermark (~8% white) — present but not loud
- foreground stack: cover thumbnail, title + author, animated
  linear progress bar with `1 - exp(-blocks/90)` fill curve, and
  the live status line ("Decrypting content… 327 blocks")

A new `LCPPDFOpenProgress` ObservableObject is the single source
of truth for that status. `TPPLCPClient.decrypt(...)` records each
successful block on both overloads (the protocol entry point and
the convenience extension) so the counter ticks visibly on every
forward step. The reporter has four phases — preparing, opening
publication, decrypting content, loading first page — driven by
`ReaderService.openPDF` and cleared by either the navigator's
first `locationDidChange` (the page-1-painted signal) or by
`releaseReadiumPDF` on back-out.

Also addresses the OOM crash from the device log
("Memory warning received — clearing in-memory catalog cache"
followed by a hard exit): `openPDF` now posts the system
memory-warning notification proactively at T0, so the catalog
in-memory cache, book-cell model cache, and image caches drop
their working sets BEFORE the decrypt walk pushes RAM over the
OS ceiling. Disk caches are content-addressed and survive — the
re-hydrate on back-out is a no-op from the user's perspective.

The fill curve is honest about its denominator: the total number
of decrypt blocks needed to render page 1 is not known a priori
(varies by book size and bookmarked page), so the bar never
reaches 100%. It asymptotically approaches 95% and snaps shut
when first-page-rendered fires.

**Not done:** the exponential fill curve was tuned by inspection
on a single Edge of Darkness open (~30s, ~240 blocks to reach
page 8). A device-pool sweep across small/large books would let
us replace the magic 90 with a per-book-size baseline. Filed as
follow-up.

**Scope:** loading-view UX + memory pre-release. Reader2 EPUB
path is unchanged — this is LCP-PDF-only.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two changes:

1. **Loading view shows %, not block counts.** Common users don't
   want to see "Decrypting content… 327 blocks" — that's debug
   noise. Switched to "Loading… 47%" derived from the same
   exponential curve. The `percentComplete` accessor moved onto
   `LCPPDFOpenProgress` so the view and the future tests share
   the same source of truth.

2. **Per-process LRU cache of AES-decrypted blocks
   (`LCPDecryptCache`).** PDFNavigator walks the PDF cross-ref
   table on every open; many blocks (cross-ref index, font
   tables, repeated stream dictionaries) are requested multiple
   times within a single render pass, and EVERY block was being
   decrypted again on re-open of the same book. The cache is
   keyed by ciphertext bytes — same ciphertext = same plaintext
   under a stable LCP key — and capped at 16MB via NSCache's
   totalCostLimit. Hits skip the R2LCPClient AES round-trip
   entirely.

   Cached hits count toward progress (the page is one step
   closer to rendering, just for free) but at 50% weight in
   the percentComplete curve to avoid the bar jumping on
   re-opens.

   `NSCache` evicts under memory pressure automatically. The
   memory-warning observer purges the whole thing — including
   the pre-emptive warning `ReaderService.openPDF` posts at T0,
   so a fresh open of book B doesn't pin book A's blocks in
   memory.

**Not done:** the 16MB cap is conservative — on devices with
plenty of headroom we could let it grow. Cache hit rate isn't
instrumented yet; would need to log it to tune the cap.

**Scope:** UX polish + perf cache. Memory pre-release from the
previous commit stays as-is (catalog cache drop at T0).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The bar was capped at `min(95, ratio*100)` so on large books it
saturated at 95% and visibly froze for the rest of the open (which
on a multi-thousand-block textbook could be minutes). Then the OOM
crash sealed the deal — user saw "stuck → crash."

Three changes:

1. **Two-stage curve, max 99%.** Stage 1 is the existing exponential
   `1 - exp(-credit/90)` up to ~80%. Stage 2 is a linear creep
   above 80% — every additional 50 credit adds ~1%, so a thousand-
   block book sees the bar drift 80→99 over many visible stages
   instead of plateauing. Above 95% the status text switches to
   "Finishing up…" so the user understands the bar is almost done,
   not stuck.

2. **Pause `TPPBookCoverRegistry.fetchImage` during LCP open.** The
   device log on the OOM showed concurrent CGImageSourceCreateThumbnail
   failures against BiblioBoard URLs — the catalog prefetcher kept
   firing in parallel with the decrypt walk, allocating large image
   buffers that the decoder then failed to render and leaked. A new
   `nonisolated LCPPDFOpenProgress.isOpenInProgress` flag (atomic
   under a tiny NSLock so the cover prefetcher can read it from
   arbitrary actors without a MainActor hop) lets the cover fetcher
   early-return nil while LCP is in flight. Catalog scroll re-issues
   the fetch organically once the open finishes; cache misses are
   the same shape as a transient network failure.

3. **Per-1.5s memory log line during open.** A new
   `ReaderService.residentMemoryMB()` reads `mach_task_basic_info`,
   and a detached Task spins out `[PERF] [LCP-PDF] residentMB=X
   blocks=Y cacheHits=Z phase=...` while progress is non-idle. Next
   device run on an OOM-prone book will produce a memory trace
   showing exactly which phase tips the device over the jetsam
   ceiling — without needing Instruments.

**Not done:** the underlying cause of the OOM (PDFNavigator's
internal page buffers for large textbooks) is not addressed. These
changes reduce the surrounding pressure (cover prefetch, catalog
caches) so PDFNavigator has more headroom, and add the diagnostics
needed to triage what's left. If the next run still crashes, the
memory log will tell us where to look upstream.

**Scope:** progress UX + memory diagnostics + prefetch pause.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Device diagnostics from a failed open of Power Rangers Unlimited
showed:
- 841,570 decrypt calls in ~10s (~7k/s)
- residentMB grew 1792 → 2846 (~107 MB/s, ~9 KB retained per call)
- Decrypt cache hit rate: 0.13% — every call is a fresh ciphertext
  because AES-CBC mode means same plaintext at different file
  offset → different ciphertext. PDFNavigator's random-access
  reads on the LCP-wrapped PDF stream produce uniformly-unique
  ciphertext inputs, so a ciphertext-keyed cache provides almost
  no benefit.

The fundamental problem: the current
Readium-PDFNavigator-on-LCP-publication architecture cannot
work for large Marketplace PDFs. PDFNavigator's random-access
read pattern + Readium's retained per-read buffers grow memory
linearly until iOS jetsam-kills the app. Small LCP PDFs still
work (the open finishes before memory pressure crosses the
ceiling) but large ones (Power Rangers, textbooks) cannot.

This commit ships the **graceful failure** half of the fix:
- Periodic memory log gets an abort trigger at 150k decrypts.
  At that count the open is definitively not going to succeed
  (typical small PDFs paint in 200-2000 decrypts), so we pop
  the route, surface a user-facing alert, and log a Crashlytics
  event with bookId + residentMB. Better than an OOM/jetsam.
- The per-call `Log.debug("Successfully decrypted X bytes")` line
  in TPPLCPClient is removed. At 7k calls/s it was throwing
  string-format + log-system work that itself contributed to
  the pressure. The progress reporter + periodic [PERF] [LCP-PDF]
  residentMB lines carry the same diagnostic information without
  the per-call cost.

**Not done — the real fix is architectural.** We need to switch
the LCP-PDF render path from "Readium PDFNavigator streaming
from a still-encrypted publication" to "pre-decrypt to a temp
.pdf on disk, then hand to PDFKit." Readium's Resource API
exposes a `read(range: nil)` that should return the full
decrypted publication bytes in a single linear pass — turning
~800k random-access decrypts into ~25k linear ones for a 50MB
book, AND letting PDFKit (which has efficient seeking) handle
all the random-access work after the file is on disk. Filed as
PP-4454 follow-up.

**Scope:** crash-prevention guardrail + diagnostic noise
reduction. Real perf fix is the next commit.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…r PP-4454 OOMs)

Replaces the Readium-PDFNavigator-on-LCP-publication render path
with a single-pass disk extract, then renders the resulting plain
.pdf with PDFKit. This is the architectural fix the previous
commits' diagnostics pointed to.

Why the old path can't work:
- PDFNavigator does random-access reads on the PDF cross-ref
  table. Each read goes through the LCP decrypt layer.
- AES-CBC produces uniformly-unique ciphertext for each file
  offset, so the in-process decrypt cache hits ~0.13% of the
  time (verified on device).
- Each decrypt leaves ~9 KB retained in Readium's resource
  pipeline. Memory grows ~107 MB/s, OOM in ~10s on Power
  Rangers Unlimited.

Why disk-extract fixes it:
- `publication.get(link).stream(consume:)` streams decrypted
  bytes in LINEAR order (one pass through the ZIP entry), so
  the decrypt count drops from ~800k random-access calls to
  ~25k sequential ones for a 50MB book.
- Each chunk is written immediately to a FileHandle and freed —
  in-memory footprint stays at one chunk (≪1 MB).
- Once extraction is done, the Readium publication is released
  (`releaseServedPublication`), freeing its resource pipeline.
- PDFKit mmaps the on-disk PDF; random-access page rendering
  costs whatever PDFKit costs, with no LCP layer involved.

What's added:
- `LCPPDFDiskExtract` — namespace with `cachedURL`, `extract`,
  `invalidate`. Stream loop + FileHandle write + per-chunk
  progress publish. Cache layout
  `<accountDir>/registry/lcp-pdf-extracts/<sha256(bookId)>.pdf`
  matching the existing TOC cache.
- `LCPPDFOpenProgress` gains `bytesExtracted` + `totalExtractBytes`.
  The progress bar prefers the BYTE-accurate percentage when
  the total is known (from `Resource.estimatedLength()`), falling
  back to the legacy decrypt-curve only when extraction hasn't
  started yet. This is the first time the bar is showing real
  progress instead of a heuristic.
- `LocalBookContentService.deleteLocalContent` invalidates the
  extract on book return so a re-borrow gets a fresh extract.
- `NavigationHostView` is unchanged — the `.pdf` route already
  preferred the plain-PDF branch when no Readium publication
  was stored. We just route through `storePDF` instead of
  `storeReadiumPDF` on completion.

What's NOT removed yet:
- `ReadiumPDFReaderView`, `ReadiumPDFContainer`,
  `ReadiumPDFViewController`, `ReadiumPDFTOCCache`,
  `LCPDecryptCache`, the Readium-PDF host-view branch — all dead
  code under the new flow but cleaner to remove in a follow-up
  once we've shipped a build that's confirmed stable. Smaller
  blast radius for this commit.

**Scope:** LCP PDF render architecture swap. EPUB / audiobook /
non-LCP PDF paths unchanged.

**Not done:** dead-code cleanup of the old Readium-PDFNavigator
path. Filed as follow-up.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…heavy logging

User report: latest disk-extract build hangs at 1% on device then
crashes. Likely cause: `Resource.stream(consume:)` on an LCP-wrapped
publication isn't actually emitting incremental chunks — it's
either firing the consume callback once with a giant buffer
(forcing a full-file allocation inside Readium's pipeline before
our consume runs) or chunking unpredictably for the progress bar.

Switches the extract loop to explicit `read(range:)` calls with a
fixed 1MB chunk size:
- We control chunk size and the OS gets FileHandle flush
  opportunities between chunks.
- Per-chunk progress is deterministic — bytesExtracted advances by
  exactly 1MB per iteration.
- A stall is now diagnosable: a single failing `read(range:)` call
  surfaces as a Swift error from the `Result` switch, not a hang.

Added detailed logging:
- Link href + media type before extraction begins (verifies we're
  reading the PDF resource, not the manifest or cover).
- `estimatedLength()` result (including failure mode).
- Forward-progress log at offset == chunkSize and every 16MB
  thereafter — flags a stall without flooding the log on a fast
  open.
- explicit `read(range:)` failure with the range that failed.

Fallback path: if `estimatedLength()` returns nil/0 we can't loop
on ranges, so we fall back to the original `stream(consume:)`
approach. Logged as a warning so we know to investigate.

**Not done:** the actual root cause of the 1% hang isn't proved
yet — chunked reads might surface the same symptom in a more
diagnosable form. The next device run will tell us whether we
see deterministic chunk progress, or which chunk read fails.

**Scope:** disk-extract reliability + diagnostics. UI / state
machine unchanged.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
User report: on Moes Max, after the prior commit's stream-based
extract crashed mid-write, opening the same book showed "Unable
to load PDF file" with the legacy PDFKit reader screen. Root
cause: `cachedURL()` only checked file existence, not
correctness. A partial/truncated extract from a crashed prior
run would pass the existence check, get handed to
`TPPPDFDocument(url:)`, fail PDFKit parsing, and surface the
generic "Unable to load" alert.

Adds two cheap validation checks on cache hit:
- File size must be ≥ 1KB (a true LCP-PDF extract is hundreds of
  MB; anything smaller is definitely a partial write).
- First 5 bytes must equal `%PDF-` (0x25 0x50 0x44 0x46 0x2D).
  PDF spec requires this header on every well-formed PDF; a file
  missing it can't be a complete extract.

If validation fails the file is deleted on the spot so the next
`extract()` call rebuilds cleanly. The validation is
intentionally NOT a full `PDFDocument(url:)` open — that mmaps
and parses the xref table on the entire ~830MB file, which is
exactly the work we want to defer to the actual reader UI.

**Not done:** there's no validation of internal PDF structure
beyond the header bytes. A file with `%PDF-` magic but a corrupt
trailer would still get handed to PDFKit and fail at parse time.
The previous fix (chunked reads + remove-on-failure) makes this
unlikely going forward, but a future-proof option would be a
small marker file (`<sha>.complete`) written atomically after
successful extract — only consider the cache valid when the
marker is present.

**Scope:** cache-correctness guardrail. Mostly recovers users
whose phone was stuck with a partial extract from the prior
stream-based build.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…nces

User report: opening Edge of Darkness shows the "Sync Reading
Position" prompt on every open, even when they haven't changed
position on a different device. Root cause: `Stay` was a no-op
that dismissed the alert without recording the decision. Each
fresh open built a new `TPPPDFDocumentMetadata`, re-fetched the
remote page, found the same mismatch (local 0 / remote 14, or
similar after cross-device reading), and re-prompted.

The fix records the user's Stay decision per-book against the
SPECIFIC remote page value:

- `dismissRemotePageSync()` persists `(bookId, declinedRemotePage)`
  to UserDefaults.
- `shouldPromptRemotePageSync()` returns false when the current
  remote page equals the previously-declined value for this book —
  so re-opens are quiet on the same mismatch.
- If the server later advances to a DIFFERENT page (real new
  progress from another device), the previously-declined value
  no longer matches and the prompt fires again.
- `syncReadingPosition()` (the Move action) clears the declined
  marker so subsequent updates can prompt.

Why not just push local-to-server on Stay: that would overwrite
genuine reading progress made on another device (user reads
50 pages on device A, opens device B at page 10, taps Stay → if
we pushed 10 to server, device A loses its position). The
per-book / per-remote-page decline marker preserves cross-device
progress while not nagging.

Wired up in `TPPPDFReaderView` (the legacy PDFKit reader, which
is the current LCP-PDF render target after the disk-extract
architecture switch). `ReadiumPDFReaderView` has the same alert
logic but is dead code under the disk-extract flow — cleanup
follow-up.

**Not done:** the EPUB reader has its own sync-position prompt
on `LastReadPositionSynchronizer` — not touched here. Same
fix would help if it has the same complaint, but PR scope is
LCP-PDF only.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…reporter

PP-4454 disk-extract pipeline now has unit-level test coverage on
its two highest-value surfaces. Both classes survive mutation
testing — flip a comparator in `cachedURL`'s size check, drop the
%PDF- magic byte test, change a +1 to -1 in `recordDecrypt`, or
break the phase auto-transition, and a specific test fails.

**LCPPDFDiskExtractTests (5 tests)** — locks in the cache-correctness
guardrail that addresses the device-side "Unable to load PDF file"
seen after the stream-based extract crashed mid-write:

- testCachedURL_validPDFHeaderAndSize_returnsURL — happy path,
  also verifies the validator doesn't delete a valid file
- testCachedURL_undersizedFile_returnsNilAndDeletesFile —
  rejects + cleans up a 5-byte partial write
- testCachedURL_wrongMagicBytes_returnsNilAndDeletesFile —
  rejects + cleans up a PK\x03\x04 ZIP-magic fixture (the raw
  LCP container leaking through as the cached file)
- testCachedURL_noFileOnDisk_returnsNil — negative path
- testCachedURL_partialWriteWithGoodHeader_stillRejected — edge
  case where header IS present but size sub-1KB; lock-in for
  the AND-not-OR semantic of the two checks

Tests derive the production file path from the same public
account-directory helper, so a refactor of the filename strategy
is caught by the test (production constant + test constant
diverging).

**LCPPDFOpenProgressTests (13 tests)** — locks in the observable
state machine that drives the loading-view percentage. Per
CLAUDE.md "Round-trip wiring tests required for state machines":

- begin → counters zeroed + cross-actor flag flipped
- recordDecrypt(byteCount:) → blocks+bytes increment
- recordDecrypt(fromCache:true) → only cachedHits, NOT decrypt
  counters (cached hits "ARE forward motion but for free")
- recordDecrypt while .idle → no-op (stray decrypts from
  audiobook paths must not poison the next LCP-PDF open)
- AUTO-TRANSITION seams: first decrypt flips .openingPublication
  → .decryptingContent; first chunk-write flips → .extractingToDisk
- percentComplete: bytes-based when total known, decrypt curve
  fallback when not, clamps at 99 (never 100 before paint), 0
  on fresh begin
- Round-trip: begin → work → finish → begin → clean state again

setUp uses `begin(bookIdentifier: "test-reset")` + `finish()` to
zero ALL counters (not just phase + identifier) — `finish` alone
leaves decrypt counters intact, which would cross-contaminate
tests via the @mainactor singleton.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@mauricecarrier7 mauricecarrier7 merged commit 16fc469 into develop May 27, 2026
2 of 3 checks passed
@mauricecarrier7 mauricecarrier7 deleted the fix/PP-4454-lcp-pdf-marketplace branch May 27, 2026 15:36
mauricecarrier7 added a commit that referenced this pull request May 27, 2026
…4454 merge)

PP-4454 (#1008) just landed on develop, migrating LCP-PDF rendering from
the legacy bitmap-tile TPPEncryptedPDFView path to PDFKit via
ReadiumPDFViewController. Without this wire-up, LCP-PDFs in the new path
would route around the PP-4297 gate (the PalacePDFView gating only fires
on the legacy non-LCP TPPPDFView path) and expose Copy/Cut/Paste/Look
Up/Share on long-press.

ReadiumPDFViewController now passes ReaderEditingActions.resolve(for:
book) as editingActions to PDFNavigatorViewController.Configuration —
DRM books get [] (no selection handles, no Copy); non-DRM books get
Readium's defaultActions. Same predicate, same call shape as the EPUB
navigator gating.

26/26 PP-4297 unit tests still passing post-merge.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
mauricecarrier7 added a commit that referenced this pull request May 27, 2026
…1012)

* PP-4297: gate reader copy/paste on the DRM-protected flag (EPUB + PDF)

Aligns both readers with the rule: DRM-protected titles suppress the system
text-selection edit menu (Copy/Cut/Paste/Look Up/Share); non-DRM titles keep
the full menu.

- TPPBook.isDRMProtected: walks the full nested acquisition chain for Adobe
  ACS + Readium LCP (EPUB/PDF/audiobook). One predicate; adding a new DRM
  scheme is a one-line change.
- ReaderEditingActions.resolve(for:isSample:appending:): single decision
  point for the Readium EditingAction list. DRM book → []; non-DRM book →
  defaultActions + caller-supplied custom actions; sample preview of any
  book → treated as non-DRM.
- TPPEPUBViewController: routes its EditingAction list through the helper
  so DRM EPUBs lose the long-press menu including Highlight; non-DRM EPUBs
  keep Readium's defaults plus Highlight; samples behave like non-DRM.
- PalacePDFView (PDFKit subclass) + TPPPDFView wiring: sets allowsCopy from
  metadata.book.isDRMProtected in onAppear (metadata isn't available at
  field-init time; the view is mounted before any long-press can fire).
  canPerformAction + buildMenu overrides suppress Copy/Cut/Paste/SelectAll
  /Look Up/Share when DRM.

Tests pin the matrix: TPPBookDRMProtectedTests covers every DRM scheme +
open-access counterpart + the OPDS fixture; ReaderEditingActionsTests
covers EPUB gating including sample-of-DRM passthrough and custom-action
suppression under DRM; PalacePDFViewTests covers Copy/Cut/Paste/SelectAll
/Look Up/Share suppression, non-editing-selector passthrough, and the
default-true safe behavior. 23/23 passing locally.

**Scope:** EPUB gating via Readium EditingActions; PDFKit PDFView gating
via canPerformAction subclass. Both call sites updated. ReaderEditingActions
also serves the upcoming Readium PDF path (post-PP-4454) without further
work.

**Not done:** Live end-to-end DRM simdrive verification (DAISY-EPUB
downloads fail repeatedly on the harness sim's network). Unit tests pin
the gating matrix exhaustively; manual QA pass per the ticket's
acceptance criteria (one Adobe-DRM + one LCP title, one open-access EPUB
+ one open-access PDF) belongs to release-regression rather than this PR.

**Deferred:** None. The Readium-PDF path lands separately on PP-4454; the
helper here is ready for that caller — no follow-up needed in this branch.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* PP-4297: fix trailing comma in PalacePDFView blocked-selectors

SwiftLint trailing_comma violation on the static array; no behavior change.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* PP-4297: apply architect review feedback (rev_dc27d478)

- Drop the dead-code clause that checked path.types for
  ContentTypePDFLCP ("application/pdf+lcp") in isDRMProtected. That MIME
  isn't in TPPOPDSAcquisitionPath.supportedTypes() / supportedSubtypes,
  so the path-walker can never return a path containing it. LCP-PDF
  detection happens through the ContentTypeReadiumLCP clause; the LCP-
  PDF test still passes with the same coverage.
- Invert buildMenu order in PalacePDFView: defer to super first, then
  strip. The canPerformAction short-circuit prevents leakage today, but
  super-then-mutate is defense-in-depth against a future PDFKit version
  that inserts items late in its buildMenu pass.

23/23 unit tests still passing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* PP-4297: close QA testing gaps from rev_1b4e3a40

- Add canPerformAction tests for the system-private _define: and
  _translate: selectors (gap: blocked-selectors list included them but
  no test pinned them — flipping the entries would not fail any test).
- Add accessibility-selector passthrough test for AC #7 (VoiceOver
  text read-aloud preserved on DRM titles). Asserts the subclass
  returns the same value as the allowsCopy=true baseline for an
  accessibility selector.
- Document the buildMenu override as belt-and-suspenders behind
  canPerformAction so the intent isn't lost if someone reads only
  buildMenu and concludes it's redundant.
- TPPPDFView gets an architectural note that today's PDFKit-rendered
  path is non-encrypted only (LCP-PDF goes through the bitmap-tile
  TPPEncryptedPDFView path); the PalacePDFView gating is forward-
  looking for any future PDFKit-rendered DRM-PDF path (PP-4454
  Readium-PDF, etc.).

12/12 PalacePDFView tests passing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* PP-4297: wire DRM gating into the new Readium-PDF navigator (post-PP-4454 merge)

PP-4454 (#1008) just landed on develop, migrating LCP-PDF rendering from
the legacy bitmap-tile TPPEncryptedPDFView path to PDFKit via
ReadiumPDFViewController. Without this wire-up, LCP-PDFs in the new path
would route around the PP-4297 gate (the PalacePDFView gating only fires
on the legacy non-LCP TPPPDFView path) and expose Copy/Cut/Paste/Look
Up/Share on long-press.

ReadiumPDFViewController now passes ReaderEditingActions.resolve(for:
book) as editingActions to PDFNavigatorViewController.Configuration —
DRM books get [] (no selection handles, no Copy); non-DRM books get
Readium's defaultActions. Same predicate, same call shape as the EPUB
navigator gating.

26/26 PP-4297 unit tests still passing post-merge.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant