Skip to content

Commit deb4f40

Browse files
Merge pull request #720 from ThePalaceProject/fix/PP-3272-investigate-audiobook-position-loss
[PP-3322] Resolves failed downloads on new signin
2 parents d908757 + 0ecc038 commit deb4f40

37 files changed

+1212
-1624
lines changed

Palace.xcodeproj/project.pbxproj

Lines changed: 23 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,6 @@
5353
11396FB9193D289100E16EE8 /* NSDate+NYPLDateAdditions.m in Sources */ = {isa = PBXBuildFile; fileRef = 11396FB8193D289100E16EE8 /* NSDate+NYPLDateAdditions.m */; };
5454
113DB8A719C24E54004E1154 /* TPPIndeterminateProgressView.m in Sources */ = {isa = PBXBuildFile; fileRef = 113DB8A619C24E54004E1154 /* TPPIndeterminateProgressView.m */; };
5555
114C8CD719BE2FD300719B72 /* TPPAttributedString.m in Sources */ = {isa = PBXBuildFile; fileRef = 114C8CD619BE2FD300719B72 /* TPPAttributedString.m */; };
56-
1158812E1A894F4E008672C3 /* TPPAccountSignInViewController.m in Sources */ = {isa = PBXBuildFile; fileRef = 1158812D1A894F4E008672C3 /* TPPAccountSignInViewController.m */; };
5756
116A5EB91947B57500491A21 /* TPPConfiguration.m in Sources */ = {isa = PBXBuildFile; fileRef = 116A5EB81947B57500491A21 /* TPPConfiguration.m */; };
5857
1183F35B194F847100DC322F /* TPPAsync.m in Sources */ = {isa = PBXBuildFile; fileRef = 1183F35A194F847100DC322F /* TPPAsync.m */; };
5958
118B7ABF195CBF72005CE3E7 /* TPPSession.m in Sources */ = {isa = PBXBuildFile; fileRef = 118B7ABE195CBF72005CE3E7 /* TPPSession.m */; };
@@ -413,7 +412,6 @@
413412
73EB0B1925821DF4006BC997 /* TPPBookDetailsProblemDocumentViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8CE9C470237F84820072E964 /* TPPBookDetailsProblemDocumentViewController.swift */; };
414413
73EB0B1C25821DF4006BC997 /* TPPBook+DistributorChecks.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7384C756252D20AA0012C2DD /* TPPBook+DistributorChecks.swift */; };
415414
73EB0B1D25821DF4006BC997 /* TPPLoginCellTypes.swift in Sources */ = {isa = PBXBuildFile; fileRef = 089E430B24A2459100310360 /* TPPLoginCellTypes.swift */; };
416-
73EB0B1E25821DF4006BC997 /* TPPAccountSignInViewController.m in Sources */ = {isa = PBXBuildFile; fileRef = 1158812D1A894F4E008672C3 /* TPPAccountSignInViewController.m */; };
417415
73EB0B1F25821DF4006BC997 /* UserProfileDocument.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5D3A28CB22D3DA850042B3BD /* UserProfileDocument.swift */; };
418416
73EB0B2025821DF4006BC997 /* AccountsManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03F94CD01DD6288C00CE8F4F /* AccountsManager.swift */; };
419417
73EB0B2125821DF4006BC997 /* TPPLocalization.m in Sources */ = {isa = PBXBuildFile; fileRef = 52592BB721220A1100587288 /* TPPLocalization.m */; };
@@ -662,6 +660,10 @@
662660
E5824BE12994AF4800DE76C2 /* DownloadingBookCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = E5824BE02994AF4800DE76C2 /* DownloadingBookCell.swift */; };
663661
E58565CF269774C400A5FBD5 /* AudioEngine.xcframework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = E58565CE269774C400A5FBD5 /* AudioEngine.xcframework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; };
664662
E58C330A2AD98F61005C44A2 /* EPUBSearchViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = E57345502AD6EB600021D768 /* EPUBSearchViewModel.swift */; };
663+
E58EAD5C2EC7746700CDA626 /* UserAccountPublisher+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = E58EAD5B2EC7746700CDA626 /* UserAccountPublisher+Extensions.swift */; };
664+
E58EAD5D2EC7746700CDA626 /* UserAccountPublisher.swift in Sources */ = {isa = PBXBuildFile; fileRef = E58EAD5A2EC7746700CDA626 /* UserAccountPublisher.swift */; };
665+
E58EAD5E2EC7746700CDA626 /* UserAccountPublisher+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = E58EAD5B2EC7746700CDA626 /* UserAccountPublisher+Extensions.swift */; };
666+
E58EAD5F2EC7746700CDA626 /* UserAccountPublisher.swift in Sources */ = {isa = PBXBuildFile; fileRef = E58EAD5A2EC7746700CDA626 /* UserAccountPublisher.swift */; };
665667
E59526EB28D24FC000C179DA /* AudiobookSample.swift in Sources */ = {isa = PBXBuildFile; fileRef = E59526EA28D24FC000C179DA /* AudiobookSample.swift */; };
666668
E596C14E2E9450AA00214F78 /* CatalogLaneMoreViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = E596C14D2E9450AA00214F78 /* CatalogLaneMoreViewModel.swift */; };
667669
E596C14F2E9450AA00214F78 /* CatalogLaneMoreViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = E596C14D2E9450AA00214F78 /* CatalogLaneMoreViewModel.swift */; };
@@ -1141,8 +1143,6 @@
11411143
1146EE3F1A5DD071009F7576 /* CoreText.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = CoreText.framework; path = System/Library/Frameworks/CoreText.framework; sourceTree = SDKROOT; };
11421144
114C8CD519BE2FD300719B72 /* TPPAttributedString.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = TPPAttributedString.h; sourceTree = "<group>"; };
11431145
114C8CD619BE2FD300719B72 /* TPPAttributedString.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = TPPAttributedString.m; sourceTree = "<group>"; };
1144-
1158812C1A894F4E008672C3 /* TPPAccountSignInViewController.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = TPPAccountSignInViewController.h; sourceTree = "<group>"; };
1145-
1158812D1A894F4E008672C3 /* TPPAccountSignInViewController.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = TPPAccountSignInViewController.m; sourceTree = "<group>"; };
11461146
116A5EB71947B57500491A21 /* TPPConfiguration.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = TPPConfiguration.h; sourceTree = "<group>"; };
11471147
116A5EB81947B57500491A21 /* TPPConfiguration.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = TPPConfiguration.m; sourceTree = "<group>"; };
11481148
1183F359194F847100DC322F /* TPPAsync.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = TPPAsync.h; sourceTree = "<group>"; };
@@ -1578,6 +1578,8 @@
15781578
E5824BE02994AF4800DE76C2 /* DownloadingBookCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DownloadingBookCell.swift; sourceTree = "<group>"; };
15791579
E58565CE269774C400A5FBD5 /* AudioEngine.xcframework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcframework; name = AudioEngine.xcframework; path = Carthage/Build/AudioEngine.xcframework; sourceTree = "<group>"; };
15801580
E58565D2269774D900A5FBD5 /* NYPLAEToolkit.xcframework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcframework; path = NYPLAEToolkit.xcframework; sourceTree = "<group>"; };
1581+
E58EAD5A2EC7746700CDA626 /* UserAccountPublisher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserAccountPublisher.swift; sourceTree = "<group>"; };
1582+
E58EAD5B2EC7746700CDA626 /* UserAccountPublisher+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UserAccountPublisher+Extensions.swift"; sourceTree = "<group>"; };
15811583
E58F09B92988269200DAC72A /* de */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = de; path = de.lproj/InfoPlist.strings; sourceTree = "<group>"; };
15821584
E58F09BA2988269700DAC72A /* fr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = fr; path = fr.lproj/InfoPlist.strings; sourceTree = "<group>"; };
15831585
E58F09BB2988269900DAC72A /* it */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = it; path = it.lproj/InfoPlist.strings; sourceTree = "<group>"; };
@@ -2142,8 +2144,6 @@
21422144
isa = PBXGroup;
21432145
children = (
21442146
E5E2F8D32CB631C100A95840 /* TPPSAMLHelper.swift */,
2145-
1158812C1A894F4E008672C3 /* TPPAccountSignInViewController.h */,
2146-
1158812D1A894F4E008672C3 /* TPPAccountSignInViewController.m */,
21472147
53CCA7049DE640BABC8D20B8 /* SignInModalView.swift */,
21482148
086C45D524AE77CA00F5108E /* TPPBasicAuth.swift */,
21492149
089E42C5249A823800310360 /* TPPCookiesWebViewController.swift */,
@@ -2592,6 +2592,9 @@
25922592
73DE532F2525A2EC003E2C56 /* User */ = {
25932593
isa = PBXGroup;
25942594
children = (
2595+
7394FBD8252F936E00037515 /* Announcements */,
2596+
E58EAD5A2EC7746700CDA626 /* UserAccountPublisher.swift */,
2597+
E58EAD5B2EC7746700CDA626 /* UserAccountPublisher+Extensions.swift */,
25952598
0857A0F62478337D00C7984E /* TPPCredentials.swift */,
25962599
17CE5304243C020800315E63 /* TPPUserAccount.swift */,
25972600
5D3A28CB22D3DA850042B3BD /* UserProfileDocument.swift */,
@@ -2803,7 +2806,6 @@
28032806
E759B2052A1BF7AA0041B075 /* PalaceDebug.entitlements */,
28042807
E59892DD28A9AA7400C44A85 /* Samples */,
28052808
73DE53292525A2AD003E2C56 /* Accounts */,
2806-
7394FBD8252F936E00037515 /* Announcements */,
28072809
73225DE5250B46CE00EF1877 /* AppInfrastructure */,
28082810
21E7E07424FEA7C100189224 /* Audiobooks */,
28092811
73B5DFD72605296300225C12 /* Book */,
@@ -3958,6 +3960,8 @@
39583960
21D746E82718A4C000C0E1B4 /* AdobeDRMError.swift in Sources */,
39593961
E5AD72F32E5526B1005A8070 /* CatalogLaneMoreView.swift in Sources */,
39603962
73EB0A9425821DF4006BC997 /* UIButton+NYPLAppearanceAdditions.m in Sources */,
3963+
E58EAD5E2EC7746700CDA626 /* UserAccountPublisher+Extensions.swift in Sources */,
3964+
E58EAD5F2EC7746700CDA626 /* UserAccountPublisher.swift in Sources */,
39613965
E5E2F8D52CB631C700A95840 /* TPPSAMLHelper.swift in Sources */,
39623966
73EB0A9625821DF4006BC997 /* SEMigrations.swift in Sources */,
39633967
21E41799292813A000A78606 /* AsyncImage.swift in Sources */,
@@ -4203,7 +4207,6 @@
42034207
E727EFAF2A1BFB20006AB1F2 /* DLNavigator.swift in Sources */,
42044208
E50546C42E621981007CCFAB /* NavigationHostView.swift in Sources */,
42054209
E5515EC22AD8E843000BDFE9 /* UIHostingController+Extensions.swift in Sources */,
4206-
73EB0B1E25821DF4006BC997 /* TPPAccountSignInViewController.m in Sources */,
42074210
8DB340D04268405FAA8954D3 /* SignInModalView.swift in Sources */,
42084211
73EB0B1F25821DF4006BC997 /* UserProfileDocument.swift in Sources */,
42094212
21E41777292810E000A78606 /* TPPPDFDocumentMetadata.swift in Sources */,
@@ -4488,6 +4491,8 @@
44884491
732F474B260B224A00E2CB64 /* TPPBookmarkSpec.swift in Sources */,
44894492
2D754BBB2002E2FB0061D34F /* TPPOPDSAcquisition.m in Sources */,
44904493
E75F4A2929C3AB1F006DFBD8 /* TPPPublicationSpeechSynthesizer.swift in Sources */,
4494+
E58EAD5C2EC7746700CDA626 /* UserAccountPublisher+Extensions.swift in Sources */,
4495+
E58EAD5D2EC7746700CDA626 /* UserAccountPublisher.swift in Sources */,
44914496
E5E4A9CC2EB055BB00CC1D67 /* PersistentLogger.swift in Sources */,
44924497
E5E4A9CD2EB055BB00CC1D67 /* ErrorLogExporter.swift in Sources */,
44934498
E5E4A9C52EB0558600CC1D67 /* CrashRecoveryService.swift in Sources */,
@@ -4583,7 +4588,6 @@
45834588
E5AD72F02E55218A005A8070 /* NetworkClient.swift in Sources */,
45844589
E562DD962D35AB8F003A1A40 /* BookDetailViewModel.swift in Sources */,
45854590
089E430C24A2459100310360 /* TPPLoginCellTypes.swift in Sources */,
4586-
1158812E1A894F4E008672C3 /* TPPAccountSignInViewController.m in Sources */,
45874591
8DB340CF4268405FAA8954D2 /* SignInModalView.swift in Sources */,
45884592
E59526EB28D24FC000C179DA /* AudiobookSample.swift in Sources */,
45894593
E5B2B8DA275952EC00150ED4 /* TPPSettingsView.swift in Sources */,
@@ -4848,7 +4852,7 @@
48484852
CODE_SIGN_IDENTITY = "Apple Distribution";
48494853
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution";
48504854
CODE_SIGN_STYLE = Manual;
4851-
CURRENT_PROJECT_VERSION = 408;
4855+
CURRENT_PROJECT_VERSION = 411;
48524856
DEVELOPMENT_TEAM = 88CBA74T8K;
48534857
"DEVELOPMENT_TEAM[sdk=iphoneos*]" = 88CBA74T8K;
48544858
ENABLE_BITCODE = NO;
@@ -4869,7 +4873,7 @@
48694873
"$(inherited)",
48704874
"@executable_path/Frameworks",
48714875
);
4872-
MARKETING_VERSION = 2.0.5;
4876+
MARKETING_VERSION = 2.1.0;
48734877
PRODUCT_BUNDLE_IDENTIFIER = org.thepalaceproject.palace;
48744878
PRODUCT_MODULE_NAME = Palace;
48754879
PRODUCT_NAME = "Palace-noDRM";
@@ -4905,7 +4909,7 @@
49054909
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES_ERROR;
49064910
CODE_SIGN_ENTITLEMENTS = Palace/SimplyE.entitlements;
49074911
CODE_SIGN_IDENTITY = "iPhone Distribution";
4908-
CURRENT_PROJECT_VERSION = 408;
4912+
CURRENT_PROJECT_VERSION = 411;
49094913
DEVELOPMENT_TEAM = 88CBA74T8K;
49104914
"DEVELOPMENT_TEAM[sdk=iphoneos*]" = 88CBA74T8K;
49114915
ENABLE_BITCODE = NO;
@@ -4926,7 +4930,7 @@
49264930
"$(inherited)",
49274931
"@executable_path/Frameworks",
49284932
);
4929-
MARKETING_VERSION = 2.0.5;
4933+
MARKETING_VERSION = 2.1.0;
49304934
PRODUCT_BUNDLE_IDENTIFIER = org.thepalaceproject.palace;
49314935
PRODUCT_MODULE_NAME = Palace;
49324936
PRODUCT_NAME = "Palace-noDRM";
@@ -5090,9 +5094,9 @@
50905094
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES_ERROR;
50915095
CODE_SIGN_ENTITLEMENTS = Palace/PalaceDebug.entitlements;
50925096
CODE_SIGN_IDENTITY = "Apple Development";
5093-
CODE_SIGN_STYLE = Automatic;
5094-
CURRENT_PROJECT_VERSION = 408;
5095-
DEVELOPMENT_TEAM = 88CBA74T8K;
5097+
CODE_SIGN_STYLE = Manual;
5098+
CURRENT_PROJECT_VERSION = 411;
5099+
DEVELOPMENT_TEAM = "";
50965100
ENABLE_BITCODE = NO;
50975101
FRAMEWORK_SEARCH_PATHS = (
50985102
"$(inherited)",
@@ -5117,7 +5121,7 @@
51175121
"$(inherited)",
51185122
"@executable_path/Frameworks",
51195123
);
5120-
MARKETING_VERSION = 2.0.5;
5124+
MARKETING_VERSION = 2.1.0;
51215125
PRODUCT_BUNDLE_IDENTIFIER = org.thepalaceproject.palace;
51225126
PROVISIONING_PROFILE_SPECIFIER = "";
51235127
RUN_CLANG_STATIC_ANALYZER = YES;
@@ -5151,7 +5155,7 @@
51515155
CODE_SIGN_IDENTITY = "Apple Development";
51525156
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution";
51535157
CODE_SIGN_STYLE = Manual;
5154-
CURRENT_PROJECT_VERSION = 408;
5158+
CURRENT_PROJECT_VERSION = 411;
51555159
DEVELOPMENT_TEAM = "";
51565160
"DEVELOPMENT_TEAM[sdk=iphoneos*]" = 88CBA74T8K;
51575161
ENABLE_BITCODE = NO;
@@ -5178,7 +5182,7 @@
51785182
"$(inherited)",
51795183
"@executable_path/Frameworks",
51805184
);
5181-
MARKETING_VERSION = 2.0.5;
5185+
MARKETING_VERSION = 2.1.0;
51825186
PRODUCT_BUNDLE_IDENTIFIER = org.thepalaceproject.palace;
51835187
PROVISIONING_PROFILE_SPECIFIER = "";
51845188
"PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]" = "App Store";

