From 3d7252ce6d0b56dda6c34b5b53e304fd92fb18ac Mon Sep 17 00:00:00 2001 From: diyaa Date: Sun, 31 May 2026 13:20:07 +0200 Subject: [PATCH] Add iPhone local favorites --- apps/apple/Velody.xcodeproj/project.pbxproj | 145 ++++++ .../xcschemes/VelodyiPhone.xcscheme | 106 ++++ .../Sources/iPhoneLibraryView.swift | 41 +- .../Sources/iPhoneLibraryViewModel.swift | 125 ++++- ...iPhoneLibraryViewModelFavoritesTests.swift | 451 ++++++++++++++++++ apps/apple/project.yml | 25 + .../FavoriteTrackStore.swift | 111 +++++ .../FavoriteTrackStoreTests.swift | 113 +++++ 8 files changed, 1103 insertions(+), 14 deletions(-) create mode 100644 apps/apple/Velody.xcodeproj/xcshareddata/xcschemes/VelodyiPhone.xcscheme create mode 100644 apps/apple/VelodyiPhone/Tests/iPhoneLibraryViewModelFavoritesTests.swift create mode 100644 packages/apple/VelodyPersistence/Sources/VelodyPersistence/FavoriteTrackStore.swift create mode 100644 packages/apple/VelodyPersistence/Tests/VelodyPersistenceTests/FavoriteTrackStoreTests.swift diff --git a/apps/apple/Velody.xcodeproj/project.pbxproj b/apps/apple/Velody.xcodeproj/project.pbxproj index 80cd0c9..a882e61 100644 --- a/apps/apple/Velody.xcodeproj/project.pbxproj +++ b/apps/apple/Velody.xcodeproj/project.pbxproj @@ -14,26 +14,44 @@ 3B9765B34963F430467B7527 /* LocalMusicScanner.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3CAD2FE907C9C90FBE01E7D4 /* LocalMusicScanner.swift */; }; 3D22DE55C4A27A4DE68E6359 /* VelodySync in Frameworks */ = {isa = PBXBuildFile; productRef = DA5EBADF45BC0977F73F241C /* VelodySync */; }; 3D2A2E7D9371C62F8F86DD84 /* VelodyDomain in Frameworks */ = {isa = PBXBuildFile; productRef = 3C910108376E6ECEF152DCE1 /* VelodyDomain */; }; + 4F8B36EB80C008CDD9B2F6A6 /* VelodyUtilities in Frameworks */ = {isa = PBXBuildFile; productRef = C199F1B9F55E51FD373EA729 /* VelodyUtilities */; }; + 58E2E18AAC9318AB98F81004 /* VelodyDomain in Frameworks */ = {isa = PBXBuildFile; productRef = 14CD56063C10911F40C9CBA3 /* VelodyDomain */; }; 5D2616BFA3DD5E131EC928F2 /* iPhoneLibraryViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = AE56EE8AD38DC563003A7979 /* iPhoneLibraryViewModel.swift */; }; 7174D80FB45839E82F150613 /* VelodyMacApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 96A45EA6FB9536D0CD91874C /* VelodyMacApp.swift */; }; 783EBF82108F7A04B6DD33B4 /* VelodyPersistence in Frameworks */ = {isa = PBXBuildFile; productRef = 4DD09D7C123E184CEC0A2F4D /* VelodyPersistence */; }; 8A3C2C7EB14F364D682A92B7 /* VelodyNetworking in Frameworks */ = {isa = PBXBuildFile; productRef = 713279722B202FB1CF4A869E /* VelodyNetworking */; }; 93F386D6A8B0131EAB50E2B9 /* VelodyDomain in Frameworks */ = {isa = PBXBuildFile; productRef = 48BF8F8596E7A86383A9CCD1 /* VelodyDomain */; }; + A2DDB28F9916D0DFB1ADD4FF /* VelodyNetworking in Frameworks */ = {isa = PBXBuildFile; productRef = A5B134A54FAD5F2EB5ECBE57 /* VelodyNetworking */; }; A54D8AD8A59D8B77FCA0794F /* MacLibraryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB28FE17346E100F697C1BF4 /* MacLibraryView.swift */; }; AB6C7E42A3A850D395E4F5E7 /* VelodySync in Frameworks */ = {isa = PBXBuildFile; productRef = 2449C403E81DD84D7A8DD7E1 /* VelodySync */; }; + AC8B414ECE5493BD52DEC44A /* VelodyPersistence in Frameworks */ = {isa = PBXBuildFile; productRef = B99672FF5519DDF310A5EBD1 /* VelodyPersistence */; }; D0D65CE73B9DFF3C73F432DB /* VelodyUtilities in Frameworks */ = {isa = PBXBuildFile; productRef = B15F842ACBB110CC8A766669 /* VelodyUtilities */; }; D4B554447B262C7B946ED21F /* VelodyPersistence in Frameworks */ = {isa = PBXBuildFile; productRef = C8F5FF593C4DB829D1CDD497 /* VelodyPersistence */; }; + DCB814642BA3F081D4B5A3BE /* iPhoneLibraryViewModelFavoritesTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6DE70FE94372F028D76DC335 /* iPhoneLibraryViewModelFavoritesTests.swift */; }; + E4627FCDF7EB169D83EA50DB /* VelodySync in Frameworks */ = {isa = PBXBuildFile; productRef = A571882D6B46CA9F729D13CB /* VelodySync */; }; EE48EF0688C7E33CDA783234 /* VelodyNetworking in Frameworks */ = {isa = PBXBuildFile; productRef = 0682A261A6F2F050F4B83AF6 /* VelodyNetworking */; }; FB68EF710F2B6FBAF80F63F0 /* VelodyUtilities in Frameworks */ = {isa = PBXBuildFile; productRef = AAD3F903A475AA0B0159C79E /* VelodyUtilities */; }; FB76843BB27CCCD2B0CFF11D /* iPhoneLibraryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD8848AEA828FE5B2607EB46 /* iPhoneLibraryView.swift */; }; /* End PBXBuildFile section */ +/* Begin PBXContainerItemProxy section */ + 488EB06E63B557EFEA13BE16 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = D14070EEF27F35DD4F5F547B /* Project object */; + proxyType = 1; + remoteGlobalIDString = BA256DA9698C16C35E28D0EF; + remoteInfo = VelodyiPhone; + }; +/* End PBXContainerItemProxy section */ + /* Begin PBXFileReference section */ 07508485E10C6E2942FE29AB /* VelodySync */ = {isa = PBXFileReference; lastKnownFileType = folder; name = VelodySync; path = ../../packages/apple/VelodySync; sourceTree = SOURCE_ROOT; }; 0F6993844F6FD7E86D52EC25 /* VelodyNetworking */ = {isa = PBXFileReference; lastKnownFileType = folder; name = VelodyNetworking; path = ../../packages/apple/VelodyNetworking; sourceTree = SOURCE_ROOT; }; 15A17C02F8CBB0A492A82C14 /* VelodyPlayback */ = {isa = PBXFileReference; lastKnownFileType = folder; name = VelodyPlayback; path = ../../packages/apple/VelodyPlayback; sourceTree = SOURCE_ROOT; }; + 1913BA882BB97E1B90C3B30B /* VelodyiPhoneTests.xctest */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.cfbundle; path = VelodyiPhoneTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 3CAD2FE907C9C90FBE01E7D4 /* LocalMusicScanner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalMusicScanner.swift; sourceTree = ""; }; 5EC9AED651FE0AB193AAFE94 /* FolderAccessService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FolderAccessService.swift; sourceTree = ""; }; + 6DE70FE94372F028D76DC335 /* iPhoneLibraryViewModelFavoritesTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = iPhoneLibraryViewModelFavoritesTests.swift; sourceTree = ""; }; 6E7BCB85A35B8286E4472822 /* VelodyPersistence */ = {isa = PBXFileReference; lastKnownFileType = folder; name = VelodyPersistence; path = ../../packages/apple/VelodyPersistence; sourceTree = SOURCE_ROOT; }; 7A1BDB40E1A38A685237BCF3 /* VelodyMac.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = VelodyMac.app; sourceTree = BUILT_PRODUCTS_DIR; }; 89FE56E825FC42D026EAC784 /* VelodyUtilities */ = {isa = PBXFileReference; lastKnownFileType = folder; name = VelodyUtilities; path = ../../packages/apple/VelodyUtilities; sourceTree = SOURCE_ROOT; }; @@ -73,9 +91,30 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + CE2E7DEBA00CD6E8FB4A4FA0 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 58E2E18AAC9318AB98F81004 /* VelodyDomain in Frameworks */, + A2DDB28F9916D0DFB1ADD4FF /* VelodyNetworking in Frameworks */, + AC8B414ECE5493BD52DEC44A /* VelodyPersistence in Frameworks */, + E4627FCDF7EB169D83EA50DB /* VelodySync in Frameworks */, + 4F8B36EB80C008CDD9B2F6A6 /* VelodyUtilities in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ + 57CBC02BBC37680DB422EDDE /* Tests */ = { + isa = PBXGroup; + children = ( + 6DE70FE94372F028D76DC335 /* iPhoneLibraryViewModelFavoritesTests.swift */, + ); + name = Tests; + path = VelodyiPhone/Tests; + sourceTree = ""; + }; 72E9140E6A9783B11030B506 /* Sources */ = { isa = PBXGroup; children = ( @@ -106,6 +145,7 @@ 770229FFB4009FE9ED8F7FA1 /* Packages */, 72E9140E6A9783B11030B506 /* Sources */, C055BC0585BDA296E6A6D9F8 /* Sources */, + 57CBC02BBC37680DB422EDDE /* Tests */, E7EA6D658DCAE7B098AACC54 /* Products */, ); sourceTree = ""; @@ -127,6 +167,7 @@ isa = PBXGroup; children = ( BF223F7E40D4B594935F5BC2 /* VelodyiPhone.app */, + 1913BA882BB97E1B90C3B30B /* VelodyiPhoneTests.xctest */, 7A1BDB40E1A38A685237BCF3 /* VelodyMac.app */, ); name = Products; @@ -159,6 +200,30 @@ productReference = 7A1BDB40E1A38A685237BCF3 /* VelodyMac.app */; productType = "com.apple.product-type.application"; }; + 75B4E7EF9F920A90B66109DD /* VelodyiPhoneTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 47B0505DA66C0C0151085FB5 /* Build configuration list for PBXNativeTarget "VelodyiPhoneTests" */; + buildPhases = ( + 3910E580B4AC84E7F67D2A67 /* Sources */, + CE2E7DEBA00CD6E8FB4A4FA0 /* Frameworks */, + ); + buildRules = ( + ); + dependencies = ( + 6E19F3A327667FEB6B990077 /* PBXTargetDependency */, + ); + name = VelodyiPhoneTests; + packageProductDependencies = ( + 14CD56063C10911F40C9CBA3 /* VelodyDomain */, + A5B134A54FAD5F2EB5ECBE57 /* VelodyNetworking */, + B99672FF5519DDF310A5EBD1 /* VelodyPersistence */, + A571882D6B46CA9F729D13CB /* VelodySync */, + C199F1B9F55E51FD373EA729 /* VelodyUtilities */, + ); + productName = VelodyiPhoneTests; + productReference = 1913BA882BB97E1B90C3B30B /* VelodyiPhoneTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; BA256DA9698C16C35E28D0EF /* VelodyiPhone */ = { isa = PBXNativeTarget; buildConfigurationList = 6BBCD1EA2ADBFF580325615C /* Build configuration list for PBXNativeTarget "VelodyiPhone" */; @@ -215,6 +280,7 @@ targets = ( 35991DE50CE00DB09C257624 /* VelodyMac */, BA256DA9698C16C35E28D0EF /* VelodyiPhone */, + 75B4E7EF9F920A90B66109DD /* VelodyiPhoneTests */, ); }; /* End PBXProject section */ @@ -230,6 +296,14 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 3910E580B4AC84E7F67D2A67 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + DCB814642BA3F081D4B5A3BE /* iPhoneLibraryViewModelFavoritesTests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; 82777CB01A8F49D7D29F03D0 /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; @@ -244,6 +318,14 @@ }; /* End PBXSourcesBuildPhase section */ +/* Begin PBXTargetDependency section */ + 6E19F3A327667FEB6B990077 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = BA256DA9698C16C35E28D0EF /* VelodyiPhone */; + targetProxy = 488EB06E63B557EFEA13BE16 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + /* Begin XCBuildConfiguration section */ 384C8F5F47E4F21667BF35CD /* Debug */ = { isa = XCBuildConfiguration; @@ -307,6 +389,23 @@ }; name = Debug; }; + 3DCD500A04B056B2DDF1ED8A /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@loader_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = de.diyaa.velody.iphone.tests; + SDKROOT = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/VelodyiPhone.app/VelodyiPhone"; + }; + name = Debug; + }; 69FECBCBCA0B51ADDBD79E78 /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { @@ -396,6 +495,23 @@ }; name = Release; }; + CA2342EB0A1A156E1CEDEFD1 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@loader_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = de.diyaa.velody.iphone.tests; + SDKROOT = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/VelodyiPhone.app/VelodyiPhone"; + }; + name = Release; + }; D92AAC4C7558EFC853CF352F /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { @@ -433,6 +549,15 @@ /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ + 47B0505DA66C0C0151085FB5 /* Build configuration list for PBXNativeTarget "VelodyiPhoneTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 3DCD500A04B056B2DDF1ED8A /* Debug */, + CA2342EB0A1A156E1CEDEFD1 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Debug; + }; 6BBCD1EA2ADBFF580325615C /* Build configuration list for PBXNativeTarget "VelodyiPhone" */ = { isa = XCConfigurationList; buildConfigurations = ( @@ -494,6 +619,10 @@ isa = XCSwiftPackageProductDependency; productName = VelodyNetworking; }; + 14CD56063C10911F40C9CBA3 /* VelodyDomain */ = { + isa = XCSwiftPackageProductDependency; + productName = VelodyDomain; + }; 2449C403E81DD84D7A8DD7E1 /* VelodySync */ = { isa = XCSwiftPackageProductDependency; productName = VelodySync; @@ -514,6 +643,14 @@ isa = XCSwiftPackageProductDependency; productName = VelodyNetworking; }; + A571882D6B46CA9F729D13CB /* VelodySync */ = { + isa = XCSwiftPackageProductDependency; + productName = VelodySync; + }; + A5B134A54FAD5F2EB5ECBE57 /* VelodyNetworking */ = { + isa = XCSwiftPackageProductDependency; + productName = VelodyNetworking; + }; AAD3F903A475AA0B0159C79E /* VelodyUtilities */ = { isa = XCSwiftPackageProductDependency; productName = VelodyUtilities; @@ -522,6 +659,14 @@ isa = XCSwiftPackageProductDependency; productName = VelodyUtilities; }; + B99672FF5519DDF310A5EBD1 /* VelodyPersistence */ = { + isa = XCSwiftPackageProductDependency; + productName = VelodyPersistence; + }; + C199F1B9F55E51FD373EA729 /* VelodyUtilities */ = { + isa = XCSwiftPackageProductDependency; + productName = VelodyUtilities; + }; C8F5FF593C4DB829D1CDD497 /* VelodyPersistence */ = { isa = XCSwiftPackageProductDependency; productName = VelodyPersistence; diff --git a/apps/apple/Velody.xcodeproj/xcshareddata/xcschemes/VelodyiPhone.xcscheme b/apps/apple/Velody.xcodeproj/xcshareddata/xcschemes/VelodyiPhone.xcscheme new file mode 100644 index 0000000..4dc98f1 --- /dev/null +++ b/apps/apple/Velody.xcodeproj/xcshareddata/xcschemes/VelodyiPhone.xcscheme @@ -0,0 +1,106 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/apple/VelodyiPhone/Sources/iPhoneLibraryView.swift b/apps/apple/VelodyiPhone/Sources/iPhoneLibraryView.swift index f310f87..1af6028 100644 --- a/apps/apple/VelodyiPhone/Sources/iPhoneLibraryView.swift +++ b/apps/apple/VelodyiPhone/Sources/iPhoneLibraryView.swift @@ -28,10 +28,20 @@ struct iPhoneLibraryView: View { Spacer() if let trackID = viewModel.nowPlaying.trackID { - Button(viewModel.nowPlaying.isPlaying ? "Pause" : "Play") { - viewModel.togglePlayback(trackID: trackID) + HStack(spacing: 12) { + if viewModel.nowPlayingFavoriteTrackID == trackID { + favoriteButton(isFavorite: viewModel.isNowPlayingTrackFavorite) { + Task { + await viewModel.toggleFavorite(trackID: trackID) + } + } + } + + Button(viewModel.nowPlaying.isPlaying ? "Pause" : "Play") { + viewModel.togglePlayback(trackID: trackID) + } + .buttonStyle(.borderedProminent) } - .buttonStyle(.borderedProminent) } } } @@ -61,6 +71,12 @@ struct iPhoneLibraryView: View { Spacer() + favoriteButton(isFavorite: track.isFavorite) { + Task { + await viewModel.toggleFavorite(trackID: track.id) + } + } + Text(track.statusText) .font(.caption.weight(.semibold)) .padding(.horizontal, 10) @@ -139,6 +155,12 @@ struct iPhoneLibraryView: View { Spacer() + favoriteButton(isFavorite: track.isFavorite) { + Task { + await viewModel.toggleFavorite(trackID: track.id) + } + } + Button(track.playButtonTitle) { viewModel.togglePlayback(trackID: track.id) } @@ -255,6 +277,19 @@ struct iPhoneLibraryView: View { return .red } } + + private func favoriteButton( + isFavorite: Bool, + action: @escaping () -> Void + ) -> some View { + Button(action: action) { + Image(systemName: isFavorite ? "heart.fill" : "heart") + .font(.title3.weight(.semibold)) + .foregroundStyle(isFavorite ? .red : .secondary) + } + .buttonStyle(.borderless) + .accessibilityLabel(isFavorite ? "Remove Favorite" : "Add Favorite") + } } private struct ArtworkThumbnailView: View { diff --git a/apps/apple/VelodyiPhone/Sources/iPhoneLibraryViewModel.swift b/apps/apple/VelodyiPhone/Sources/iPhoneLibraryViewModel.swift index 1b2418b..8defbf9 100644 --- a/apps/apple/VelodyiPhone/Sources/iPhoneLibraryViewModel.swift +++ b/apps/apple/VelodyiPhone/Sources/iPhoneLibraryViewModel.swift @@ -194,16 +194,20 @@ final class iPhoneLibraryViewModel { isPlaying: false, errorMessage: nil ) + var nowPlayingFavoriteTrackID: String? + var isNowPlayingTrackFavorite = false private let environment: ServerEnvironment private let apiClient: any VelodyAPIClient private let syncService: RemoteLibrarySyncService private let offlineLibraryService: OfflineLibraryService + private let favoriteTrackStore: any FavoriteTrackStore private let keychainService: any KeychainService private let player: any iPhoneLocalAudioPlaying private var cachedRemoteTracksByID: [String: RemoteTrack] = [:] private var cachedRemoteLibraryTracks: [OfflineLibraryRemoteTrack] = [] private var cachedAvailableOfflineTracks: [OfflineLibraryTrack] = [] + private var cachedFavoriteTrackRecordsByID: [String: FavoriteTrackRecord] = [:] private var hasLoaded = false var hasActiveSearch: Bool { @@ -214,7 +218,7 @@ final class iPhoneLibraryViewModel { !cachedRemoteLibraryTracks.isEmpty } - init( + convenience init( player: (any iPhoneLocalAudioPlaying)? = nil, keychainService: any KeychainService = SystemKeychainService( service: "de.diyaa.velody.iphone" @@ -229,6 +233,7 @@ final class iPhoneLibraryViewModel { let downloadStateStore = Self.makeRemoteTrackDownloadStateStore() let audioFileStore = Self.makeOfflineAudioFileStore() let artworkStore = Self.makeArtworkStore() + let favoriteTrackStore = Self.makeFavoriteTrackStore() let repository = DefaultRemoteLibraryRepository( apiClient: apiClient, store: store @@ -240,16 +245,38 @@ final class iPhoneLibraryViewModel { artworkStore: artworkStore ) - self.environment = environment - self.apiClient = apiClient - self.keychainService = keychainService - self.player = player ?? iPhoneLocalAudioPlayer() - self.syncService = syncService - self.offlineLibraryService = OfflineLibraryService( + let offlineLibraryService = OfflineLibraryService( syncService: syncService, audioFileStore: audioFileStore, artworkStore: artworkStore ) + self.init( + environment: environment, + apiClient: apiClient, + syncService: syncService, + offlineLibraryService: offlineLibraryService, + favoriteTrackStore: favoriteTrackStore, + player: player ?? iPhoneLocalAudioPlayer(), + keychainService: keychainService + ) + } + + init( + environment: ServerEnvironment, + apiClient: any VelodyAPIClient, + syncService: RemoteLibrarySyncService, + offlineLibraryService: OfflineLibraryService, + favoriteTrackStore: any FavoriteTrackStore, + player: any iPhoneLocalAudioPlaying, + keychainService: any KeychainService + ) { + self.environment = environment + self.apiClient = apiClient + self.syncService = syncService + self.offlineLibraryService = offlineLibraryService + self.favoriteTrackStore = favoriteTrackStore + self.keychainService = keychainService + self.player = player self.player.onStateChange = { [weak self] state in self?.handleNowPlayingStateChange(state) } @@ -259,9 +286,20 @@ final class iPhoneLibraryViewModel { guard !hasLoaded else { return } hasLoaded = true + var favoriteRestoreError: Error? + + do { + try await reloadFavoriteTracks() + } catch { + favoriteRestoreError = error + } + do { let snapshot = try await reloadOfflineLibrary() applyRestoredTracks(snapshot) + if let favoriteRestoreError { + syncStatus += " Favorites could not be restored: \(favoriteRestoreError.localizedDescription)" + } } catch { state = .idle syncStatus = "Failed to load cached remote library: \(error.localizedDescription)" @@ -354,6 +392,32 @@ final class iPhoneLibraryViewModel { } } + func toggleFavorite(trackID: String) async { + guard hasTrackInLibrarySnapshot(trackID) else { + return + } + + let previousFavorites = cachedFavoriteTrackRecordsByID + + if cachedFavoriteTrackRecordsByID[trackID] != nil { + cachedFavoriteTrackRecordsByID.removeValue(forKey: trackID) + } else { + cachedFavoriteTrackRecordsByID[trackID] = FavoriteTrackRecord( + remoteTrackId: trackID, + favoritedAt: Date() + ) + } + rebuildRows() + + do { + try await favoriteTrackStore.saveFavoriteTracks(Array(cachedFavoriteTrackRecordsByID.values)) + } catch { + cachedFavoriteTrackRecordsByID = previousFavorites + rebuildRows() + syncStatus = "Favorite update failed: \(error.localizedDescription)" + } + } + private func currentOrRegisterDeviceID() async throws -> String { if let existingDeviceID = try await keychainService.loadValue( forKey: Self.deviceIDKey @@ -430,6 +494,25 @@ final class iPhoneLibraryViewModel { return InMemoryArtworkStore() } + private static func makeFavoriteTrackStore() -> any FavoriteTrackStore { + if let store = try? FileFavoriteTrackStore() { + return store + } + + return InMemoryFavoriteTrackStore() + } + + private func reloadFavoriteTracks() async throws { + let favoriteTracks = try await favoriteTrackStore.loadFavoriteTracks() + var favoritesByTrackID: [String: FavoriteTrackRecord] = [:] + + for favoriteTrack in favoriteTracks { + favoritesByTrackID[favoriteTrack.remoteTrackId] = favoriteTrack + } + + cachedFavoriteTrackRecordsByID = favoritesByTrackID + } + private func reloadOfflineLibrary() async throws -> OfflineLibrarySnapshot { let snapshot = try await offlineLibraryService.loadSnapshot() @@ -444,6 +527,7 @@ final class iPhoneLibraryViewModel { } private func rebuildRows() { + let favoriteTrackIDs = Set(cachedFavoriteTrackRecordsByID.keys) let filteredSnapshot = OfflineLibrarySnapshot( remoteTracks: cachedRemoteLibraryTracks, availableTracks: cachedAvailableOfflineTracks @@ -452,15 +536,24 @@ final class iPhoneLibraryViewModel { remoteTracks = filteredSnapshot.remoteTracks.map { track in RemoteTrackRowViewData( track: track, - nowPlaying: nowPlaying + nowPlaying: nowPlaying, + isFavorite: favoriteTrackIDs.contains(track.remoteTrack.trackId) ) } availableOfflineTracks = filteredSnapshot.availableTracks.map { track in AvailableOfflineTrackRowViewData( track: track, - nowPlaying: nowPlaying + nowPlaying: nowPlaying, + isFavorite: favoriteTrackIDs.contains(track.remoteTrackId) ) } + if let trackID = nowPlaying.trackID, hasTrackInLibrarySnapshot(trackID) { + nowPlayingFavoriteTrackID = trackID + isNowPlayingTrackFavorite = favoriteTrackIDs.contains(trackID) + } else { + nowPlayingFavoriteTrackID = nil + isNowPlayingTrackFavorite = false + } } private func handleNowPlayingStateChange(_ state: iPhoneNowPlayingState) { @@ -481,6 +574,10 @@ final class iPhoneLibraryViewModel { cachedRemoteLibraryTracks[index] = transform(cachedRemoteLibraryTracks[index]) } + private func hasTrackInLibrarySnapshot(_ trackID: String) -> Bool { + cachedRemoteTracksByID[trackID] != nil + } + private func refreshOfflineLibraryInBackground() { Task { @MainActor [weak self] in guard let self else { @@ -508,6 +605,7 @@ struct RemoteTrackRowViewData: Identifiable, Equatable { let title: String let artist: String let durationText: String + let isFavorite: Bool let remoteTrackID: String let assetID: String let status: OfflineLibraryRemoteTrackStatus @@ -521,12 +619,14 @@ struct RemoteTrackRowViewData: Identifiable, Equatable { init( track: OfflineLibraryRemoteTrack, - nowPlaying: iPhoneNowPlayingState + nowPlaying: iPhoneNowPlayingState, + isFavorite: Bool ) { id = track.remoteTrack.trackId title = track.remoteTrack.title artist = track.remoteTrack.artist durationText = Self.formatDuration(seconds: track.remoteTrack.durationSeconds) + self.isFavorite = isFavorite remoteTrackID = track.remoteTrack.trackId assetID = track.remoteTrack.assetId status = track.status @@ -581,6 +681,7 @@ struct AvailableOfflineTrackRowViewData: Identifiable, Equatable { let title: String let artist: String let durationText: String + let isFavorite: Bool let remoteTrackID: String let assetID: String let playButtonTitle: String @@ -588,12 +689,14 @@ struct AvailableOfflineTrackRowViewData: Identifiable, Equatable { init( track: OfflineLibraryTrack, - nowPlaying: iPhoneNowPlayingState + nowPlaying: iPhoneNowPlayingState, + isFavorite: Bool ) { id = track.remoteTrackId title = track.title artist = track.artist durationText = RemoteTrackRowViewData.formatDuration(seconds: track.durationSeconds) + self.isFavorite = isFavorite remoteTrackID = track.remoteTrackId assetID = track.assetId playButtonTitle = nowPlaying.trackID == track.remoteTrackId && nowPlaying.isPlaying diff --git a/apps/apple/VelodyiPhone/Tests/iPhoneLibraryViewModelFavoritesTests.swift b/apps/apple/VelodyiPhone/Tests/iPhoneLibraryViewModelFavoritesTests.swift new file mode 100644 index 0000000..e615936 --- /dev/null +++ b/apps/apple/VelodyiPhone/Tests/iPhoneLibraryViewModelFavoritesTests.swift @@ -0,0 +1,451 @@ +import Foundation +import XCTest +import VelodyDomain +import VelodyNetworking +import VelodyPersistence +import VelodySync +import VelodyUtilities +@testable import VelodyiPhone + +@MainActor +final class iPhoneLibraryViewModelFavoritesTests: XCTestCase { + func testFavoritingTrackUpdatesRemoteOfflineAndNowPlayingState() async throws { + let track = makeRemoteTrack( + trackId: "remote-light-trap", + assetId: "asset-light-trap", + title: "Light Trap" + ) + let player = TestPlayer() + let viewModel = makeViewModel( + remoteTracks: [track], + downloadStates: [makeDownloadedState(for: track)], + audioFiles: [localFilePath(for: track): Data([0x1, 0x2, 0x3])], + player: player + ) + + await viewModel.loadIfNeeded() + viewModel.togglePlayback(trackID: track.trackId) + + XCTAssertEqual(viewModel.nowPlayingFavoriteTrackID, track.trackId) + XCTAssertFalse(viewModel.isNowPlayingTrackFavorite) + + await viewModel.toggleFavorite(trackID: track.trackId) + + XCTAssertTrue(try XCTUnwrap(remoteRow(in: viewModel, trackID: track.trackId)).isFavorite) + XCTAssertTrue(try XCTUnwrap(offlineRow(in: viewModel, trackID: track.trackId)).isFavorite) + XCTAssertEqual(viewModel.nowPlayingFavoriteTrackID, track.trackId) + XCTAssertTrue(viewModel.isNowPlayingTrackFavorite) + } + + func testUnfavoritingTrackRemovesFavoriteState() async throws { + let track = makeRemoteTrack( + trackId: "remote-harbor-lights", + assetId: "asset-harbor-lights", + title: "Harbor Lights" + ) + let favoriteStore = InMemoryFavoriteTrackStore(tracks: [ + FavoriteTrackRecord( + remoteTrackId: track.trackId, + favoritedAt: Date(timeIntervalSince1970: 1_000) + ), + ]) + let viewModel = makeViewModel( + remoteTracks: [track], + downloadStates: [makeDownloadedState(for: track)], + favoriteTrackStore: favoriteStore, + audioFiles: [localFilePath(for: track): Data([0x1])] + ) + + await viewModel.loadIfNeeded() + XCTAssertTrue(try XCTUnwrap(remoteRow(in: viewModel, trackID: track.trackId)).isFavorite) + + await viewModel.toggleFavorite(trackID: track.trackId) + + XCTAssertFalse(try XCTUnwrap(remoteRow(in: viewModel, trackID: track.trackId)).isFavorite) + XCTAssertFalse(try XCTUnwrap(offlineRow(in: viewModel, trackID: track.trackId)).isFavorite) + let savedFavorites = try await favoriteStore.loadFavoriteTracks() + XCTAssertTrue(savedFavorites.isEmpty) + } + + func testFavoritesPersistAcrossReload() async throws { + let fileManager = FileManager.default + let tempDirectory = fileManager.temporaryDirectory.appendingPathComponent( + UUID().uuidString, + isDirectory: true + ) + let fileURL = tempDirectory.appendingPathComponent("favorite-tracks.json") + let track = makeRemoteTrack( + trackId: "remote-night-drive", + assetId: "asset-night-drive", + title: "Night Drive" + ) + + defer { + try? fileManager.removeItem(at: tempDirectory) + } + + let firstViewModel = makeViewModel( + remoteTracks: [track], + downloadStates: [makeDownloadedState(for: track)], + favoriteTrackStore: try FileFavoriteTrackStore(fileURL: fileURL), + audioFiles: [localFilePath(for: track): Data([0x2])] + ) + await firstViewModel.loadIfNeeded() + await firstViewModel.toggleFavorite(trackID: track.trackId) + + let relaunchedViewModel = makeViewModel( + remoteTracks: [track], + downloadStates: [makeDownloadedState(for: track)], + favoriteTrackStore: try FileFavoriteTrackStore(fileURL: fileURL), + audioFiles: [localFilePath(for: track): Data([0x2])] + ) + await relaunchedViewModel.loadIfNeeded() + XCTAssertTrue(try XCTUnwrap(remoteRow(in: relaunchedViewModel, trackID: track.trackId)).isFavorite) + + await relaunchedViewModel.toggleFavorite(trackID: track.trackId) + + let secondRelaunchViewModel = makeViewModel( + remoteTracks: [track], + downloadStates: [makeDownloadedState(for: track)], + favoriteTrackStore: try FileFavoriteTrackStore(fileURL: fileURL), + audioFiles: [localFilePath(for: track): Data([0x2])] + ) + await secondRelaunchViewModel.loadIfNeeded() + + XCTAssertFalse(try XCTUnwrap(remoteRow(in: secondRelaunchViewModel, trackID: track.trackId)).isFavorite) + } + + func testMultipleFavoritesAreTrackedIndependently() async throws { + let firstTrack = makeRemoteTrack( + trackId: "remote-first", + assetId: "asset-first", + title: "First Favorite" + ) + let secondTrack = makeRemoteTrack( + trackId: "remote-second", + assetId: "asset-second", + title: "Second Favorite" + ) + let favoriteStore = InMemoryFavoriteTrackStore() + let viewModel = makeViewModel( + remoteTracks: [firstTrack, secondTrack], + downloadStates: [ + makeDownloadedState(for: firstTrack), + makeDownloadedState(for: secondTrack), + ], + favoriteTrackStore: favoriteStore, + audioFiles: [ + localFilePath(for: firstTrack): Data([0x1]), + localFilePath(for: secondTrack): Data([0x2]), + ] + ) + + await viewModel.loadIfNeeded() + await viewModel.toggleFavorite(trackID: firstTrack.trackId) + await viewModel.toggleFavorite(trackID: secondTrack.trackId) + + let savedIDs = try await favoriteStore.loadFavoriteTracks() + .map(\.remoteTrackId) + .sorted() + + XCTAssertEqual(savedIDs, [firstTrack.trackId, secondTrack.trackId].sorted()) + XCTAssertTrue(try XCTUnwrap(remoteRow(in: viewModel, trackID: firstTrack.trackId)).isFavorite) + XCTAssertTrue(try XCTUnwrap(remoteRow(in: viewModel, trackID: secondTrack.trackId)).isFavorite) + } + + func testToggleFavoriteRepeatedlyLeavesSingleStableRecord() async throws { + let track = makeRemoteTrack( + trackId: "remote-repeat", + assetId: "asset-repeat", + title: "Repeat Toggle" + ) + let favoriteStore = InMemoryFavoriteTrackStore() + let viewModel = makeViewModel( + remoteTracks: [track], + downloadStates: [makeDownloadedState(for: track)], + favoriteTrackStore: favoriteStore, + audioFiles: [localFilePath(for: track): Data([0x3])] + ) + + await viewModel.loadIfNeeded() + for _ in 0..<5 { + await viewModel.toggleFavorite(trackID: track.trackId) + } + + XCTAssertTrue(try XCTUnwrap(remoteRow(in: viewModel, trackID: track.trackId)).isFavorite) + let savedFavorites = try await favoriteStore.loadFavoriteTracks() + XCTAssertEqual(savedFavorites.count, 1) + } + + func testFavoriteStateDerivesCorrectlyInRemoteLibrary() async throws { + let track = makeRemoteTrack( + trackId: "remote-only", + assetId: "asset-only", + title: "Remote Only" + ) + let viewModel = makeViewModel( + remoteTracks: [track], + favoriteTrackStore: InMemoryFavoriteTrackStore(tracks: [ + FavoriteTrackRecord( + remoteTrackId: track.trackId, + favoritedAt: Date(timeIntervalSince1970: 4_000) + ), + ]) + ) + + await viewModel.loadIfNeeded() + + XCTAssertTrue(try XCTUnwrap(remoteRow(in: viewModel, trackID: track.trackId)).isFavorite) + XCTAssertTrue(viewModel.availableOfflineTracks.isEmpty) + } + + func testFavoriteStateDerivesCorrectlyInOfflineLibrary() async throws { + let track = makeRemoteTrack( + trackId: "offline-favorite", + assetId: "asset-offline-favorite", + title: "Offline Favorite" + ) + let viewModel = makeViewModel( + remoteTracks: [track], + downloadStates: [makeDownloadedState(for: track)], + favoriteTrackStore: InMemoryFavoriteTrackStore(tracks: [ + FavoriteTrackRecord( + remoteTrackId: track.trackId, + favoritedAt: Date(timeIntervalSince1970: 5_000) + ), + ]), + audioFiles: [localFilePath(for: track): Data([0x4])] + ) + + await viewModel.loadIfNeeded() + + XCTAssertTrue(try XCTUnwrap(offlineRow(in: viewModel, trackID: track.trackId)).isFavorite) + } + + func testSearchDoesNotModifyFavoriteState() async throws { + let favoriteTrack = makeRemoteTrack( + trackId: "remote-search-favorite", + assetId: "asset-search-favorite", + title: "Northern Lights" + ) + let otherTrack = makeRemoteTrack( + trackId: "remote-search-other", + assetId: "asset-search-other", + title: "Quiet Harbor" + ) + let favoriteStore = InMemoryFavoriteTrackStore() + let viewModel = makeViewModel( + remoteTracks: [favoriteTrack, otherTrack], + downloadStates: [ + makeDownloadedState(for: favoriteTrack), + makeDownloadedState(for: otherTrack), + ], + favoriteTrackStore: favoriteStore, + audioFiles: [ + localFilePath(for: favoriteTrack): Data([0x5]), + localFilePath(for: otherTrack): Data([0x6]), + ] + ) + + await viewModel.loadIfNeeded() + await viewModel.toggleFavorite(trackID: favoriteTrack.trackId) + + viewModel.searchText = otherTrack.title + XCTAssertNil(remoteRow(in: viewModel, trackID: favoriteTrack.trackId)) + + viewModel.searchText = favoriteTrack.title + + XCTAssertTrue(try XCTUnwrap(remoteRow(in: viewModel, trackID: favoriteTrack.trackId)).isFavorite) + let savedFavoriteIDs = try await favoriteStore.loadFavoriteTracks().map(\.remoteTrackId) + XCTAssertEqual(savedFavoriteIDs, [favoriteTrack.trackId]) + } + + func testMissingFileStateDoesNotRemoveFavorite() async throws { + let track = makeRemoteTrack( + trackId: "remote-missing", + assetId: "asset-missing", + title: "Missing Favorite" + ) + let viewModel = makeViewModel( + remoteTracks: [track], + downloadStates: [makeDownloadedState(for: track)], + favoriteTrackStore: InMemoryFavoriteTrackStore(tracks: [ + FavoriteTrackRecord( + remoteTrackId: track.trackId, + favoritedAt: Date(timeIntervalSince1970: 6_000) + ), + ]) + ) + + await viewModel.loadIfNeeded() + + let remoteTrack = try XCTUnwrap(remoteRow(in: viewModel, trackID: track.trackId)) + + XCTAssertEqual(remoteTrack.status, .missing) + XCTAssertTrue(remoteTrack.isFavorite) + XCTAssertTrue(viewModel.availableOfflineTracks.isEmpty) + } +} + +@MainActor +private final class TestPlayer: iPhoneLocalAudioPlaying { + var onStateChange: ((iPhoneNowPlayingState) -> Void)? + private(set) var state = iPhoneNowPlayingState( + trackID: nil, + title: nil, + artist: nil, + isPlaying: false, + errorMessage: nil + ) + + func play( + trackID: String, + title: String, + artist: String, + fileURL: URL + ) throws { + _ = fileURL + state = iPhoneNowPlayingState( + trackID: trackID, + title: title, + artist: artist, + isPlaying: true, + errorMessage: nil + ) + onStateChange?(state) + } + + func resume() throws { + state.isPlaying = true + state.errorMessage = nil + onStateChange?(state) + } + + func pause() { + state.isPlaying = false + onStateChange?(state) + } +} + +private actor TestRemoteLibraryRepository: RemoteLibraryRepository { + private let tracks: [RemoteTrack] + + init(tracks: [RemoteTrack]) { + self.tracks = tracks + } + + func loadCachedRemoteTracks() async throws -> [RemoteTrack] { + tracks + } + + func syncRemoteTracks(deviceId: String) async throws -> [RemoteTrack] { + _ = deviceId + return tracks + } + + func downloadAudioAsset(assetId: String, deviceId: String) async throws -> Data { + _ = assetId + _ = deviceId + throw TestRepositoryError.unexpectedDownload + } + + func downloadArtwork(artworkId: String, deviceId: String) async throws -> Data { + _ = artworkId + _ = deviceId + throw TestRepositoryError.unexpectedDownload + } +} + +private enum TestRepositoryError: Error { + case unexpectedDownload +} + +@MainActor +private func makeViewModel( + remoteTracks: [RemoteTrack], + downloadStates: [RemoteTrackDownloadState] = [], + favoriteTrackStore: any FavoriteTrackStore = InMemoryFavoriteTrackStore(), + audioFiles: [String: Data] = [:], + player: (any iPhoneLocalAudioPlaying)? = nil +) -> iPhoneLibraryViewModel { + let repository = TestRemoteLibraryRepository(tracks: remoteTracks) + let downloadStateStore = InMemoryRemoteTrackDownloadStateStore(states: downloadStates) + let audioFileStore = InMemoryOfflineAudioFileStore(files: audioFiles) + let artworkStore = InMemoryArtworkStore() + let syncService = RemoteLibrarySyncService( + repository: repository, + downloadStateStore: downloadStateStore, + audioFileStore: audioFileStore, + artworkStore: artworkStore + ) + let offlineLibraryService = OfflineLibraryService( + syncService: syncService, + audioFileStore: audioFileStore, + artworkStore: artworkStore + ) + + return iPhoneLibraryViewModel( + environment: ServerEnvironment( + baseURL: ServerEnvironment.defaultLocalBaseURL, + appVersion: "Tests" + ), + apiClient: URLSessionVelodyAPIClient( + environment: ServerEnvironment( + baseURL: ServerEnvironment.defaultLocalBaseURL, + appVersion: "Tests" + ) + ), + syncService: syncService, + offlineLibraryService: offlineLibraryService, + favoriteTrackStore: favoriteTrackStore, + player: player ?? TestPlayer(), + keychainService: MemoryKeychainService() + ) +} + +private func makeRemoteTrack( + trackId: String, + assetId: String, + title: String +) -> RemoteTrack { + RemoteTrack( + trackId: trackId, + title: title, + artist: "Velody Artist", + durationSeconds: 245, + sha256: String(repeating: "a", count: 64), + assetId: assetId, + createdAt: "2026-05-30T08:00:00.000Z", + updatedAt: "2026-05-30T08:05:00.000Z" + ) +} + +private func makeDownloadedState(for track: RemoteTrack) -> RemoteTrackDownloadState { + RemoteTrackDownloadState( + remoteTrackId: track.trackId, + assetId: track.assetId, + localFilePath: localFilePath(for: track), + downloadedAt: Date(timeIntervalSince1970: 1_000), + downloadStatus: .downloaded + ) +} + +private func localFilePath(for track: RemoteTrack) -> String { + "/in-memory/\(track.assetId).mp3" +} + +@MainActor +private func remoteRow( + in viewModel: iPhoneLibraryViewModel, + trackID: String +) -> RemoteTrackRowViewData? { + viewModel.remoteTracks.first(where: { $0.id == trackID }) +} + +@MainActor +private func offlineRow( + in viewModel: iPhoneLibraryViewModel, + trackID: String +) -> AvailableOfflineTrackRowViewData? { + viewModel.availableOfflineTracks.first(where: { $0.id == trackID }) +} diff --git a/apps/apple/project.yml b/apps/apple/project.yml index 1bd0592..52d4248 100644 --- a/apps/apple/project.yml +++ b/apps/apple/project.yml @@ -66,3 +66,28 @@ targets: product: VelodySync - package: VelodyUtilities product: VelodyUtilities + scheme: + testTargets: + - VelodyiPhoneTests + VelodyiPhoneTests: + type: bundle.unit-test + platform: iOS + deploymentTarget: "17.0" + hostApplication: VelodyiPhone + sources: + - path: VelodyiPhone/Tests + settings: + base: + PRODUCT_BUNDLE_IDENTIFIER: de.diyaa.velody.iphone.tests + dependencies: + - target: VelodyiPhone + - package: VelodyDomain + product: VelodyDomain + - package: VelodyNetworking + product: VelodyNetworking + - package: VelodyPersistence + product: VelodyPersistence + - package: VelodySync + product: VelodySync + - package: VelodyUtilities + product: VelodyUtilities diff --git a/packages/apple/VelodyPersistence/Sources/VelodyPersistence/FavoriteTrackStore.swift b/packages/apple/VelodyPersistence/Sources/VelodyPersistence/FavoriteTrackStore.swift new file mode 100644 index 0000000..159661d --- /dev/null +++ b/packages/apple/VelodyPersistence/Sources/VelodyPersistence/FavoriteTrackStore.swift @@ -0,0 +1,111 @@ +import Foundation + +public struct FavoriteTrackRecord: Codable, Hashable, Sendable { + public var remoteTrackId: String + public var favoritedAt: Date + + public init( + remoteTrackId: String, + favoritedAt: Date + ) { + self.remoteTrackId = remoteTrackId + self.favoritedAt = favoritedAt + } +} + +public protocol FavoriteTrackStore: Actor { + func loadFavoriteTracks() async throws -> [FavoriteTrackRecord] + func saveFavoriteTracks(_ tracks: [FavoriteTrackRecord]) async throws +} + +public extension FavoriteTrackStore { + func saveFavoriteTrack(_ track: FavoriteTrackRecord) async throws { + var tracks = try await loadFavoriteTracks() + + if let existingIndex = tracks.firstIndex(where: { $0.remoteTrackId == track.remoteTrackId }) { + tracks[existingIndex] = track + } else { + tracks.append(track) + } + + try await saveFavoriteTracks(tracks) + } + + func removeFavoriteTrack(remoteTrackId: String) async throws { + let tracks = try await loadFavoriteTracks() + .filter { $0.remoteTrackId != remoteTrackId } + try await saveFavoriteTracks(tracks) + } +} + +public actor FileFavoriteTrackStore: FavoriteTrackStore { + private let fileURL: URL + private let fileManager: FileManager + private let encoder = JSONEncoder() + private let decoder = JSONDecoder() + + public init( + fileURL: URL? = nil, + fileManager: FileManager = .default + ) throws { + self.fileManager = fileManager + if let fileURL { + self.fileURL = fileURL + } else { + self.fileURL = try Self.defaultFileURL(fileManager: fileManager) + } + encoder.dateEncodingStrategy = .iso8601 + decoder.dateDecodingStrategy = .iso8601 + } + + public func loadFavoriteTracks() async throws -> [FavoriteTrackRecord] { + guard fileManager.fileExists(atPath: fileURL.path) else { + return [] + } + + let data = try Data(contentsOf: fileURL) + return try decoder.decode([FavoriteTrackRecord].self, from: data) + } + + public func saveFavoriteTracks(_ tracks: [FavoriteTrackRecord]) async throws { + try fileManager.createDirectory( + at: fileURL.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + + let sortedTracks = tracks.sorted { lhs, rhs in + lhs.remoteTrackId.localizedCaseInsensitiveCompare(rhs.remoteTrackId) == .orderedAscending + } + let data = try encoder.encode(sortedTracks) + try data.write(to: fileURL, options: .atomic) + } + + private static func defaultFileURL(fileManager: FileManager) throws -> URL { + guard let applicationSupportURL = fileManager.urls( + for: .applicationSupportDirectory, + in: .userDomainMask + ).first else { + throw CocoaError(.fileNoSuchFile) + } + + return applicationSupportURL + .appendingPathComponent("Velody", isDirectory: true) + .appendingPathComponent("favorite-tracks.json") + } +} + +public actor InMemoryFavoriteTrackStore: FavoriteTrackStore { + private var tracks: [FavoriteTrackRecord] + + public init(tracks: [FavoriteTrackRecord] = []) { + self.tracks = tracks + } + + public func loadFavoriteTracks() async throws -> [FavoriteTrackRecord] { + tracks + } + + public func saveFavoriteTracks(_ tracks: [FavoriteTrackRecord]) async throws { + self.tracks = tracks + } +} diff --git a/packages/apple/VelodyPersistence/Tests/VelodyPersistenceTests/FavoriteTrackStoreTests.swift b/packages/apple/VelodyPersistence/Tests/VelodyPersistenceTests/FavoriteTrackStoreTests.swift new file mode 100644 index 0000000..b127f34 --- /dev/null +++ b/packages/apple/VelodyPersistence/Tests/VelodyPersistenceTests/FavoriteTrackStoreTests.swift @@ -0,0 +1,113 @@ +import Foundation +import XCTest +@testable import VelodyPersistence + +final class FavoriteTrackStoreTests: XCTestCase { + func testFavoriteTrackStoreUpsertsAndRemovesByRemoteTrackID() async throws { + let store = InMemoryFavoriteTrackStore() + let originalFavorite = FavoriteTrackRecord( + remoteTrackId: "track-123", + favoritedAt: Date(timeIntervalSince1970: 1_000) + ) + let updatedFavorite = FavoriteTrackRecord( + remoteTrackId: "track-123", + favoritedAt: Date(timeIntervalSince1970: 2_000) + ) + let secondFavorite = FavoriteTrackRecord( + remoteTrackId: "track-456", + favoritedAt: Date(timeIntervalSince1970: 3_000) + ) + + try await store.saveFavoriteTrack(originalFavorite) + try await store.saveFavoriteTrack(updatedFavorite) + try await store.saveFavoriteTrack(secondFavorite) + + let savedFavorites = try await store.loadFavoriteTracks() + + XCTAssertEqual(savedFavorites.count, 2) + XCTAssertTrue(savedFavorites.contains(updatedFavorite)) + XCTAssertTrue(savedFavorites.contains(secondFavorite)) + + try await store.removeFavoriteTrack(remoteTrackId: "track-123") + let remainingFavorites = try await store.loadFavoriteTracks() + + XCTAssertEqual(remainingFavorites, [secondFavorite]) + } + + func testFileFavoriteTrackStorePersistsAcrossInstances() async throws { + let fileManager = FileManager.default + let tempDirectory = fileManager.temporaryDirectory.appendingPathComponent( + UUID().uuidString, + isDirectory: true + ) + let fileURL = tempDirectory.appendingPathComponent("favorite-tracks.json") + let favorites = [ + FavoriteTrackRecord( + remoteTrackId: "track-123", + favoritedAt: Date(timeIntervalSince1970: 1_000) + ), + FavoriteTrackRecord( + remoteTrackId: "track-456", + favoritedAt: Date(timeIntervalSince1970: 2_000) + ), + ] + + defer { + try? fileManager.removeItem(at: tempDirectory) + } + + let firstStore = try FileFavoriteTrackStore(fileURL: fileURL) + try await firstStore.saveFavoriteTracks(favorites) + + let secondStore = try FileFavoriteTrackStore(fileURL: fileURL) + let restoredFavorites = try await secondStore.loadFavoriteTracks() + + XCTAssertEqual(restoredFavorites, favorites) + } + + func testFileFavoriteTrackStoreWritesAtomicallyAndOverwritesOldContents() async throws { + let fileManager = FileManager.default + let tempDirectory = fileManager.temporaryDirectory.appendingPathComponent( + UUID().uuidString, + isDirectory: true + ) + let fileURL = tempDirectory.appendingPathComponent("favorite-tracks.json") + let firstFavorites = [ + FavoriteTrackRecord( + remoteTrackId: "track-b", + favoritedAt: Date(timeIntervalSince1970: 2_000) + ), + FavoriteTrackRecord( + remoteTrackId: "track-a", + favoritedAt: Date(timeIntervalSince1970: 1_000) + ), + ] + let secondFavorites = [ + FavoriteTrackRecord( + remoteTrackId: "track-c", + favoritedAt: Date(timeIntervalSince1970: 3_000) + ), + ] + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + + defer { + try? fileManager.removeItem(at: tempDirectory) + } + + let store = try FileFavoriteTrackStore(fileURL: fileURL) + try await store.saveFavoriteTracks(firstFavorites) + try await store.saveFavoriteTracks(secondFavorites) + + let rawData = try Data(contentsOf: fileURL) + let restoredFavorites = try decoder.decode([FavoriteTrackRecord].self, from: rawData) + let directoryContents = try fileManager.contentsOfDirectory(atPath: tempDirectory.path) + let json = try XCTUnwrap( + JSONSerialization.jsonObject(with: rawData) as? [[String: Any]] + ) + + XCTAssertEqual(restoredFavorites, secondFavorites) + XCTAssertEqual(directoryContents.sorted(), ["favorite-tracks.json"]) + XCTAssertEqual(Set(json[0].keys), ["favoritedAt", "remoteTrackId"]) + } +}