diff --git a/BookPlayer/Coordinators/DataInitializerCoordinator.swift b/BookPlayer/Coordinators/DataInitializerCoordinator.swift index 51c46c79..acb9cf10 100644 --- a/BookPlayer/Coordinators/DataInitializerCoordinator.swift +++ b/BookPlayer/Coordinators/DataInitializerCoordinator.swift @@ -58,6 +58,7 @@ class DataInitializerCoordinator: BPLogger { || error.code == NSMigrationMissingMappingModelError || error.code == NSMigrationManagerSourceStoreError || error.code == NSMigrationManagerDestinationStoreError || error.code == NSEntityMigrationPolicyError || error.code == NSValidationMultipleErrorsError || error.code == NSValidationMissingMandatoryPropertyError + || error.code == NSPersistentStoreIncompatibleSchemaError { Self.logger.warning("Failed to perform migration, attempting recovery with the loading library sequence") await MainActor.run { @@ -79,26 +80,28 @@ class DataInitializerCoordinator: BPLogger { Additional Info \(error.userInfo) """ - alertPresenter.showAlert(BPAlertContent( - title: "error_title".localized, - message: errorDescription, - style: .alert, - actionItems: [ - BPActionItem( - title: "ok_button".localized, - handler: { - fatalError("Unresolved error \(error.domain) (\(error.code)): \(error.localizedDescription)") - } - ), - .init( - title: "Reset and recover database", - style: .destructive, - handler: { - self.recoverLibraryFromFailedMigration() - } - ) - ] - )) + alertPresenter.showAlert( + BPAlertContent( + title: "error_title".localized, + message: errorDescription, + style: .alert, + actionItems: [ + BPActionItem( + title: "ok_button".localized, + handler: { + fatalError("Unresolved error \(error.domain) (\(error.code)): \(error.localizedDescription)") + } + ), + .init( + title: "Reset and recover database", + style: .destructive, + handler: { + self.recoverLibraryFromFailedMigration() + } + ), + ] + ) + ) } } } diff --git a/BookPlayer/Generated/AutoMockable.generated.swift b/BookPlayer/Generated/AutoMockable.generated.swift index 608d2769..d4d7a9f6 100644 --- a/BookPlayer/Generated/AutoMockable.generated.swift +++ b/BookPlayer/Generated/AutoMockable.generated.swift @@ -12,62 +12,6 @@ import AppKit import Combine import BookPlayerKit @testable import BookPlayer -class KeychainServiceProtocolMock: KeychainServiceProtocol { - //MARK: - setAccessToken - - var setAccessTokenThrowableError: Error? - var setAccessTokenCallsCount = 0 - var setAccessTokenCalled: Bool { - return setAccessTokenCallsCount > 0 - } - var setAccessTokenReceivedToken: String? - var setAccessTokenReceivedInvocations: [String] = [] - var setAccessTokenClosure: ((String) throws -> Void)? - func setAccessToken(_ token: String) throws { - if let error = setAccessTokenThrowableError { - throw error - } - setAccessTokenCallsCount += 1 - setAccessTokenReceivedToken = token - setAccessTokenReceivedInvocations.append(token) - try setAccessTokenClosure?(token) - } - //MARK: - getAccessToken - - var getAccessTokenThrowableError: Error? - var getAccessTokenCallsCount = 0 - var getAccessTokenCalled: Bool { - return getAccessTokenCallsCount > 0 - } - var getAccessTokenReturnValue: String? - var getAccessTokenClosure: (() throws -> String?)? - func getAccessToken() throws -> String? { - if let error = getAccessTokenThrowableError { - throw error - } - getAccessTokenCallsCount += 1 - if let getAccessTokenClosure = getAccessTokenClosure { - return try getAccessTokenClosure() - } else { - return getAccessTokenReturnValue - } - } - //MARK: - removeAccessToken - - var removeAccessTokenThrowableError: Error? - var removeAccessTokenCallsCount = 0 - var removeAccessTokenCalled: Bool { - return removeAccessTokenCallsCount > 0 - } - var removeAccessTokenClosure: (() throws -> Void)? - func removeAccessToken() throws { - if let error = removeAccessTokenThrowableError { - throw error - } - removeAccessTokenCallsCount += 1 - try removeAccessTokenClosure?() - } -} class LibraryServiceProtocolMock: LibraryServiceProtocol { var metadataUpdatePublisher: AnyPublisher<[String: Any], Never> { get { return underlyingMetadataUpdatePublisher } diff --git a/BookPlayer/sk-SK.lproj/AppShortcuts.strings b/BookPlayer/sk-SK.lproj/AppShortcuts.strings index 5369a887..e5ea0f64 100644 --- a/BookPlayer/sk-SK.lproj/AppShortcuts.strings +++ b/BookPlayer/sk-SK.lproj/AppShortcuts.strings @@ -13,9 +13,9 @@ "Turn on the sleep timer in ${applicationName}" = "Zapnúť časovač spánku v ${applicationName}"; "Enable the sleep timer in ${applicationName}" = "Povoliť časovač spánku v ${applicationName}"; "Rewind in ${applicationName}" = "Pretočiť späť v ${applicationName}"; -"Jump back in ${applicationName}" = "Skočiť späť do ${applicationName}"; +"Jump back in ${applicationName}" = "Skočiť späť v ${applicationName}"; "Skip back in ${applicationName}" = "Preskočiť späť v ${applicationName}"; -"Go back in ${applicationName}" = "Vráťte sa do ${applicationName}"; +"Go back in ${applicationName}" = "Vrátiť späť v ${applicationName}"; "Fast forward in ${applicationName}" = "Rýchly posun dopredu v ${applicationName}"; "Skip forward in ${applicationName}" = "Preskočiť vpred v ${applicationName}"; "Jump forward in ${applicationName}" = "Skočiť vpred v ${applicationName}"; diff --git a/BookPlayer/sk-SK.lproj/Localizable.strings b/BookPlayer/sk-SK.lproj/Localizable.strings index 7def0f4d..7e14fa22 100644 --- a/BookPlayer/sk-SK.lproj/Localizable.strings +++ b/BookPlayer/sk-SK.lproj/Localizable.strings @@ -20,7 +20,7 @@ "settings_support_title" = "PODPORA"; "settings_siri_title" = "SKRATKY SIRI"; "settings_support_project_title" = "Zobraziť projekt na Githube"; -"settings_support_project_description" = "Vytvoriť nové hlásenie chyby alebo požiadavky na funkcie"; +"settings_support_project_description" = "Vytvorí nové hlásenie chyby alebo požiadavky na funkcie"; "settings_support_email_title" = "Pošlite nám e-mail"; "settings_credits_title" = "Zásluhy a licencie"; "settings_support_compose_title" = "Nemožno vytvoriť e-mail"; @@ -33,12 +33,12 @@ "delete_single_item_title" = "Chcete odstrániť \"%@\"?"; "delete_single_playlist_description" = "Ak odstránite iba priečinok, všetky jeho súbory sa presunú späť do knižnice."; "delete_shallow_button" = "Odstrániť iba priečinok"; -"delete_deep_button" = "Odstráňte priečinok a súbory"; +"delete_deep_button" = "Odstrániť priečinok a súbory"; "create_playlist_button" = "Vytvoriť priečinok"; "create_button" = "Vytvoriť"; "import_button" = "Import súborov"; "import_description" = "Taktiež môžete súbory pridať cez AirDrop. Pošlite súbor knihy do svojho zariadenia a zo zoznamu, ktorý sa zobrazí, vyberte BookPlayer."; -"create_playlist_title" = "Vytvorte nový priečinok"; +"create_playlist_title" = "Vytváranie nového priečinka"; "delete_multiple_items_description" = "Týmto sa odstránia aj všetky súbory vo vybratých priečinkoch."; "new_playlist_button" = "Nový priečinok"; "existing_playlist_button" = "Existujúci priečinok"; @@ -66,7 +66,7 @@ "settings_tip_jar_title" = "Príspevky"; "theme_system_title" = "Použiť podľa nastavenia systému"; "theme_switch_title" = "Automaticky prepínať"; -"theme_dark_title" = "Vždy používať tmavý režim"; +"theme_dark_title" = "Vždy používať tmavú tému"; "themes_title" = "Témy"; "icons_bookplayer_credit_description" = "Od vašich priateľov z BookPlayera"; "kind_tip_title" = "Láskavý príspevok"; @@ -89,7 +89,7 @@ "file_error_description" = "Súbor tejto knihy nemožno načítať. Uistite sa, že nepoužívate súbory s ochranou DRM (napríklad súbory .aax)"; "file_missing_title" = "Chýbajúci súbor!"; "file_missing_description" = "Súbor knihy bol odobratý z vášho zariadenia. Ak chcete knihu prehrať, znovu naimportujte súbor"; -"empty_playlist_description" = "Súbory sem môžete presunúť aj ich presunutím do tohto priečinka v knižnici"; +"empty_playlist_description" = "Súbory sem môžete presunúť aj ich pretiahnutím do tohto priečinka v knižnici"; "playlist_add_title" = "Pridať súbory"; "player_speed_title" = "Nastavenie rýchlosti prehrávania"; "player_sleep_title" = "Pozastavenie prehrávania"; @@ -101,10 +101,10 @@ "themes_caps_title" = "TÉMY"; "plus_app_icons_title" = "Ikony aplikácie"; "mark_unfinished_title" = "Označiť ako nedokončené"; -"move_playlist_button" = "Premiestni do priečinku"; +"move_playlist_button" = "Presunúť do priečinku"; "move_single_item_title" = "Chcete presunúť '%@' do '%@'?"; "current_playlist_title" = "Aktuálny priečinok"; -"select_item_title" = "Vybrať položku"; +"select_item_title" = "Vyberte položku"; "sleep_alert_description" = "Uspať po dokončení kapitoly"; "sleep_time_description" = "Uspať o %@"; "sleep_off_title" = "Vypnuté"; @@ -117,7 +117,7 @@ "book_duration_title" = "Dĺžka knihy:"; "icon_error_description" = "Zmena ikony aplikácie nebola úspešná. Vyskúšajte to neskôr."; "error_title" = "Chyba"; -"generic_retry_description" = "Opakujte akciu neskôr, prosím"; +"generic_retry_description" = "Prosím, opakujte neskôr"; "network_error_title" = "Chyba siete"; "purchases_restored_title" = "Nákupy boli obnovené!"; "tip_missing_title" = "Zatiaľ ste nám neposlali žiadny príspevok"; @@ -126,7 +126,7 @@ "pause_title" = "Pozastaviť"; "voiceover_no_title" = "Žiadny názov"; "voiceover_no_author" = "Žiadny autor"; -"voiceover_book_progress" = "%@ podľa %@, %.0f percent dokončených, trvanie: %@"; +"voiceover_book_progress" = "%@ z %@, %.0f percent dokončených, trvanie: %@"; "voiceover_no_file_title" = "Žiadny názov súboru"; "voiceover_no_file_subtitle" = "Žiadny podtitul súboru"; "voiceover_no_playlist_title" = "Žiadny názov priečinka"; @@ -152,10 +152,10 @@ "library_add_title" = "Pridajte vašu prvú knihu"; "invalid_url_title" = "Neplatná adresa URL: %@"; "downloading_file_title" = "Sťahovanie súboru"; -"progress_title" = "Priebeh spracovania"; +"progress_title" = "Priebeh"; "settings_siri_sleeptimer_title" = "Časovač spánku"; "active_title" = "Zap."; -"voiceover_currently_playing_title" = "Práve sa prehráva %@ od %@"; +"voiceover_currently_playing_title" = "Práve sa prehráva %@ z %@"; "voiceover_miniplayer_hint" = "Miniprehrávač. Ťuknite pre zobrazenie Prehrávača"; "voiceover_chapter_time_title" = "Dĺžka aktuálnej kapitoly: %@"; "voiceover_dismiss_player_title" = "Zavrieť prehrávač"; @@ -174,7 +174,7 @@ "bookmark_created_title" = "Vaša záložka bola uložená v čase %@"; "bookmark_exists_title" = "Už ste si vytvorili záložku v čase %@"; "bookmark_note_action_title" = "Pridať poznámku"; -"bookmarks_see_title" = "Viď záložky"; +"bookmarks_see_title" = "Zobraziť záložky"; "bookmark_type_automatic_title" = "Automatické"; "bookmark_type_user_title" = "Vlastné"; "bookmark_automatic_play_title" = "Posledná pozícia pred spustením prehrávania"; @@ -198,74 +198,74 @@ "voiceover_bound_books_progress" = "%@, Zväzok, %.0f percent dokončených, trvanie %@"; "voiceover_no_bound_books_title" = "Žiadny názov zväzku"; "default_title" = "Predvolené"; -"voiceover_default_speed_title" = "Nastavte predvolenú rýchlosť"; +"voiceover_default_speed_title" = "Nastavenie predvolenej rýchlosti"; "error_loading_chapters" = "Kapitoly nebolo možné načítať z: \n%@"; "error_empty_chapters" = "Kapitoly sú prázdne, skontrolujte obsah priečinka: %@"; "sleeptimer_option_custom" = "Vlastný časovač spánku"; "sleeptimer_custom_alert_title" = "Vlastný časovač spánku"; "settings_progresslabels_title" = "Štítky priebehu spracovania"; -"settings_playerinterface_list_title" = "Tlačidlo zoznamu otvorí"; +"settings_playerinterface_list_title" = "Otvára"; "settings_remainingtime_title" = "Zostávajúci čas"; "settings_chaptercontext_title" = "Kontext kapitoly"; "settings_progresslabels_description" = "Prepínanie medzi zobrazením zostávajúceho času, celkového trvania a priebehu spracovania kapitoly alebo knihy na obrazovke prehrávača"; "settings_playerinterface_list_description" = "Úprava správania sa tlačidla zoznamu na obrazovke prehrávača"; -"settings_autoplay_section_title" = "AUTOHRA"; -"settings_autoplay_restart_title" = "Reštartujte hotové knihy"; -"settings_theme_autobrightness" = "Vyberte tmavú variáciu témy, ak jas obrazovky klesne pod nastavenú hranicu."; +"settings_autoplay_section_title" = "AUTOMATICKÉ PREHRÁVANIE"; +"settings_autoplay_restart_title" = "Reštartuje hotové knihy"; +"settings_theme_autobrightness" = "Vyberie tmavú tému, ak jas obrazovky klesne pod nastavenú hranicu."; "note_title" = "Poznámka"; -"playing_title" = "prehrávanie zvuku"; +"playing_title" = "Prehrávanie"; "paused_title" = "Pozastavené"; "skipped_forward_title" = "Preskočené dopredu"; -"skipped_back_title" = "Preskočené späť"; -"button_free_title" = "Gestá potiahnutia"; +"skipped_back_title" = "Preskočené dozadu"; +"button_free_title" = "Gestá"; "screen_gestures_title" = "Gestá na obrazovke"; -"gesture_tap_title" = "Klepnutím spustíte prehrávanie alebo pozastavenie"; -"gesture_swipe_left_title" = "Potiahnutím doľava sa vrátite"; -"gesture_swipe_right_title" = "Potiahnutím doprava preskočíte dopredu"; -"gesture_swipe_vertically_title" = "Záložku vytvoríte potiahnutím zvisle"; +"gesture_tap_title" = "Klepnutím spustíte alebo pozastavíte prehrávanie"; +"gesture_swipe_left_title" = "Potiahnutím doľava pretočíte dozadu"; +"gesture_swipe_right_title" = "Potiahnutím doprava pretočíte dopredu"; +"gesture_swipe_vertically_title" = "Potiahnutím zvisle vytvoríte záložku"; "details_title" = "Podrobnosti"; -"download_title" = "Stiahnuť ▼"; +"download_title" = "Stiahnuť"; "cancel_download_title" = "Zrušiť sťahovanie"; "remove_downloaded_file_title" = "Odstrániť zo zariadenia"; -"download_from_url_title" = "Stiahnuť z adresy URL"; +"download_from_url_title" = "Sťahovanie z adresy URL"; "done_title" = "Hotovo"; "select_title" = "Výber"; "sort_button_title" = "Zoradiť podľa"; "search_title" = "Vyhľadávanie"; -"books_title" = "knihy"; +"books_title" = "Knihy"; "folders_title" = "Priečinky"; "section_item_title" = "Názov"; "section_item_author" = "Autor"; -"artwork_options_title" = "Možnosti umeleckých diel"; -"artwork_photolibrary_title" = "Vyberte si z knižnice fotografií"; -"artwork_clipboard_title" = "Prilepiť zo schránky"; -"artwork_reset_title" = "Resetovať"; +"artwork_options_title" = "Možnosti obalov"; +"artwork_photolibrary_title" = "Výber z knižnice fotografií"; +"artwork_clipboard_title" = "Vloženie zo schránky"; +"artwork_reset_title" = "Reset"; "artwork_clipboard_empty_title" = "V schránke nie je žiadny obrázok"; -"artwork_title" = "Umelecké diela"; -"update_title" = "Aktualizovať"; -"edit_title" = "Upraviť"; -"setup_account_title" = "Nastaviť účet"; -"not_signedin_title" = "Nie je prihlásený"; +"artwork_title" = "Obal"; +"update_title" = "Aktualizácia"; +"edit_title" = "Úprava"; +"setup_account_title" = "Nastavenie účtu"; +"not_signedin_title" = "Neprihlásený"; "total_listening_title" = "Celkový čas počúvania"; -"sync_library_title" = "Synchronizovať knižnicu"; +"sync_library_title" = "Synchronizácia knižnice"; "last_sync_title" = "Posledná synchronizácia: %@"; "profile_title" = "Profil"; "manage_title" = "Spravovať"; "logout_title" = "Odhlásiť sa"; -"delete_account_title" = "Zmazať účet"; -"account_title" = "účtu"; +"delete_account_title" = "Odstránenie účtu"; +"account_title" = "Účet"; "benefits_cloudsync_title" = "Cloudová synchronizácia (Beta)"; "benefits_themesicons_title" = "Témy a ikony"; -"benefits_supportus_title" = "Podpor nás"; -"completeaccount_title" = "Dokončite svoj účet"; -"benefits_cloudsync_description" = "Stiahnite si a synchronizujte svoju knižnicu a priebeh kníh do všetkých podporovaných zariadení."; +"benefits_supportus_title" = "Podporte nás"; +"completeaccount_title" = "Dokončenie svojho účtu"; +"benefits_cloudsync_description" = "Sťahovanie a synchronizácia svojej knižnice a priebeh kníh vo všetkých podporovaných zariadení."; "benefits_themesicons_description" = "Budete mať prístup k ďalším témam a ikonám aplikácií, ktoré sa odomknú darovaním a pripojením sa k BookPlayer Plus."; "benefits_supportus_description" = "S vašou pomocou sme schopní implementovať viac funkcií a urobiť BookPlayer ešte lepším."; "benefits_disclaimer_title" = "Prosím, majte na pamäti nasledovné:"; "benefits_disclaimer_account_description" = "- Účet u nás potrebujete iba vtedy, ak plánujete počúvať svoju knižnicu na rôznych zariadeniach"; -"benefits_disclaimer_subscription_description" = "- Vzhľadom na pretrvávajúce náklady servera na úložisko v cloude a synchronizáciu priebehu vyžadujeme predplatné na kompenzáciu nákladov na túto funkciu"; +"benefits_disclaimer_subscription_description" = "- Vzhľadom na pretrvávajúce náklady servera na úložisko v cloude a synchronizáciu priebehu, vyžadujeme predplatné na kompenzáciu nákladov na túto funkciu"; "subscribe_title" = "Odoberaj teraz"; -"renewal_description" = "Automaticky sa obnovuje mesačne"; +"renewal_description" = "Automatická obnova každý mesiac"; "pro_welcome_title" = "Vitajte v BookPlayer Pro!"; "pro_welcome_description" = "Keď bude k dispozícii Wi-Fi, spustíme synchronizáciu vašej knižnice"; "benefits_disclaimer_watch_description" = "- Samostatné prehrávanie na Apple Watch zatiaľ nie je dostupné. Bude súčasťou budúcej verzie."; @@ -275,7 +275,7 @@ "agreement_prefix_title" = "Pokračovaním vyjadrujete súhlas"; "yearly_title" = "za rok"; "monthly_title" = "za mesiac"; -"choose_plan_title" = "Vyberte si svoj plán"; +"choose_plan_title" = "Výber svojho plánu"; "tasks_title" = "Úlohy"; "queued_sync_tasks_title" = "Úlohy synchronizácie vo fronte (%d)"; "sync_tasks_alert_description" = "Ak je synchronizačná úloha zaseknutá a nevymaže sa ani po aktívnom pripojení na internet, môžete sa odhlásiť a prihlásiť, aby ste vymazali aktuálne úlohy. Tým sa vaša knižnica vráti do posledného stavu synchronizovaného s našimi servermi. @@ -291,10 +291,10 @@ Usilovne pracujeme na poskytovaní bezproblémového zážitku, ak je to možné "settings_crash_reports_title" = "Zakázať správy o zlyhaní"; "settings_skan_attribution_title" = "Zakázať SKAN"; "settings_skan_attribution_description" = "SKAN je riešenie spoločnosti Apple zamerané na ochranu súkromia, ktoré umožňuje merať účinnosť kampane bez narušenia súkromia používateľov."; -"intent_sleeptimer_cancel" = "Zrušenie časovač spánku"; +"intent_sleeptimer_cancel" = "Zrušenie časovača spánku"; "intent_sleeptimer_set_duration" = "Nastavenie časovača spánku s trvaním"; "duration_title" = "Trvanie"; -"intent_sleeptimer_request_duration_title" = "Ako dlho"; +"intent_sleeptimer_request_duration_title" = "Na ako dlho"; "Set Sleep Timer for ${duration}" = "Nastaviť časovač spánku na ${duration}"; "intent_sleeptimer_eoc_title" = "Nastavenie časovača spánku na koniec kapitoly"; "intent_lastbook_play_title" = "Obnovenie poslednej prehrávanej knihy"; @@ -304,20 +304,20 @@ Usilovne pracujeme na poskytovaní bezproblémového zážitku, ak je to možné "settings_share_debug_information" = "Zdieľať informácie o ladení"; "settings_autlock_section_title" = "Automatické uzamknutie"; "settings_sleeptimer_auto_title" = "Časovač automatického spánku"; -"settings_sleeptimer_auto_description" = "Po obnovení prehrávania sa reštartuje posledný aktívny časovač spánku"; -"sync_tasks_inprogress_alert_title" = "Nie je možné synchronizovať dáta, pokiaľ prebiehajú úlohy vo fronte"; -"sync_tasks_view_title" = "Zobraziť úlohy"; +"settings_sleeptimer_auto_description" = "Po obnovení prehrávania reštartovať posledný aktívny časovač spánku"; +"sync_tasks_inprogress_alert_title" = "Nie je možné synchronizovať dáta pokiaľ prebiehajú úlohy vo fronte"; +"sync_tasks_view_title" = "Zobrazenie úloh"; "settings_datausage_title" = "Využitie dát"; -"datausage_upload_wifionly_title" = "Nahrajte pomocou mobilných dát"; -"warning_title" = "POZOR"; +"datausage_upload_wifionly_title" = "Nahrávanie pomocou mobilných dát"; +"warning_title" = "Upozornenie"; "sync_tasks_item_upload_queued" = "Vo fronte je úloha nahrávania pre: \n %@ \n Odstránením súboru zabránite aplikácii nahrať ho"; "intent_custom_skipforward_title" = "Preskočiť dopredu s intervalom"; -"intent_custom_skip_request_title" = "Ako dlho?"; +"intent_custom_skip_request_title" = "O koľko?"; "intent_custom_interval_title" = "Časový interval"; "Skip forward ${interval}" = "Preskočiť dopredu ${interval}"; "intent_custom_skiprewind_title" = "Pretáčanie dozadu s intervalom"; "Rewind ${interval}" = "Pretočiť ${interval}"; "settings_lock_orientation_title" = "Orientácia uzamknutá"; "more_title" = "Viac"; -"repeat_turn_on_title" = "Pre túto knihu zapnite možnosť Opakovať"; +"repeat_turn_on_title" = "Zapnúť Opakovať pre túto knihu"; "repeat_turn_off_title" = "Vypnúť Opakovať pre túto knihu"; diff --git a/BookPlayerTests/Services/AccountServiceTests.swift b/BookPlayerTests/Services/AccountServiceTests.swift index 3f0d47c3..a2c516e8 100644 --- a/BookPlayerTests/Services/AccountServiceTests.swift +++ b/BookPlayerTests/Services/AccountServiceTests.swift @@ -15,12 +15,12 @@ import XCTest class AccountServiceTests: XCTestCase { var sut: AccountService! - var mockKeychain: KeychainServiceProtocolMock! + var mockKeychain: KeychainService! override func setUp() { DataTestUtils.clearFolderContents(url: DataManager.getProcessedFolderURL()) let dataManager = DataManager(coreDataStack: CoreDataStack(testPath: "/dev/null")) - self.mockKeychain = KeychainServiceProtocolMock() + self.mockKeychain = KeychainService() self.sut = AccountService( dataManager: dataManager, client: NetworkClientMock(mockedResponse: Empty()), @@ -89,7 +89,7 @@ class AccountServiceTests: XCTestCase { try self.sut.logout() - XCTAssert(try mockKeychain.getAccessToken() == nil) + XCTAssert(try mockKeychain.get(.token) == nil) let account = self.sut.getAccount() XCTAssert(account?.donationMade == true) XCTAssert(account?.hasSubscription == false) @@ -100,7 +100,7 @@ class AccountServiceTests: XCTestCase { func testDeleteAccoount() async throws { let dataManager = DataManager(coreDataStack: CoreDataStack(testPath: "/dev/null")) let mockResponse = DeleteResponse(message: "success") - let keychainMock = KeychainServiceProtocolMock() + let keychainMock = KeychainService() self.sut = AccountService( dataManager: dataManager, @@ -115,6 +115,6 @@ class AccountServiceTests: XCTestCase { XCTAssert(result == "success") XCTAssert(self.sut.hasAccount() == true) XCTAssert(self.sut.getAccount()?.hasSubscription == false) - XCTAssert(try mockKeychain.getAccessToken() == nil) + XCTAssert(try mockKeychain.get(.token) == nil) } } diff --git a/BookPlayerTests/Services/KeychainServiceTests.swift b/BookPlayerTests/Services/KeychainServiceTests.swift index 1c6599c3..a6957591 100644 --- a/BookPlayerTests/Services/KeychainServiceTests.swift +++ b/BookPlayerTests/Services/KeychainServiceTests.swift @@ -16,19 +16,39 @@ import XCTest class KeychainServiceTests: XCTestCase { var sut: KeychainService! + private struct TestItem: Codable { + var name: String + var token: String + } + override func setUp() { self.sut = KeychainService() - try? self.sut.removeAccessToken() + try? self.sut.remove(.token) } func testSettingAndGettingKey() throws { - let emptyToken = try! self.sut.getAccessToken() + let emptyToken = try! self.sut.get(.token) XCTAssert(emptyToken == nil) - try! self.sut.setAccessToken("test token") - let token = try! self.sut.getAccessToken() + try! self.sut.set("test token", key: .token) + let token = try! self.sut.get(.token) XCTAssert(token == "test token") - try! self.sut.setAccessToken("updated token") - let updatedToken = try! self.sut.getAccessToken() + try! self.sut.set("updated token", key: .token) + let updatedToken = try! self.sut.get(.token) XCTAssert(updatedToken == "updated token") } + + func testSettingAndGettingCodableKey() throws { + let emptyToken: TestItem? = try! self.sut.get(.token) + XCTAssert(emptyToken == nil) + var testItem = TestItem(name: "test name", token: "test token") + try! self.sut.set(testItem, key: .token) + let item: TestItem = try! self.sut.get(.token)! + XCTAssert(item.name == "test name") + XCTAssert(item.token == "test token") + testItem.token = "updated token" + try! self.sut.set(testItem, key: .token) + let updatedItem: TestItem = try! self.sut.get(.token)! + XCTAssert(updatedItem.name == "test name") + XCTAssert(updatedItem.token == "updated token") + } } diff --git a/Shared/Network/NetworkClient.swift b/Shared/Network/NetworkClient.swift index d6fb3eeb..2725b3b9 100644 --- a/Shared/Network/NetworkClient.swift +++ b/Shared/Network/NetworkClient.swift @@ -219,7 +219,7 @@ public class NetworkClient: NetworkClientProtocol, BPLogger { request.setValue("application/json", forHTTPHeaderField: "Content-Type") if useKeychain, - let accessToken = try? keychain.getAccessToken() { + let accessToken: String = try? keychain.get(.token) { request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization") } diff --git a/Shared/Services/Account/AccountService.swift b/Shared/Services/Account/AccountService.swift index 7a77ac51..c902ed74 100644 --- a/Shared/Services/Account/AccountService.swift +++ b/Shared/Services/Account/AccountService.swift @@ -270,7 +270,7 @@ public final class AccountService: AccountServiceProtocol { hasSubscription: true ) - try self.keychain.setAccessToken(token) + try self.keychain.set(token, key: .token) _ = try await Purchases.shared.logIn(userId) } @@ -281,7 +281,7 @@ public final class AccountService: AccountServiceProtocol { ) async throws -> Account? { let response: LoginResponse = try await provider.request(.login(token: token)) - try self.keychain.setAccessToken(response.token) + try self.keychain.set(response.token, key: .token) let (customerInfo, _) = try await Purchases.shared.logIn(userId) @@ -318,7 +318,7 @@ public final class AccountService: AccountServiceProtocol { } public func logout() throws { - try self.keychain.removeAccessToken() + try self.keychain.remove(.token) self.updateAccount( id: "", diff --git a/Shared/Services/KeychainService.swift b/Shared/Services/KeychainService.swift index 4103a43b..d1f58187 100644 --- a/Shared/Services/KeychainService.swift +++ b/Shared/Services/KeychainService.swift @@ -9,32 +9,56 @@ import Foundation import Security -/// sourcery: AutoMockable public protocol KeychainServiceProtocol { - func setAccessToken(_ token: String) throws - func getAccessToken() throws -> String? - func removeAccessToken() throws + func set(_ value: String, key: KeychainKeys) throws + func set(_ value: T, key: KeychainKeys) throws + + func get(_ key: KeychainKeys) throws -> String? + func get(_ key: KeychainKeys) throws -> T? + func remove(_ key: KeychainKeys) throws +} + +public enum KeychainKeys: String { + /// Stores BookPlayer's API access token + case token = "access_token" } public class KeychainService: KeychainServiceProtocol { let service = Bundle.main.configurationString(for: .bundleIdentifier) - let tokenKey = "access_token" + + private let encoder: JSONEncoder = JSONEncoder() + private let decoder: JSONDecoder = JSONDecoder() public init() {} - public func setAccessToken(_ token: String) throws { - try self.set(token, key: tokenKey) - } + public func get(_ key: KeychainKeys) throws -> T? { + guard + let data = try getData(key.rawValue) + else { + return nil + } - public func getAccessToken() throws -> String? { - return try self.get(tokenKey) + return try decoder.decode(T.self, from: data) } - public func removeAccessToken() throws { - try self.remove(tokenKey) + public func get(_ key: KeychainKeys) throws -> String? { + guard + let data = try getData(key.rawValue) + else { + return nil + } + + guard + let string = String(data: data, encoding: .utf8) + else { + // unexpectedError + throw NSError(domain: NSOSStatusErrorDomain, code: -99999) + } + + return string } - private func get(_ key: String) throws -> String? { + private func getData(_ key: String) throws -> Data? { let query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: service, @@ -49,13 +73,12 @@ public class KeychainService: KeychainServiceProtocol { switch status { case errSecSuccess: guard - let data = result as? Data, - let string = String(data: data, encoding: .utf8) + let data = result as? Data else { // unexpectedError throw NSError(domain: NSOSStatusErrorDomain, code: -99999) } - return string + return data case errSecItemNotFound: return nil default: @@ -63,12 +86,21 @@ public class KeychainService: KeychainServiceProtocol { } } - private func set(_ value: String, key: String) throws { + public func set(_ value: T, key: KeychainKeys) throws { + let data = try encoder.encode(value) + try setData(data, key: key.rawValue) + } + + public func set(_ value: String, key: KeychainKeys) throws { guard let data = value.data(using: .utf8, allowLossyConversion: false) else { // conversionError throw NSError(domain: NSOSStatusErrorDomain, code: -67594) } + try setData(data, key: key.rawValue) + } + + private func setData(_ data: Data, key: String) throws { let searchQuery: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: service, @@ -93,7 +125,7 @@ public class KeychainService: KeychainServiceProtocol { #if os(iOS) if status == errSecInteractionNotAllowed && floor(NSFoundationVersionNumber) <= floor(NSFoundationVersionNumber_iOS_8_0) { try remove(key) - try set(value, key: key) + try setData(data, key: key) } else { status = SecItemUpdate(query as CFDictionary, attributes as CFDictionary) if status != errSecSuccess { @@ -128,6 +160,10 @@ public class KeychainService: KeychainServiceProtocol { } } + public func remove(_ key: KeychainKeys) throws { + try remove(key.rawValue) + } + private func remove(_ key: String) throws { let query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword,