Skip to content

Commit cd8692b

Browse files
Merge 1486ca7 into deb4f40
2 parents deb4f40 + 1486ca7 commit cd8692b

File tree

273 files changed

+19963
-1875
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

273 files changed

+19963
-1875
lines changed

.github/workflows/unit-testing.yml

Lines changed: 709 additions & 1 deletion
Large diffs are not rendered by default.

.gitignore

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,3 +16,37 @@ fastlane/report.xml
1616
fastlane/*_output
1717
Palace.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved
1818
Palace.xcodeproj/xcshareddata/xcschemes/Palace.xcscheme
19+
*.xcresult
20+
.ledger/config.json
21+
.ledger/index.sqlite
22+
.ledger/last_update.json
23+
.ledger/model.json
24+
docs/ledger/CONTRACTS/CONTRACT_AudiobookBookmarkDelegate.md
25+
docs/ledger/CONTRACTS/CONTRACT_AudiobookLifecycleListener.md
26+
docs/ledger/CONTRACTS/CONTRACT_AudiobookManager.md
27+
docs/ledger/CONTRACTS/CONTRACT_AudiobookNetworkService.md
28+
docs/ledger/CONTRACTS/CONTRACT_AudiobookPlaybackTrackerDelegate.md
29+
docs/ledger/CONTRACTS/CONTRACT_CatalogAPI.md
30+
docs/ledger/CONTRACTS/CONTRACT_CatalogRepositoryProtocol.md
31+
docs/ledger/CONTRACTS/CONTRACT_DataManager.md
32+
docs/ledger/CONTRACTS/CONTRACT_DownloadTask.md
33+
docs/ledger/CONTRACTS/CONTRACT_DRMDecryptor.md
34+
docs/ledger/CONTRACTS/CONTRACT_ImageCacheType.md
35+
docs/ledger/CONTRACTS/CONTRACT_LCPStreamingProvider.md
36+
docs/ledger/CONTRACTS/CONTRACT_NetworkClient.md
37+
docs/ledger/CONTRACTS/CONTRACT_Player.md
38+
docs/ledger/CONTRACTS/CONTRACT_PositionCalculating.md
39+
docs/ledger/CONTRACTS/CONTRACT_StreamingCapablePlayer.md
40+
docs/ledger/CONTRACTS/CONTRACT_StreamingResourceProvider.md
41+
docs/ledger/CONTRACTS/CONTRACT_TimeEntry.md
42+
docs/ledger/CONTRACTS/CONTRACT_TPPPublicationSpeechSynthesizerDelegate.md
43+
docs/ledger/CONTRACTS/CONTRACT_Track.md
44+
docs/ledger/CONTRACTS/CONTRACT_TrackFactoryProtocol.md
45+
docs/ledger/FLOWS/FLOW_audiobookplayback.md
46+
docs/ledger/FLOWS/FLOW_checkout.md
47+
docs/ledger/FLOWS/FLOW_login.md
48+
docs/ledger/INDEX.md
49+
docs/ledger/OPEN_QUESTIONS.md
50+
docs/ledger/SYSTEM_MAP.md
51+
docs/ledger/VERIFICATION.md
52+
readium-sdk

Palace.xcodeproj/project.pbxproj

Lines changed: 420 additions & 37 deletions
Large diffs are not rendered by default.

Palace.xcodeproj/xcshareddata/xcschemes/Palace.xcscheme

Lines changed: 33 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@
2626
buildConfiguration = "Debug"
2727
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
2828
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
29-
shouldUseLaunchSchemeArgsEnv = "YES"
29+
shouldUseLaunchSchemeArgsEnv = "NO"
3030
systemAttachmentLifetime = "keepNever"
3131
codeCoverageEnabled = "YES"
3232
onlyGenerateCoverageForSpecifiedTargets = "YES">
@@ -39,6 +39,28 @@
3939
ReferencedContainer = "container:Palace.xcodeproj">
4040
</BuildableReference>
4141
</MacroExpansion>
42+
<EnvironmentVariables>
43+
<EnvironmentVariable
44+
key = "TEST_MODE"
45+
value = "1"
46+
isEnabled = "YES">
47+
</EnvironmentVariable>
48+
<EnvironmentVariable
49+
key = "SKIP_ANIMATIONS"
50+
value = "1"
51+
isEnabled = "YES">
52+
</EnvironmentVariable>
53+
<EnvironmentVariable
54+
key = "LYRASIS_BARCODE"
55+
value = "01230000000002"
56+
isEnabled = "YES">
57+
</EnvironmentVariable>
58+
<EnvironmentVariable
59+
key = "LYRASIS_PIN"
60+
value = "Lyrtest123"
61+
isEnabled = "YES">
62+
</EnvironmentVariable>
63+
</EnvironmentVariables>
4264
<AdditionalOptions>
4365
<AdditionalOption
4466
key = "MallocStackLogging"
@@ -71,14 +93,6 @@
7193
BlueprintName = "PalaceTests"
7294
ReferencedContainer = "container:Palace.xcodeproj">
7395
</BuildableReference>
74-
<SkippedTests>
75-
<Test
76-
Identifier = "AudiobookBookmarkBusinessLogicTests">
77-
</Test>
78-
<Test
79-
Identifier = "AudiobookTimeTrackerTests">
80-
</Test>
81-
</SkippedTests>
8296
</TestableReference>
8397
<TestableReference
8498
skipped = "NO"
@@ -102,6 +116,16 @@
102116
ReferencedContainer = "container:Palace.xcodeproj">
103117
</BuildableReference>
104118
</TestableReference>
119+
<TestableReference
120+
skipped = "NO">
121+
<BuildableReference
122+
BuildableIdentifier = "primary"
123+
BlueprintIdentifier = "E58EAE132ECCC4F700CDA626"
124+
BuildableName = "PalaceUITests.xctest"
125+
BlueprintName = "PalaceUITests"
126+
ReferencedContainer = "container:Palace.xcodeproj">
127+
</BuildableReference>
128+
</TestableReference>
105129
</Testables>
106130
</TestAction>
107131
<LaunchAction
@@ -147,18 +171,6 @@
147171
isEnabled = "YES">
148172
</EnvironmentVariable>
149173
</EnvironmentVariables>
150-
<AdditionalOptions>
151-
<AdditionalOption
152-
key = "PrefersMallocStackLoggingLite"
153-
value = ""
154-
isEnabled = "YES">
155-
</AdditionalOption>
156-
<AdditionalOption
157-
key = "MallocStackLogging"
158-
value = ""
159-
isEnabled = "YES">
160-
</AdditionalOption>
161-
</AdditionalOptions>
162174
<LocationScenarioReference
163175
identifier = "New York, NY, USA"
164176
referenceType = "1">

Palace/Accounts/Library/Account.swift

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -245,6 +245,14 @@ protocol AccountLogoDelegate: AnyObject {
245245
loansUrl = URL.init(string: authenticationDocument.links?.first(where: { $0.rel == "http://opds-spec.org/shelf" })?.href ?? "")
246246
supportsSimplyESync = userProfileUrl != nil
247247

248+
// Debug logging for sync support
249+
Log.debug(#file, """
250+
🔖 AccountDetails init for '\(authenticationDocument.title ?? "unknown")':
251+
userProfileUrl: \(userProfileUrl ?? "nil")
252+
supportsSimplyESync: \(supportsSimplyESync)
253+
Available links: \(authenticationDocument.links?.map { "[\($0.rel ?? "no-rel"): \($0.href ?? "no-href")]" }.joined(separator: ", ") ?? "none")
254+
""")
255+
248256
mainColor = authenticationDocument.colorScheme
249257

250258
let registerUrlStr = authenticationDocument.links?.first(where: { $0.rel == "register" })?.href

Palace/Accounts/Library/AccountsManager.swift

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,13 +81,44 @@ let currentAccountIdentifierKey = "TPPCurrentAccountIdentifier"
8181
return account(uuid)
8282
}
8383
set {
84+
let previousAccountId = currentAccountId
85+
let newAccountId = newValue?.uuid
86+
8487
Log.debug(#file, "Setting currentAccount to <\(newValue?.name ?? "[N/A]")>")
88+
Log.debug(#file, "Previous account: \(previousAccountId ?? "nil") → New account: \(newAccountId ?? "nil")")
89+
90+
if previousAccountId != newAccountId, previousAccountId != nil {
91+
Log.info(#file, "🔄 Account switch detected - cleaning up active content")
92+
cleanupActiveContentBeforeAccountSwitch(from: previousAccountId, to: newAccountId)
93+
}
94+
8595
self.currentAccount?.hasUpdatedToken = false
8696
currentAccountId = newValue?.uuid
8797
TPPErrorLogger.setUserID(TPPUserAccount.sharedAccount().barcode)
8898
NotificationCenter.default.post(name: .TPPCurrentAccountDidChange, object: nil)
8999
}
90100
}
101+
102+
/// Cleans up active audiobook playback and other content before switching accounts
103+
private func cleanupActiveContentBeforeAccountSwitch(from previousId: String?, to newId: String?) {
104+
Task { @MainActor in
105+
// Clear any active audiobook playback
106+
if let coordinator = NavigationCoordinatorHub.shared.coordinator {
107+
let pathCount = coordinator.path.count
108+
Log.debug(#file, " Navigation path has \(pathCount) items")
109+
110+
// If there's anything in the navigation stack, pop to root to clean up any active content
111+
// This prevents audiobooks or other content from continuing to play with the wrong account context
112+
if pathCount > 0 {
113+
Log.info(#file, " 🔄 Popping to root to clean up active content before account switch")
114+
coordinator.popToRoot()
115+
116+
// Give the UI a moment to clean up
117+
try? await Task.sleep(nanoseconds: 100_000_000) // 0.1s
118+
}
119+
}
120+
}
121+
}
91122

92123
private(set) var currentAccountId: String? {
93124
get { UserDefaults.standard.string(forKey: currentAccountIdentifierKey) }

Palace/Accounts/User/TPPCredentials.swift

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -60,11 +60,13 @@ extension TPPCredentials: Codable {
6060
case .token:
6161
let additionalInfo = try values.nestedContainer(keyedBy: TokenKeys.self, forKey: .associatedTokenData)
6262
let token = try additionalInfo.decode(String.self, forKey: .authToken)
63-
let expirationDate = try additionalInfo.decode(Date.self, forKey: .expirationDate)
63+
// Use decodeIfPresent for optional Date - handles nil gracefully
64+
let expirationDate = try additionalInfo.decodeIfPresent(Date.self, forKey: .expirationDate)
6465

6566
let barcodePinInfo = try values.nestedContainer(keyedBy: BarcodeAndPinKeys.self, forKey: .associatedBarcodeAndPinData)
66-
let barcode = try barcodePinInfo.decode(String.self, forKey: .barcode)
67-
let pin = try barcodePinInfo.decode(String.self, forKey: .pin)
67+
// Use decodeIfPresent for optional barcode/pin - handles nil gracefully
68+
let barcode = try barcodePinInfo.decodeIfPresent(String.self, forKey: .barcode)
69+
let pin = try barcodePinInfo.decodeIfPresent(String.self, forKey: .pin)
6870
self = .token(authToken: token, barcode: barcode, pin: pin, expirationDate: expirationDate)
6971

7072
case .barcodeAndPin:

Palace/Accounts/User/TPPUserAccount.swift

Lines changed: 42 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -46,28 +46,27 @@ private enum StorageKey: String {
4646
var libraryUUID: String? {
4747
didSet {
4848
guard libraryUUID != oldValue else { return }
49-
let variables: [StorageKey: Keyable] = [
50-
StorageKey.authorizationIdentifier: _authorizationIdentifier,
51-
StorageKey.adobeToken: _adobeToken,
52-
StorageKey.licensor: _licensor,
53-
StorageKey.patron: _patron,
54-
StorageKey.adobeVendor: _adobeVendor,
55-
StorageKey.provider: _provider,
56-
StorageKey.userID: _userID,
57-
StorageKey.deviceID: _deviceID,
58-
StorageKey.credentials: _credentials,
59-
StorageKey.authDefinition: _authDefinition,
60-
StorageKey.cookies: _cookies,
61-
62-
// legacy
63-
StorageKey.barcode: _barcode,
64-
StorageKey.PIN: _pin,
65-
StorageKey.authToken: _authToken,
66-
]
67-
68-
for (key, var value) in variables {
69-
value.key = key.keyForLibrary(uuid: libraryUUID)
70-
}
49+
50+
Log.info(#file, "🔐 TPPUserAccount libraryUUID changed: \(oldValue ?? "nil")\(libraryUUID ?? "nil")")
51+
52+
// Update keychain variable keys directly to access account-specific storage
53+
// Setting the key property triggers didSet which resets alreadyInited (forces re-read from keychain)
54+
_authorizationIdentifier.key = StorageKey.authorizationIdentifier.keyForLibrary(uuid: libraryUUID)
55+
_adobeToken.key = StorageKey.adobeToken.keyForLibrary(uuid: libraryUUID)
56+
_licensor.key = StorageKey.licensor.keyForLibrary(uuid: libraryUUID)
57+
_patron.key = StorageKey.patron.keyForLibrary(uuid: libraryUUID)
58+
_adobeVendor.key = StorageKey.adobeVendor.keyForLibrary(uuid: libraryUUID)
59+
_provider.key = StorageKey.provider.keyForLibrary(uuid: libraryUUID)
60+
_userID.key = StorageKey.userID.keyForLibrary(uuid: libraryUUID)
61+
_deviceID.key = StorageKey.deviceID.keyForLibrary(uuid: libraryUUID)
62+
_credentials.key = StorageKey.credentials.keyForLibrary(uuid: libraryUUID)
63+
_authDefinition.key = StorageKey.authDefinition.keyForLibrary(uuid: libraryUUID)
64+
_cookies.key = StorageKey.cookies.keyForLibrary(uuid: libraryUUID)
65+
66+
// Legacy
67+
_barcode.key = StorageKey.barcode.keyForLibrary(uuid: libraryUUID)
68+
_pin.key = StorageKey.PIN.keyForLibrary(uuid: libraryUUID)
69+
_authToken.key = StorageKey.authToken.keyForLibrary(uuid: libraryUUID)
7170
}
7271
}
7372

@@ -175,8 +174,10 @@ private enum StorageKey: String {
175174
}
176175

177176
class func sharedAccount(libraryUUID: String?) -> TPPUserAccount {
178-
shared.accountInfoQueue.async(flags: .barrier) {
179-
shared.libraryUUID = libraryUUID
177+
shared.accountInfoQueue.sync(flags: .barrier) {
178+
if shared.libraryUUID != libraryUUID {
179+
shared.libraryUUID = libraryUUID
180+
}
180181
}
181182
return shared
182183
}
@@ -398,6 +399,23 @@ private enum StorageKey: String {
398399

399400
return expirationDate <= Date() // Expired if date is in the past
400401
}
402+
403+
private enum TokenExpiry {
404+
static let refreshThresholdSeconds: TimeInterval = 300 // 5 minutes
405+
}
406+
407+
/// Returns true if the auth token exists and will expire within 5 minutes.
408+
/// Use this for proactive token refresh before making requests.
409+
var authTokenNearExpiry: Bool {
410+
guard let credentials = credentials,
411+
case let TPPCredentials.token(authToken: _, barcode: _, pin: _, expirationDate: expirationDate) = credentials,
412+
let expirationDate = expirationDate else {
413+
return false
414+
}
415+
416+
let expiryThreshold = Date().addingTimeInterval(TokenExpiry.refreshThresholdSeconds)
417+
return expirationDate <= expiryThreshold
418+
}
401419

402420
var patronFullName: String? {
403421
if let patron = patron,

Palace/AppInfrastructure/AppTabHostView.swift

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ struct AppTabHostView: View {
2626
}
2727
}
2828
.tag(AppTab.catalog)
29+
.accessibilityIdentifier(AccessibilityID.TabBar.catalogTab)
2930

3031
NavigationHostView(rootView: MyBooksView(model: MyBooksViewModel()))
3132
.tabItem {
@@ -35,6 +36,7 @@ struct AppTabHostView: View {
3536
}
3637
}
3738
.tag(AppTab.myBooks)
39+
.accessibilityIdentifier(AccessibilityID.TabBar.myBooksTab)
3840

3941
NavigationHostView(rootView: HoldsView())
4042
.tabItem {
@@ -45,10 +47,12 @@ struct AppTabHostView: View {
4547
}
4648
.badge(holdsBadgeCount)
4749
.tag(AppTab.holds)
50+
.accessibilityIdentifier(AccessibilityID.TabBar.holdsTab)
4851

4952
NavigationHostView(rootView: TPPSettingsView())
5053
.tabItem { Label(Strings.Settings.settings, systemImage: "gearshape") }
5154
.tag(AppTab.settings)
55+
.accessibilityIdentifier(AccessibilityID.TabBar.settingsTab)
5256
}
5357
.tint(Color.accentColor)
5458
.onAppear { AppTabRouterHub.shared.router = router }
@@ -73,16 +77,54 @@ struct AppTabHostView: View {
7377

7478
private extension AppTabHostView {
7579
func updateHoldsBadge() {
80+
// Use test books if debug configuration is enabled, otherwise use real registry data
81+
#if DEBUG
82+
let held: [TPPBook] = DebugSettings.shared.createTestHoldBooks() ?? TPPBookRegistry.shared.heldBooks
83+
let usingTestBooks = DebugSettings.shared.isTestHoldsEnabled
84+
#else
7685
let held = TPPBookRegistry.shared.heldBooks
86+
#endif
87+
7788
var readyCount = 0
89+
7890
for book in held {
7991
book.defaultAcquisition?.availability.matchUnavailable(nil,
8092
limited: nil,
8193
unlimited: nil,
8294
reserved: nil,
8395
ready: { _ in readyCount += 1 })
8496
}
97+
98+
// Update in-app tab badge
8599
holdsBadgeCount = readyCount
100+
101+
// Update home screen app icon badge (only shows books ready to borrow)
102+
#if DEBUG
103+
if DebugSettings.shared.isBadgeLoggingEnabled {
104+
var reservedCount = 0
105+
for book in held {
106+
book.defaultAcquisition?.availability.matchUnavailable(nil, limited: nil, unlimited: nil,
107+
reserved: { _ in reservedCount += 1 }, ready: nil)
108+
}
109+
Log.info(#file, "[DEBUG-BADGE] updateHoldsBadge: source=\(usingTestBooks ? "TEST BOOKS" : "registry"), totalHeld=\(held.count), reserved=\(reservedCount), ready=\(readyCount)")
110+
for (index, book) in held.enumerated() {
111+
var status = "unknown"
112+
book.defaultAcquisition?.availability.matchUnavailable(
113+
{ _ in status = "unavailable" },
114+
limited: { _ in status = "limited" },
115+
unlimited: { _ in status = "unlimited" },
116+
reserved: { r in status = "reserved (pos: \(r.holdPosition))" },
117+
ready: { _ in status = "READY" }
118+
)
119+
Log.info(#file, "[DEBUG-BADGE] Book[\(index)]: '\(book.title)' - status: \(status)")
120+
}
121+
}
122+
#endif
123+
124+
// Update app icon badge on main thread
125+
DispatchQueue.main.async {
126+
UIApplication.shared.applicationIconBadgeNumber = readyCount
127+
}
86128
}
87129
}
88130

0 commit comments

Comments
 (0)