Palace/Accounts/Library/AccountsManager.swift

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -177,7 +177,7 @@ let currentAccountIdentifierKey = "TPPCurrentAccountIdentifier"
177177
if addLoadingHandler(for: hash, completion) { return }
178178

179179
Log.debug(#file, "Loading catalogs for hash \(hash)")
180-
TPPNetworkExecutor(cachingStrategy: .fallback).GET(targetUrl, useTokenIfAvailable: false) { [weak self] result in
180+
TPPNetworkExecutor.shared.GET(targetUrl, useTokenIfAvailable: false) { [weak self] result in
181181
guard let self = self else { return }
182182
switch result {
183183
case .success(let data, _):
@@ -187,14 +187,17 @@ let currentAccountIdentifierKey = "TPPCurrentAccountIdentifier"
187187
self.callAndClearLoadingHandlers(for: hash, success)
188188
}
189189

190-
case .failure:
190+
case .failure(let error, _):
191+
Log.error(#file, "Failed to load catalogs from network: \(error.localizedDescription)")
191192
// fallback to disk
192193
if let data = self.readCachedAccountsCatalogData(hash: hash) {
194+
Log.info(#file, "Using cached catalog data as fallback")
193195
self.loadAccountSetsAndAuthDoc(fromCatalogData: data, key: hash) { success in
194196
NotificationCenter.default.post(name: .TPPCatalogDidLoad, object: nil)
195197
self.callAndClearLoadingHandlers(for: hash, success)
196198
}
197199
} else {
200+
Log.error(#file, "No cached catalog data available, catalog load failed completely")
198201
// truly failed
199202
self.callAndClearLoadingHandlers(for: hash, false)
200203
}

Palace/Announcements/TPPAnnouncementBusinessLogic.swift renamed to Palace/Accounts/User/Announcements/TPPAnnouncementBusinessLogic.swift

File renamed without changes.

Palace/Accounts/User/TPPUserAccount.swift

Lines changed: 19 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -175,7 +175,7 @@ private enum StorageKey: String {
175175
}
176176

177177
class func sharedAccount(libraryUUID: String?) -> TPPUserAccount {
178-
shared.accountInfoQueue.sync {
178+
shared.accountInfoQueue.async(flags: .barrier) {
179179
shared.libraryUUID = libraryUUID
180180
}
181181
return shared
@@ -187,6 +187,12 @@ private enum StorageKey: String {
187187
}
188188

189189
private func notifyAccountDidChange() {
190+
// Update modern Combine publisher
191+
Task { @MainActor in
192+
UserAccountPublisher.shared.updateState(from: self)
193+
}
194+
195+
// Maintain backward compatibility with legacy notification system
190196
NotificationCenter.default.post(
191197
name: Notification.Name.TPPUserAccountDidChange,
192198
object: self
@@ -258,19 +264,16 @@ private enum StorageKey: String {
258264
func isTokenRefreshRequired() -> Bool {
259265
guard let authDefinition = authDefinition else { return false }
260266

261-
// Basic-token auth: only refresh if token EXPIRED (not just missing)
262267
if authDefinition.isToken {
263268
guard authDefinition.tokenURL != nil,
264269
username != nil,
265270
pin != nil else {
266271
return false
267272
}
268-
// Only refresh if we have a token that expired
269-
// Don't refresh if token is simply missing (user needs to login first)
273+
270274
return authTokenHasExpired
271275
}
272276

273-
// OAuth: can refresh if token is missing or expired
274277
let isOAuthAndNeedsRefresh = authDefinition.isOauth &&
275278
!hasAuthToken() &&
276279
(authDefinition.tokenURL != nil)
@@ -483,6 +486,7 @@ private enum StorageKey: String {
483486
keychainTransaction.perform {
484487
_credentials.write(.token(authToken: token, barcode: barcode, pin: pin, expirationDate: expirationDate))
485488
}
489+
notifyAccountDidChange()
486490
}
487491

488492
@objc(setCookies:)
@@ -531,13 +535,18 @@ private enum StorageKey: String {
531535
_barcode.write(nil)
532536
_pin.write(nil)
533537
_authToken.write(nil)
534-
535-
notifyAccountDidChange()
536-
537-
NotificationCenter.default.post(name: Notification.Name.TPPDidSignOut,
538-
object: nil)
539538
}
540539
}
540+
541+
// Post events after releasing the queue lock to prevent deadlock
542+
// Update modern Combine publisher
543+
Task { @MainActor in
544+
UserAccountPublisher.shared.signOut()
545+
}
546+
547+
// Maintain backward compatibility with legacy notification system
548+
notifyAccountDidChange()
549+
NotificationCenter.default.post(name: Notification.Name.TPPDidSignOut, object: nil)
541550
}
542551
}
543552

0 commit comments

Comments
 (0)