diff --git a/.swiftformat b/.swiftformat new file mode 100644 index 000000000..a8547a650 --- /dev/null +++ b/.swiftformat @@ -0,0 +1,112 @@ +# SwiftFormat Configuration for Palace Project +# https://github.com/nicklockwood/SwiftFormat + +# Include/exclude paths +--exclude Carthage,readium-sdk,readium-shared-js,adept-ios,adobe-content-filter,adobe-rmsdk,ios-tenprintcover,mobile-bookmark-spec,build,DerivedData,fastlane,scripts +--exclude "*.generated.swift" + +# Formatting rules +--rules blankLinesAroundMark +--rules blankLinesAtEndOfScope +--rules blankLinesAtStartOfScope +--rules blankLinesBetweenScopes +--rules braces +--rules conditionalAssignment +--rules consecutiveBlankLines +--rules consecutiveSpaces +--rules duplicateImports +--rules elseOnSameLine +--rules emptyBraces +--rules enumNamespaces +--rules extensionAccessControl +--rules hoistPatternLet +--rules indent +--rules initCoderUnavailable +--rules leadingDelimiters +--rules linebreakAtEndOfFile +--rules markTypes +--rules modifierOrder +--rules numberFormatting +--rules preferKeyPath +--rules redundantBackticks +--rules redundantBreak +--rules redundantExtensionACL +--rules redundantFileprivate +--rules redundantGet +--rules redundantInit +--rules redundantLet +--rules redundantLetError +--rules redundantNilInit +--rules redundantObjc +--rules redundantParens +--rules redundantPattern +--rules redundantRawValues +--rules redundantReturn +--rules redundantSelf +--rules redundantType +--rules redundantVoidReturnType +--rules semicolons +--rules sortImports +--rules spaceAroundBraces +--rules spaceAroundBrackets +--rules spaceAroundComments +--rules spaceAroundGenerics +--rules spaceAroundOperators +--rules spaceAroundParens +--rules spaceInsideBraces +--rules spaceInsideBrackets +--rules spaceInsideComments +--rules spaceInsideGenerics +--rules spaceInsideParens +--rules strongOutlets +--rules strongifiedSelf +--rules todos +--rules trailingClosures +--rules trailingCommas +--rules trailingSpace +--rules typeSugar +--rules unusedArguments +--rules void +--rules wrap +--rules wrapArguments +--rules wrapAttributes +--rules wrapConditionalBodies +--rules wrapEnumCases +--rules wrapMultilineStatementBraces + +# Configuration options +--indent 2 +--tabwidth 2 +--maxwidth 120 +--wraparguments before-first +--wrapcollections before-first +--wrapparameters before-first +--closingparen balanced +--commas always +--trimwhitespace always +--insertlines true +--removelines true +--allman false +--fractiongrouping disabled +--exponentgrouping disabled +--decimalgrouping 3,6 +--binarygrouping 4,8 +--octalgrouping 4,8 +--hexgrouping 4,8 +--ifdef no-indent +--xcodeindentation disabled +--importgrouping testable-bottom +--self remove +--selfrequired +--stripunusedargs always +--shortoptionals always +--ranges spaced +--operatorfunc spaced +--nospaceoperators ...,..< +--nowrapoperators +--assetliterals visual-width +--yodaswap always + +# Swift version and SwiftUI specific +--swiftversion 5.9 +--enable wrapMultilineStatementBraces diff --git a/.swiftlint-migration.yml b/.swiftlint-migration.yml new file mode 100644 index 000000000..c69057b62 --- /dev/null +++ b/.swiftlint-migration.yml @@ -0,0 +1,80 @@ +# SwiftLint Migration Configuration for Palace Project +# This is a more lenient configuration for gradual adoption + +# Paths to include for linting +included: + - Palace + - PalaceTests + - PalaceUIKit + - ios-audiobooktoolkit/PalaceAudiobookToolkit + - ios-audiobooktoolkit/PalaceAudiobookToolkitTests + - ios-audiobook-overdrive/OverdriveProcessor + +# Paths to exclude from linting +excluded: + - Carthage + - readium-sdk + - readium-shared-js + - adept-ios + - adobe-content-filter + - adobe-rmsdk + - ios-tenprintcover + - mobile-bookmark-spec + - build + - DerivedData + - fastlane + - scripts + - "*.generated.swift" + +# Start with only the most critical rules +disabled_rules: + - todo + - line_length + - function_body_length + - type_body_length + - file_length + - cyclomatic_complexity + - function_parameter_count + - opening_brace # Common formatting issue - fix with SwiftFormat + - trailing_closure # Style preference - can be ignored initially + - unused_optional_binding # Not critical for functionality + +# Focus on critical opt-in rules only +opt_in_rules: + - empty_string + - force_unwrapping # Critical for crash prevention + - implicitly_unwrapped_optional # Critical for crash prevention + - legacy_random + - redundant_nil_coalescing + - unused_import + +# More lenient configurations +line_length: + warning: 150 + error: 200 + ignores_urls: true + ignores_function_declarations: true + ignores_comments: true + +function_body_length: + warning: 100 + error: 200 + +type_body_length: + warning: 500 + error: 1000 + +file_length: + warning: 1000 + error: 2000 + ignore_comment_only_lines: true + +cyclomatic_complexity: + warning: 25 + error: 50 + +# Set a much higher warning threshold for migration +warning_threshold: 500 + +# Custom reporter +reporter: "xcode" diff --git a/.swiftlint.yml b/.swiftlint.yml new file mode 100644 index 000000000..c69057b62 --- /dev/null +++ b/.swiftlint.yml @@ -0,0 +1,80 @@ +# SwiftLint Migration Configuration for Palace Project +# This is a more lenient configuration for gradual adoption + +# Paths to include for linting +included: + - Palace + - PalaceTests + - PalaceUIKit + - ios-audiobooktoolkit/PalaceAudiobookToolkit + - ios-audiobooktoolkit/PalaceAudiobookToolkitTests + - ios-audiobook-overdrive/OverdriveProcessor + +# Paths to exclude from linting +excluded: + - Carthage + - readium-sdk + - readium-shared-js + - adept-ios + - adobe-content-filter + - adobe-rmsdk + - ios-tenprintcover + - mobile-bookmark-spec + - build + - DerivedData + - fastlane + - scripts + - "*.generated.swift" + +# Start with only the most critical rules +disabled_rules: + - todo + - line_length + - function_body_length + - type_body_length + - file_length + - cyclomatic_complexity + - function_parameter_count + - opening_brace # Common formatting issue - fix with SwiftFormat + - trailing_closure # Style preference - can be ignored initially + - unused_optional_binding # Not critical for functionality + +# Focus on critical opt-in rules only +opt_in_rules: + - empty_string + - force_unwrapping # Critical for crash prevention + - implicitly_unwrapped_optional # Critical for crash prevention + - legacy_random + - redundant_nil_coalescing + - unused_import + +# More lenient configurations +line_length: + warning: 150 + error: 200 + ignores_urls: true + ignores_function_declarations: true + ignores_comments: true + +function_body_length: + warning: 100 + error: 200 + +type_body_length: + warning: 500 + error: 1000 + +file_length: + warning: 1000 + error: 2000 + ignore_comment_only_lines: true + +cyclomatic_complexity: + warning: 25 + error: 50 + +# Set a much higher warning threshold for migration +warning_threshold: 500 + +# Custom reporter +reporter: "xcode" diff --git a/LINTING.md b/LINTING.md new file mode 100644 index 000000000..2dca1749e --- /dev/null +++ b/LINTING.md @@ -0,0 +1,295 @@ +# Code Linting and Formatting Setup + +This document describes the linting and formatting setup for the Palace iOS project. + +## Overview + +We use two main tools to maintain code quality: + +- **SwiftLint**: Static analysis tool for Swift that enforces style and conventions +- **SwiftFormat**: Code formatter that automatically fixes formatting issues + +## Installation + +### Automatic Installation + +Run the installation script to set up both tools: + +```bash +./scripts/install-linting-tools.sh +``` + +### Manual Installation + +If the automatic installation fails, you can install manually: + +```bash +# Via Homebrew (recommended) +brew install swiftlint swiftformat + +# Or download binaries from GitHub releases: +# - SwiftLint: https://github.com/realm/SwiftLint/releases +# - SwiftFormat: https://github.com/nicklockwood/SwiftFormat/releases +``` + +## Usage + +### Formatting Code + +Format all Swift files in the project: + +```bash +./scripts/format-code.sh +``` + +Preview formatting changes without applying them: + +```bash +./scripts/format-code.sh --preview +``` + +Format a specific file: + +```bash +./scripts/format-code.sh --file Palace/Book/TPPBook.swift +``` + +### Linting Code + +Lint all Swift files in the project: + +```bash +./scripts/lint-code.sh +``` + +Auto-fix linting issues where possible: + +```bash +./scripts/lint-code.sh --fix +``` + +Lint a specific file: + +```bash +./scripts/lint-code.sh --file Palace/Book/TPPBook.swift +``` + +Show all available linting rules: + +```bash +./scripts/lint-code.sh --rules +``` + +### Xcode Integration + +To see linting warnings and errors directly in Xcode: + +1. Open `Palace.xcodeproj` in Xcode +2. Select the 'Palace' target +3. Go to 'Build Phases' tab +4. Click the '+' button and choose 'New Run Script Phase' +5. Name it 'SwiftLint' +6. Add this script: + +```bash +# SwiftLint Build Phase +if which swiftlint > /dev/null; then + swiftlint +else + echo "warning: SwiftLint not installed, download from https://github.com/realm/SwiftLint" +fi +``` + +7. Move the SwiftLint phase to run after 'Compile Sources' + +You can also run the build phase setup script for detailed instructions: + +```bash +./scripts/add-swiftlint-buildphase.sh +``` + +## Configuration + +### SwiftLint Configuration (`.swiftlint.yml`) + +The SwiftLint configuration includes: + +- **Included paths**: `Palace/`, `PalaceTests/`, `PalaceUIKit/`, and audiobook toolkit modules +- **Excluded paths**: Third-party code, Carthage dependencies, build artifacts +- **Rules**: Comprehensive set of style and quality rules +- **Customizations**: Adjusted line length, complexity thresholds, and naming rules + +Key configurations: +- Line length: 120 characters (warning), 150 (error) +- Function body length: 50 lines (warning), 100 (error) +- File length: 500 lines (warning), 1000 (error) +- Cyclomatic complexity: 15 (warning), 25 (error) + +### SwiftFormat Configuration (`.swiftformat`) + +The SwiftFormat configuration includes: + +- **Indentation**: 2 spaces +- **Line width**: 120 characters +- **Rules**: Comprehensive formatting rules for consistent style +- **Swift features**: Support for modern Swift syntax and SwiftUI + +Key formatting rules: +- Consistent spacing around operators, braces, and parentheses +- Sorted imports with testable imports at bottom +- Trailing commas in collections +- Redundant code removal +- Modern Swift syntax preferences + +## Project Structure + +The linting setup covers these directories: + +``` +Palace/ # Main app code +PalaceTests/ # App tests +PalaceUIKit/ # UI framework +ios-audiobooktoolkit/PalaceAudiobookToolkit/ # Audiobook toolkit +ios-audiobooktoolkit/PalaceAudiobookToolkitTests/ # Toolkit tests +ios-audiobook-overdrive/OverdriveProcessor/ # Overdrive processor +``` + +Excluded directories: +- `Carthage/` - Third-party dependencies +- `readium-sdk/` - Legacy Readium SDK +- `adept-ios/` - Adobe DRM +- `scripts/` - Build scripts +- Generated files (`*.generated.swift`) + +## Workflow Integration + +### Pre-commit Hooks + +Consider setting up pre-commit hooks to automatically format code: + +```bash +# Create .git/hooks/pre-commit +#!/bin/bash +./scripts/format-code.sh --preview +if [ $? -ne 0 ]; then + echo "Code formatting issues found. Run './scripts/format-code.sh' to fix." + exit 1 +fi +``` + +### CI/CD Integration + +Add linting to your CI pipeline: + +```bash +# In your CI script +./scripts/lint-code.sh --strict +``` + +The `--strict` flag treats warnings as errors for CI environments. + +### Recommended Workflow + +1. **Before committing**: + ```bash + ./scripts/format-code.sh # Format code + ./scripts/lint-code.sh # Check for issues + ``` + +2. **During development**: + - Build in Xcode to see real-time linting feedback + - Use `--fix` flag to auto-correct simple issues + +3. **Code review**: + - Formatting should be consistent + - No linting errors should be present + +## Customization + +### Adding New Rules + +Edit `.swiftlint.yml` to add new rules to the `opt_in_rules` section: + +```yaml +opt_in_rules: + - new_rule_name +``` + +### Disabling Rules + +Add rules to the `disabled_rules` section: + +```yaml +disabled_rules: + - rule_to_disable +``` + +### File-specific Overrides + +Use inline comments to disable rules for specific lines: + +```swift +// swiftlint:disable rule_name +problematic_code() +// swiftlint:enable rule_name +``` + +Or for entire files: + +```swift +// swiftlint:disable file_length +``` + +### Formatting Overrides + +Use inline comments for SwiftFormat: + +```swift +// swiftformat:disable rule_name +code_to_preserve() +// swiftformat:enable rule_name +``` + +## Troubleshooting + +### Common Issues + +1. **Tools not found**: Ensure SwiftLint/SwiftFormat are in your PATH +2. **Configuration errors**: Validate YAML syntax in `.swiftlint.yml` +3. **Performance**: Large files may take time to process +4. **Xcode integration**: Ensure build phase script path is correct + +### Getting Help + +```bash +swiftlint help # SwiftLint help +swiftformat --help # SwiftFormat help +./scripts/lint-code.sh --rules # List all linting rules +``` + +## Version Information + +Current tool versions: +- SwiftLint: 0.61.0 +- SwiftFormat: 0.58.2 + +Update tools regularly: + +```bash +brew upgrade swiftlint swiftformat +``` + +## Contributing + +When contributing to the project: + +1. Ensure your code passes linting: `./scripts/lint-code.sh` +2. Format your code: `./scripts/format-code.sh` +3. Follow the existing code style conventions +4. Update this documentation if you modify the linting setup + +## Resources + +- [SwiftLint Documentation](https://realm.github.io/SwiftLint/) +- [SwiftFormat Documentation](https://github.com/nicklockwood/SwiftFormat) +- [Swift API Design Guidelines](https://swift.org/documentation/api-design-guidelines/) diff --git a/LINTING_MIGRATION_PLAN.md b/LINTING_MIGRATION_PLAN.md new file mode 100644 index 000000000..83812ccdb --- /dev/null +++ b/LINTING_MIGRATION_PLAN.md @@ -0,0 +1,94 @@ +# Linting Migration Plan for Palace Project + +## Overview +This document outlines a phased approach to introduce linting to the Palace project without overwhelming the development process. + +## Phase 1: Critical Issues Only (Current) +**Goal**: Fix issues that could cause crashes or serious bugs +**Duration**: 1-2 weeks + +### Configuration +- Use `.swiftlint-migration.yml` +- Focus on force unwrapping, implicitly unwrapped optionals +- Disable most style rules + +### Steps +1. Run `./scripts/gradual-linting-setup.sh --auto-fix` +2. Fix critical issues one file at a time +3. Run `./scripts/gradual-linting-setup.sh --report` weekly + +## Phase 2: Code Quality Rules (Week 3-4) +**Goal**: Improve code maintainability +**Duration**: 2 weeks + +### Enable Additional Rules +- `function_body_length` (with higher limits) +- `type_body_length` (with higher limits) +- `cyclomatic_complexity` (with higher limits) + +### Steps +1. Update `.swiftlint-migration.yml` to include quality rules +2. Address largest/most complex files first +3. Refactor incrementally + +## Phase 3: Style Consistency (Week 5-6) +**Goal**: Ensure consistent code style +**Duration**: 2 weeks + +### Enable Style Rules +- `opening_brace` +- `trailing_closure` +- `line_length` (with project-appropriate limits) + +### Steps +1. Run SwiftFormat to auto-fix most issues +2. Enable style rules gradually +3. Fix remaining manual issues + +## Phase 4: Full Rule Set (Week 7+) +**Goal**: Complete linting coverage +**Duration**: Ongoing + +### Final Configuration +- Switch to full `.swiftlint.yml` +- Enable all appropriate rules +- Lower thresholds to final values + +### Maintenance +- New code follows all rules +- Legacy code improved opportunistically +- Regular linting in CI/CD + +## Daily Workflow During Migration + +### For New Code +- Always run linting on new/modified files +- Follow full standards for new code + +### For Existing Code +- Fix issues in files you're already modifying +- Don't create separate "linting only" PRs for now + +### Commands +```bash +# Check current migration status +./scripts/gradual-linting-setup.sh --report + +# Fix formatting issues automatically +./scripts/gradual-linting-setup.sh --auto-fix + +# Lint with current migration rules +./scripts/gradual-linting-setup.sh --lint +``` + +## Success Metrics +- [ ] Phase 1: Zero critical errors (force unwrapping, etc.) +- [ ] Phase 2: Functions < 100 lines, classes < 500 lines +- [ ] Phase 3: Consistent formatting across codebase +- [ ] Phase 4: < 50 total linting violations + +## Tips for Success +1. **Start small**: Fix one file completely rather than partial fixes across many files +2. **Auto-fix first**: Let SwiftFormat handle formatting automatically +3. **Focus on value**: Prioritize rules that prevent bugs over style preferences +4. **Team alignment**: Ensure all developers understand the migration plan diff --git a/Palace/Accounts/AgeCheck/TPPAgeCheck.swift b/Palace/Accounts/AgeCheck/TPPAgeCheck.swift index cf8f0cd4a..7ace69ccb 100644 --- a/Palace/Accounts/AgeCheck/TPPAgeCheck.swift +++ b/Palace/Accounts/AgeCheck/TPPAgeCheck.swift @@ -1,36 +1,45 @@ import Foundation +// MARK: - TPPAgeCheckValidationDelegate + protocol TPPAgeCheckValidationDelegate: AnyObject { - var minYear : Int { get } - var currentYear : Int { get } - var birthYearList : [Int] { get } - var ageCheckCompleted : Bool { get set } - + var minYear: Int { get } + var currentYear: Int { get } + var birthYearList: [Int] { get } + var ageCheckCompleted: Bool { get set } + func isValid(birthYear: Int) -> Bool - + func didCompleteAgeCheck(_ birthYear: Int) func didFailAgeCheck() } +// MARK: - TPPAgeCheckVerifying + @objc protocol TPPAgeCheckVerifying { - func verifyCurrentAccountAgeRequirement(userAccountProvider: TPPUserAccountProvider, - currentLibraryAccountProvider: TPPCurrentLibraryAccountProvider, - completion: ((Bool) -> ())?) -> Void + func verifyCurrentAccountAgeRequirement( + userAccountProvider: TPPUserAccountProvider, + currentLibraryAccountProvider: TPPCurrentLibraryAccountProvider, + completion: ((Bool) -> Void)? + ) } +// MARK: - TPPAgeCheckChoiceStorage + @objc protocol TPPAgeCheckChoiceStorage { var userPresentedAgeCheck: Bool { get set } } -@objcMembers final class TPPAgeCheck : NSObject, TPPAgeCheckValidationDelegate, TPPAgeCheckVerifying { - +// MARK: - TPPAgeCheck + +@objcMembers final class TPPAgeCheck: NSObject, TPPAgeCheckValidationDelegate, TPPAgeCheckVerifying { // Members private let serialQueue = DispatchQueue(label: "\(Bundle.main.bundleIdentifier!).ageCheck") - private var handlerList = [((Bool) -> ())]() + private var handlerList = [(Bool) -> Void]() private var isPresenting = false private let ageCheckChoiceStorage: TPPAgeCheckChoiceStorage var ageCheckCompleted: Bool = false - + let minYear: Int let currentYear: Int let birthYearList: [Int] @@ -40,51 +49,52 @@ protocol TPPAgeCheckValidationDelegate: AnyObject { minYear = 1900 currentYear = Calendar.current.component(.year, from: Date()) birthYearList = Array(minYear...currentYear) - + super.init() } - - func verifyCurrentAccountAgeRequirement(userAccountProvider: TPPUserAccountProvider, - currentLibraryAccountProvider: TPPCurrentLibraryAccountProvider, - completion: ((Bool) -> ())?) { + + func verifyCurrentAccountAgeRequirement( + userAccountProvider: TPPUserAccountProvider, + currentLibraryAccountProvider: TPPCurrentLibraryAccountProvider, + completion: ((Bool) -> Void)? + ) { serialQueue.async { [weak self] in - guard let accountDetails = currentLibraryAccountProvider.currentAccount?.details else { completion?(false) return } - + if userAccountProvider.needsAuth == true || accountDetails.userAboveAgeLimit { completion?(true) return } - + if !accountDetails.userAboveAgeLimit && (self?.ageCheckChoiceStorage.userPresentedAgeCheck ?? false) { completion?(false) return } - + // Queue the callback if let completion = completion { self?.handlerList.append(completion) } - + // We're already presenting the age verification, return if self?.isPresenting ?? false { return } - - let accountDetailsCompletion: ((Bool) -> ()) = { aboveAgeLimit in + + let accountDetailsCompletion: ((Bool) -> Void) = { aboveAgeLimit in accountDetails.userAboveAgeLimit = aboveAgeLimit } self?.handlerList.append(accountDetailsCompletion) - + // Perform age check presentation self?.isPresenting = true self?.presentAgeVerificationView() } } - + fileprivate func presentAgeVerificationView() { DispatchQueue.main.async { let vc = TPPAgeCheckViewController(ageCheckDelegate: self) @@ -92,26 +102,26 @@ protocol TPPAgeCheckValidationDelegate: AnyObject { TPPPresentationUtils.safelyPresent(navigationVC) } } - + func isValid(birthYear: Int) -> Bool { - return birthYear >= minYear && birthYear <= currentYear + birthYear >= minYear && birthYear <= currentYear } - + func didCompleteAgeCheck(_ birthYear: Int) { - self.serialQueue.async { [weak self] in + serialQueue.async { [weak self] in let aboveAgeLimit = Calendar.current.component(.year, from: Date()) - birthYear > 13 self?.ageCheckChoiceStorage.userPresentedAgeCheck = true self?.isPresenting = false - + for handler in self?.handlerList ?? [] { handler(aboveAgeLimit) } self?.handlerList.removeAll() } } - + func didFailAgeCheck() { - self.serialQueue.async { [weak self] in + serialQueue.async { [weak self] in self?.isPresenting = false self?.ageCheckChoiceStorage.userPresentedAgeCheck = false self?.handlerList.removeAll() diff --git a/Palace/Accounts/AgeCheck/TPPAgeCheckViewController.swift b/Palace/Accounts/AgeCheck/TPPAgeCheckViewController.swift index 937ccdad8..28dbe10ee 100644 --- a/Palace/Accounts/AgeCheck/TPPAgeCheckViewController.swift +++ b/Palace/Accounts/AgeCheck/TPPAgeCheckViewController.swift @@ -8,90 +8,93 @@ import UIKit +// MARK: - TPPAgeCheckViewController + class TPPAgeCheckViewController: UIViewController { typealias DisplayStrings = Strings.AgeCheck // Constants let textFieldHeight: CGFloat = 40.0 - + fileprivate var birthYearSelected = 0 - + weak var ageCheckDelegate: TPPAgeCheckValidationDelegate? - + init(ageCheckDelegate: TPPAgeCheckValidationDelegate) { self.ageCheckDelegate = ageCheckDelegate - + super.init(nibName: nil, bundle: nil) } - - required init?(coder: NSCoder) { + + @available(*, unavailable) + required init?(coder _: NSCoder) { fatalError("init(coder:) has not been implemented") } - + override func viewDidLoad() { super.viewDidLoad() - - self.setupView() + + setupView() } - + // We need to fail the age check because user can swipe down to dismiss the view controller in iOS 13+ deinit { if !(ageCheckDelegate?.ageCheckCompleted ?? false) { ageCheckDelegate?.didFailAgeCheck() } } - + @objc func completeAgeCheck() { guard ageCheckDelegate?.isValid(birthYear: birthYearSelected) ?? false else { return } - + ageCheckDelegate?.didCompleteAgeCheck(birthYearSelected) ageCheckDelegate?.ageCheckCompleted = true dismiss(animated: true, completion: nil) } - + // MARK: - UI - + func updateBarButton() { rightBarButtonItem.isEnabled = ageCheckDelegate?.isValid(birthYear: birthYearSelected) ?? false } - + @objc func hidePickerView() { - self.view.endEditing(true) + view.endEditing(true) } - + func setupView() { - self.title = DisplayStrings.title - + title = DisplayStrings.title + if #available(iOS 13.0, *) { view.backgroundColor = UIColor.systemGray6 } else { view.backgroundColor = .white } - + navigationItem.setRightBarButton(rightBarButtonItem, animated: true) - + inputTextField.translatesAutoresizingMaskIntoConstraints = false titleLabel.translatesAutoresizingMaskIntoConstraints = false - + view.addSubview(inputTextField) view.addSubview(titleLabel) - + inputTextField.centerYAnchor.constraint(equalTo: view.centerYAnchor).isActive = true inputTextField.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 50).isActive = true inputTextField.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -50).isActive = true inputTextField.heightAnchor.constraint(equalToConstant: textFieldHeight).isActive = true view.bringSubviewToFront(inputTextField) - + titleLabel.centerXAnchor.constraint(equalTo: view.centerXAnchor).isActive = true titleLabel.bottomAnchor.constraint(equalTo: view.centerYAnchor, constant: -30).isActive = true titleLabel.widthAnchor.constraint(equalTo: inputTextField.widthAnchor).isActive = true titleLabel.heightAnchor.constraint(equalToConstant: textFieldHeight).isActive = true view.bringSubviewToFront(titleLabel) } - + // MARK: - UI Components - + let titleLabel: UILabel = { let label = UILabel() label.text = DisplayStrings.titleLabel @@ -99,66 +102,86 @@ class TPPAgeCheckViewController: UIViewController { label.font = UIFont.customFont(forTextStyle: .headline) return label }() - + lazy var pickerView: UIPickerView = { let view = UIPickerView() view.dataSource = self view.delegate = self return view }() - + lazy var inputTextField: UITextField = { let textfield = UITextField() textfield.text = "" - + textfield.delegate = self // Input View // UIToolbar gives an autolayout warning on iOS 13 if initialized by UIToolbar() // Initialize the toolbar with a frame like below fixes this issue // @seealso https://stackoverflow.com/questions/54284029/uitoolbar-with-uibarbuttonitem-layoutconstraint-issue - + let toolbar = UIToolbar(frame: CGRect(x: 0, y: 0, width: UIScreen.main.bounds.size.width, height: 30)) toolbar.sizeToFit() - let doneButton = UIBarButtonItem(title:DisplayStrings.done, style: .plain, target: self, action: #selector(hidePickerView)) - let spaceButton = UIBarButtonItem(barButtonSystemItem: UIBarButtonItem.SystemItem.flexibleSpace, target: nil, action: nil) + let doneButton = UIBarButtonItem( + title: DisplayStrings.done, + style: .plain, + target: self, + action: #selector(hidePickerView) + ) + let spaceButton = UIBarButtonItem( + barButtonSystemItem: UIBarButtonItem.SystemItem.flexibleSpace, + target: nil, + action: nil + ) toolbar.setItems([spaceButton, doneButton], animated: false) textfield.inputAccessoryView = toolbar textfield.inputView = pickerView - + // Styling let placeHolderString = DisplayStrings.placeholderString if #available(iOS 13.0, *) { - textfield.attributedPlaceholder = NSAttributedString(string: placeHolderString, attributes: [NSAttributedString.Key.foregroundColor: UIColor.label]) + textfield.attributedPlaceholder = NSAttributedString( + string: placeHolderString, + attributes: [NSAttributedString.Key.foregroundColor: UIColor.label] + ) textfield.backgroundColor = .systemBackground textfield.layer.borderColor = UIColor.separator.cgColor } else { textfield.backgroundColor = .white - textfield.attributedPlaceholder = NSAttributedString(string: placeHolderString, attributes: [NSAttributedString.Key.foregroundColor: UIColor.darkText]) + textfield.attributedPlaceholder = NSAttributedString( + string: placeHolderString, + attributes: [NSAttributedString.Key.foregroundColor: UIColor.darkText] + ) textfield.layer.borderColor = UIColor.darkGray.cgColor } - + textfield.layer.borderWidth = 0.5 - + textfield.leftView = UIView(frame: CGRect(x: 0, y: 0, width: 15, height: textFieldHeight)) textfield.rightView = UIImageView(image: UIImage(named: "ArrowDown")) textfield.leftViewMode = .always textfield.rightViewMode = .always - + return textfield }() - + lazy var rightBarButtonItem: UIBarButtonItem = { - let item = UIBarButtonItem(title: DisplayStrings.rightBarButtonItem, style: .plain, target: self, action: #selector(completeAgeCheck)) + let item = UIBarButtonItem( + title: DisplayStrings.rightBarButtonItem, + style: .plain, + target: self, + action: #selector(completeAgeCheck) + ) item.tintColor = .systemBlue item.isEnabled = false return item }() } -// MARK: - UITextFieldDelegate +// MARK: UITextFieldDelegate extension TPPAgeCheckViewController: UITextFieldDelegate { // Handle user's input by physical keyboard @@ -168,25 +191,25 @@ extension TPPAgeCheckViewController: UITextFieldDelegate { } } -// MARK: - UIPickerViewDelegate/Datasource +// MARK: UIPickerViewDelegate, UIPickerViewDataSource extension TPPAgeCheckViewController: UIPickerViewDelegate, UIPickerViewDataSource { - func numberOfComponents(in pickerView: UIPickerView) -> Int { - return 1 + func numberOfComponents(in _: UIPickerView) -> Int { + 1 } - - func pickerView(_ pickerView: UIPickerView, numberOfRowsInComponent component: Int) -> Int { - return ageCheckDelegate?.birthYearList.count ?? 0 + + func pickerView(_: UIPickerView, numberOfRowsInComponent _: Int) -> Int { + ageCheckDelegate?.birthYearList.count ?? 0 } - - func pickerView(_ pickerView: UIPickerView, titleForRow row: Int, forComponent component: Int) -> String? { + + func pickerView(_: UIPickerView, titleForRow row: Int, forComponent _: Int) -> String? { guard let delegate = ageCheckDelegate else { return "" } return "\(delegate.birthYearList[row])" } - - func pickerView(_ pickerView: UIPickerView, didSelectRow row: Int, inComponent component: Int) { + + func pickerView(_: UIPickerView, didSelectRow row: Int, inComponent _: Int) { guard let delegate = ageCheckDelegate else { return } diff --git a/Palace/Accounts/Library/Account+profileDocument.swift b/Palace/Accounts/Library/Account+profileDocument.swift index a8527ba0f..14788860c 100644 --- a/Palace/Accounts/Library/Account+profileDocument.swift +++ b/Palace/Accounts/Library/Account+profileDocument.swift @@ -9,20 +9,19 @@ import Foundation extension Account { - func getProfileDocument(completion: @escaping (_ profileDocument: UserProfileDocument?) -> Void) { - guard let profileHref = self.details?.userProfileUrl, + guard let profileHref = details?.userProfileUrl, let profileUrl = URL(string: profileHref) else { // Can be a normal situation, no active user account completion(nil) return } - + var request = URLRequest(url: profileUrl) TPPNetworkExecutor.shared.executeRequest(request.applyCustomUserAgent(), enableTokenRefresh: false) { result in switch result { - case .success(let data, _): + case let .success(data, _): do { let profileDocument = try UserProfileDocument.fromData(data) DispatchQueue.main.async { @@ -32,7 +31,7 @@ extension Account { } catch { TPPErrorLogger.logError(error, summary: "Error parsing user profile document") } - case .failure(let error, _): + case let .failure(error, _): TPPErrorLogger.logError(error, summary: "Error retrieveing user profile document") } DispatchQueue.main.async { @@ -40,5 +39,4 @@ extension Account { } } } - } diff --git a/Palace/Accounts/Library/Account.swift b/Palace/Accounts/Library/Account.swift index 8ae26cbe7..19827dc5a 100644 --- a/Palace/Accounts/Library/Account.swift +++ b/Palace/Accounts/Library/Account.swift @@ -1,5 +1,7 @@ -private let userAboveAgeKey = "TPPSettingsUserAboveAgeKey" -private let accountSyncEnabledKey = "TPPAccountSyncEnabledKey" +private let userAboveAgeKey = "TPPSettingsUserAboveAgeKey" +private let accountSyncEnabledKey = "TPPAccountSyncEnabledKey" + +// MARK: - OPDS2SamlIDP /// This class is used for mapping details of SAML Identity Provider received in authentication document @objcMembers @@ -14,27 +16,34 @@ class OPDS2SamlIDP: NSObject, Codable { var idpDescription: String? { descriptions?["en"] } init?(opdsLink: OPDS2Link) { - guard let url = URL(string: opdsLink.href) else { return nil } + guard let url = URL(string: opdsLink.href) else { + return nil + } self.url = url - self.displayNames = opdsLink.displayNames?.reduce(into: [String: String]()) { $0[$1.language] = $1.value } - self.descriptions = opdsLink.descriptions?.reduce(into: [String: String]()) { $0[$1.language] = $1.value } + displayNames = opdsLink.displayNames?.reduce(into: [String: String]()) { $0[$1.language] = $1.value } + descriptions = opdsLink.descriptions?.reduce(into: [String: String]()) { $0[$1.language] = $1.value } } } +// MARK: - TPPSignedInStateProvider + @objc protocol TPPSignedInStateProvider { func isSignedIn() -> Bool } +// MARK: - AccountLogoDelegate + protocol AccountLogoDelegate: AnyObject { func logoDidUpdate(in account: Account, to newLogo: UIImage) } -// MARK: AccountDetails +// MARK: - AccountDetails + // Extra data that gets loaded from an OPDS2AuthenticationDocument, @objcMembers final class AccountDetails: NSObject { enum AuthType: String, Codable { case basic = "http://opds-spec.org/auth/basic" - case coppa = "http://librarysimplified.org/terms/authentication/gate/coppa" //used for Simplified collection + case coppa = "http://librarysimplified.org/terms/authentication/gate/coppa" // used for Simplified collection case anonymous = "http://librarysimplified.org/rel/auth/anonymous" case oauthIntermediary = "http://librarysimplified.org/authtype/OAuth-with-intermediary" case saml = "http://librarysimplified.org/authtype/SAML-2.0" @@ -45,17 +54,17 @@ protocol AccountLogoDelegate: AnyObject { @objc(AccountDetailsAuthentication) @objcMembers class Authentication: NSObject, Codable, NSCoding { - let authType:AuthType - let authPasscodeLength:UInt - let patronIDKeyboard:LoginKeyboard - let pinKeyboard:LoginKeyboard - let patronIDLabel:String? - let pinLabel:String? - let supportsBarcodeScanner:Bool - let supportsBarcodeDisplay:Bool - let coppaUnderUrl:URL? - let coppaOverUrl:URL? - let oauthIntermediaryUrl:URL? + let authType: AuthType + let authPasscodeLength: UInt + let patronIDKeyboard: LoginKeyboard + let pinKeyboard: LoginKeyboard + let patronIDLabel: String? + let pinLabel: String? + let supportsBarcodeScanner: Bool + let supportsBarcodeDisplay: Bool + let coppaUnderUrl: URL? + let coppaOverUrl: URL? + let oauthIntermediaryUrl: URL? let tokenURL: URL? let methodDescription: String? @@ -65,8 +74,8 @@ protocol AccountLogoDelegate: AnyObject { let authType = AuthType(rawValue: auth.type) ?? .none self.authType = authType authPasscodeLength = auth.inputs?.password.maximumLength ?? 99 - patronIDKeyboard = LoginKeyboard.init(auth.inputs?.login.keyboard) ?? .standard - pinKeyboard = LoginKeyboard.init(auth.inputs?.password.keyboard) ?? .standard + patronIDKeyboard = LoginKeyboard(auth.inputs?.login.keyboard) ?? .standard + pinKeyboard = LoginKeyboard(auth.inputs?.password.keyboard) ?? .standard patronIDLabel = auth.labels?.login pinLabel = auth.labels?.password methodDescription = auth.description @@ -75,14 +84,20 @@ protocol AccountLogoDelegate: AnyObject { switch authType { case .coppa: - coppaUnderUrl = URL.init(string: auth.links?.first(where: { $0.rel == "http://librarysimplified.org/terms/rel/authentication/restriction-not-met" })?.href ?? "") - coppaOverUrl = URL.init(string: auth.links?.first(where: { $0.rel == "http://librarysimplified.org/terms/rel/authentication/restriction-met" })?.href ?? "") + coppaUnderUrl = URL(string: auth.links? + .first(where: { $0.rel == "http://librarysimplified.org/terms/rel/authentication/restriction-not-met" })? + .href ?? "" + ) + coppaOverUrl = URL(string: auth.links? + .first(where: { $0.rel == "http://librarysimplified.org/terms/rel/authentication/restriction-met" })? + .href ?? "" + ) oauthIntermediaryUrl = nil samlIdps = nil tokenURL = nil case .oauthIntermediary: - oauthIntermediaryUrl = URL.init(string: auth.links?.first(where: { $0.rel == "authenticate" })?.href ?? "") + oauthIntermediaryUrl = URL(string: auth.links?.first(where: { $0.rel == "authenticate" })?.href ?? "") coppaUnderUrl = nil coppaOverUrl = nil samlIdps = nil @@ -102,12 +117,11 @@ protocol AccountLogoDelegate: AnyObject { samlIdps = nil tokenURL = nil case .token: - tokenURL = URL.init(string: auth.links?.first(where: { $0.rel == "authenticate" })?.href ?? "") + tokenURL = URL(string: auth.links?.first(where: { $0.rel == "authenticate" })?.href ?? "") oauthIntermediaryUrl = nil coppaUnderUrl = nil coppaOverUrl = nil samlIdps = nil - } } @@ -146,14 +160,20 @@ protocol AccountLogoDelegate: AnyObject { func encode(with coder: NSCoder) { let jsonEncoder = JSONEncoder() - guard let data = try? jsonEncoder.encode(self) else { return } + guard let data = try? jsonEncoder.encode(self) else { + return + } coder.encode(data as NSData) } required init?(coder: NSCoder) { - guard let data = coder.decodeData() else { return nil } + guard let data = coder.decodeData() else { + return nil + } let jsonDecoder = JSONDecoder() - guard let authentication = try? jsonDecoder.decode(Authentication.self, from: data) else { return nil } + guard let authentication = try? jsonDecoder.decode(Authentication.self, from: data) else { + return nil + } authType = authentication.authType authPasscodeLength = authentication.authPasscodeLength @@ -172,54 +192,59 @@ protocol AccountLogoDelegate: AnyObject { } } - let defaults:UserDefaults - let uuid:String - let supportsSimplyESync:Bool - let supportsCardCreator:Bool - let supportsReservations:Bool + let defaults: UserDefaults + let uuid: String + let supportsSimplyESync: Bool + let supportsCardCreator: Bool + let supportsReservations: Bool let auths: [Authentication] - let mainColor:String? - let userProfileUrl:String? - let signUpUrl:URL? - let loansUrl:URL? + let mainColor: String? + let userProfileUrl: String? + let signUpUrl: URL? + let loansUrl: URL? var defaultAuth: Authentication? { - guard auths.count > 1 else { return auths.first } + guard auths.count > 1 else { + return auths.first + } return auths.first(where: { !$0.catalogRequiresAuthentication }) ?? auths.first } + var needsAgeCheck: Bool { // this will tell if any authentication method requires age check - return auths.contains(where: { $0.needsAgeCheck }) + auths.contains(where: \.needsAgeCheck) } - fileprivate var urlAnnotations:URL? - fileprivate var urlAcknowledgements:URL? - fileprivate var urlContentLicenses:URL? - fileprivate var urlEULA:URL? - fileprivate var urlPrivacyPolicy:URL? + fileprivate var urlAnnotations: URL? + fileprivate var urlAcknowledgements: URL? + fileprivate var urlContentLicenses: URL? + fileprivate var urlEULA: URL? + fileprivate var urlPrivacyPolicy: URL? - var eulaIsAccepted:Bool { + var eulaIsAccepted: Bool { get { - return getAccountDictionaryKey(TPPSettings.userHasAcceptedEULAKey) as? Bool ?? false - + getAccountDictionaryKey(TPPSettings.userHasAcceptedEULAKey) as? Bool ?? false } set { - setAccountDictionaryKey(TPPSettings.userHasAcceptedEULAKey, - toValue: newValue as AnyObject) + setAccountDictionaryKey( + TPPSettings.userHasAcceptedEULAKey, + toValue: newValue as AnyObject + ) } } - var syncPermissionGranted:Bool { + + var syncPermissionGranted: Bool { get { - return getAccountDictionaryKey(accountSyncEnabledKey) as? Bool ?? true + getAccountDictionaryKey(accountSyncEnabledKey) as? Bool ?? true } set { setAccountDictionaryKey(accountSyncEnabledKey, toValue: newValue as AnyObject) } } - var userAboveAgeLimit:Bool { - get { - return getAccountDictionaryKey(userAboveAgeKey) as? Bool ?? false + var userAboveAgeLimit: Bool { + get { + getAccountDictionaryKey(userAboveAgeKey) as? Bool ?? false } set { setAccountDictionaryKey(userAboveAgeKey, toValue: newValue as AnyObject) @@ -230,9 +255,9 @@ protocol AccountLogoDelegate: AnyObject { defaults = .standard self.uuid = uuid - auths = authenticationDocument.authentication?.map({ (opdsAuth) -> Authentication in - return Authentication.init(auth: opdsAuth) - }) ?? [] + auths = authenticationDocument.authentication?.map { opdsAuth -> Authentication in + return Authentication(auth: opdsAuth) + } ?? [] // // TODO: Code below will remove all oauth only auth methods, this behaviour wasn't tested though // // and may produce undefined results in viewcontrollers that do present auth methods if none are available @@ -240,9 +265,13 @@ protocol AccountLogoDelegate: AnyObject { // return Authentication.init(auth: opdsAuth) // }).filter { $0.authType != .oauthIntermediary } ?? [] - supportsReservations = authenticationDocument.features?.disabled?.contains("https://librarysimplified.org/rel/policy/reservations") != true - userProfileUrl = authenticationDocument.links?.first(where: { $0.rel == "http://librarysimplified.org/terms/rel/user-profile" })?.href - loansUrl = URL.init(string: authenticationDocument.links?.first(where: { $0.rel == "http://opds-spec.org/shelf" })?.href ?? "") + supportsReservations = authenticationDocument.features?.disabled? + .contains("https://librarysimplified.org/rel/policy/reservations") != true + userProfileUrl = authenticationDocument.links? + .first(where: { $0.rel == "http://librarysimplified.org/terms/rel/user-profile" })?.href + loansUrl = URL(string: authenticationDocument.links?.first(where: { $0.rel == "http://opds-spec.org/shelf" })? + .href ?? "" + ) supportsSimplyESync = userProfileUrl != nil mainColor = authenticationDocument.colorScheme @@ -268,27 +297,31 @@ protocol AccountLogoDelegate: AnyObject { super.init() if let urlString = authenticationDocument.links?.first(where: { $0.rel == "privacy-policy" })?.href, - let url = URL(string: urlString) { + let url = URL(string: urlString) + { setURL(url, forLicense: .privacyPolicy) } if let urlString = authenticationDocument.links?.first(where: { $0.rel == "terms-of-service" })?.href, - let url = URL(string: urlString) { + let url = URL(string: urlString) + { setURL(url, forLicense: .eula) } if let urlString = authenticationDocument.links?.first(where: { $0.rel == "license" })?.href, - let url = URL(string: urlString) { + let url = URL(string: urlString) + { setURL(url, forLicense: .contentLicenses) } if let urlString = authenticationDocument.links?.first(where: { $0.rel == "copyright" })?.href, - let url = URL(string: urlString) { + let url = URL(string: urlString) + { setURL(url, forLicense: .acknowledgements) } } - func setURL(_ URL: URL, forLicense urlType: URLType) -> Void { + func setURL(_ URL: URL, forLicense urlType: URLType) { switch urlType { case .acknowledgements: urlAcknowledgements = URL @@ -314,81 +347,103 @@ protocol AccountLogoDelegate: AnyObject { if let url = urlAcknowledgements { return url } else { - guard let urlString = getAccountDictionaryKey("urlAcknowledgements") as? String else { return nil } - guard let result = URL(string: urlString) else { return nil } + guard let urlString = getAccountDictionaryKey("urlAcknowledgements") as? String else { + return nil + } + guard let result = URL(string: urlString) else { + return nil + } return result } case .contentLicenses: if let url = urlContentLicenses { return url } else { - guard let urlString = getAccountDictionaryKey("urlContentLicenses") as? String else { return nil } - guard let result = URL(string: urlString) else { return nil } + guard let urlString = getAccountDictionaryKey("urlContentLicenses") as? String else { + return nil + } + guard let result = URL(string: urlString) else { + return nil + } return result } case .eula: if let url = urlEULA { return url } else { - guard let urlString = getAccountDictionaryKey("urlEULA") as? String else { return nil } - guard let result = URL(string: urlString) else { return nil } + guard let urlString = getAccountDictionaryKey("urlEULA") as? String else { + return nil + } + guard let result = URL(string: urlString) else { + return nil + } return result } case .privacyPolicy: if let url = urlPrivacyPolicy { return url } else { - guard let urlString = getAccountDictionaryKey("urlPrivacyPolicy") as? String else { return nil } - guard let result = URL(string: urlString) else { return nil } + guard let urlString = getAccountDictionaryKey("urlPrivacyPolicy") as? String else { + return nil + } + guard let result = URL(string: urlString) else { + return nil + } return result } case .annotations: if let url = urlAnnotations { return url } else { - guard let urlString = getAccountDictionaryKey("urlAnnotations") as? String else { return nil } - guard let result = URL(string: urlString) else { return nil } + guard let urlString = getAccountDictionaryKey("urlAnnotations") as? String else { + return nil + } + guard let result = URL(string: urlString) else { + return nil + } return result } } } fileprivate func setAccountDictionaryKey(_ key: String, toValue value: AnyObject) { - if var savedDict = defaults.value(forKey: self.uuid) as? [String: AnyObject] { + if var savedDict = defaults.value(forKey: uuid) as? [String: AnyObject] { savedDict[key] = value - defaults.set(savedDict, forKey: self.uuid) + defaults.set(savedDict, forKey: uuid) } else { - defaults.set([key:value], forKey: self.uuid) + defaults.set([key: value], forKey: uuid) } } fileprivate func getAccountDictionaryKey(_ key: String) -> AnyObject? { - let savedDict = defaults.value(forKey: self.uuid) as? [String: AnyObject] - guard let result = savedDict?[key] else { return nil } + let savedDict = defaults.value(forKey: uuid) as? [String: AnyObject] + guard let result = savedDict?[key] else { + return nil + } return result } } -// MARK: Account +// MARK: - Account + /// Object representing one library account in the app. Patrons may /// choose to sign up for multiple Accounts. -@objcMembers final class Account: NSObject -{ - var logo:UIImage - let uuid:String - let name:String - let subtitle:String? - var supportEmail: EmailAddress? = nil - var supportURL:URL? = nil - let catalogUrl:String? +@objcMembers final class Account: NSObject { + var logo: UIImage + let uuid: String + let name: String + let subtitle: String? + var supportEmail: EmailAddress? + var supportURL: URL? + let catalogUrl: String? var details: AccountDetails? var homePageUrl: String? lazy var hasSupportOption = { supportEmail != nil || supportURL != nil }() weak var logoDelegate: AccountLogoDelegate? var hasUpdatedToken: Bool = false - let authenticationDocumentUrl:String? - var authenticationDocument:OPDS2AuthenticationDocument? { + let authenticationDocumentUrl: String? + var authenticationDocument: OPDS2AuthenticationDocument? { didSet { guard let authenticationDocument = authenticationDocument else { return @@ -396,12 +451,13 @@ protocol AccountLogoDelegate: AnyObject { details = AccountDetails(authenticationDocument: authenticationDocument, uuid: uuid) } } - var logoUrl: URL? = nil + + var logoUrl: URL? let imageCache: ImageCacheType var loansUrl: URL? { - return details?.loansUrl + details?.loansUrl } init(publication: OPDS2Publication, imageCache: ImageCacheType) { @@ -416,7 +472,8 @@ protocol AccountLogoDelegate: AnyObject { supportURL = URL(string: link) } } - authenticationDocumentUrl = publication.links.first(where: { $0.type == "application/vnd.opds.authentication.v1.0+json" })?.href + authenticationDocumentUrl = publication.links + .first(where: { $0.type == "application/vnd.opds.authentication.v1.0+json" })?.href logo = UIImage(named: "LibraryLogoMagic")! homePageUrl = publication.links.first(where: { $0.rel == "alternate" })?.href logoUrl = publication.thumbnailURL @@ -431,20 +488,22 @@ protocol AccountLogoDelegate: AnyObject { /// No guarantees are being made about whether this is called on the main /// thread or not. This closure is not retained by `self`. @objc(loadAuthenticationDocumentUsingSignedInStateProvider:completion:) - func loadAuthenticationDocument(using signedInStateProvider: TPPSignedInStateProvider? = nil, completion: @escaping (Bool) -> ()) { + func loadAuthenticationDocument(using _: TPPSignedInStateProvider? = nil, completion: @escaping (Bool) -> Void) { Log.debug(#function, "Entering...") guard let urlString = authenticationDocumentUrl else { TPPErrorLogger.logError( withCode: .noURL, summary: "Failed to load authentication document because its URL is invalid", - metadata: ["self.uuid": uuid, - "urlString": authenticationDocumentUrl ?? "N/A"] + metadata: [ + "self.uuid": uuid, + "urlString": authenticationDocumentUrl ?? "N/A" + ] ) completion(false) return } - fetchAuthenticationDocument(urlString) { (document) in + fetchAuthenticationDocument(urlString) { document in guard let authenticationDocument = document else { completion(false) return @@ -464,15 +523,20 @@ protocol AccountLogoDelegate: AnyObject { } } - private func fetchAuthenticationDocument(_ urlString: String, completion: @escaping (OPDS2AuthenticationDocument?) -> Void) { + private func fetchAuthenticationDocument( + _ urlString: String, + completion: @escaping (OPDS2AuthenticationDocument?) -> Void + ) { var document: OPDS2AuthenticationDocument? guard let url = URL(string: urlString) else { TPPErrorLogger.logError( withCode: .noURL, summary: "Failed to load authentication document because its URL is invalid", - metadata: ["self.uuid": uuid, - "urlString": urlString] + metadata: [ + "self.uuid": uuid, + "urlString": urlString + ] ) completion(document) return @@ -480,11 +544,11 @@ protocol AccountLogoDelegate: AnyObject { TPPNetworkExecutor.shared.GET(url, useTokenIfAvailable: false) { result in switch result { - case .success(let serverData, _): + case let .success(serverData, _): do { document = try OPDS2AuthenticationDocument.fromData(serverData) completion(document) - } catch (let error) { + } catch { let responseBody = String(data: serverData, encoding: .utf8) TPPErrorLogger.logError( withCode: .authDocParseFail, @@ -497,7 +561,7 @@ protocol AccountLogoDelegate: AnyObject { ) completion(document) } - case .failure(let error, _): + case let .failure(error, _): TPPErrorLogger.logError( withCode: .authDocLoadFail, summary: "Authentication Document request failed to load", @@ -509,31 +573,35 @@ protocol AccountLogoDelegate: AnyObject { } func loadLogo() { - guard let url = self.logoUrl else { return } + guard let url = logoUrl else { + return + } - self.fetchImage(from: url, completion: { - guard let image = $0 else { return } + fetchImage(from: url, completion: { + guard let image = $0 else { + return + } self.logo = image self.logoDelegate?.logoDidUpdate(in: self, to: image) }) } - private func fetchImage(from url: URL, completion: @escaping (UIImage?) -> ()) { - if let cachedImage = imageCache.get(for: self.uuid) { + private func fetchImage(from url: URL, completion: @escaping (UIImage?) -> Void) { + if let cachedImage = imageCache.get(for: uuid) { completion(cachedImage) return } TPPNetworkExecutor.shared.GET(url, useTokenIfAvailable: false) { result in DispatchQueue.main.async { switch result { - case .success(let serverData, _): + case let .success(serverData, _): guard let image = UIImage(data: serverData) else { completion(nil) return } self.imageCache.set(image, for: self.uuid) completion(image) - case .failure(let error, _): + case let .failure(error, _): TPPErrorLogger.logError( withCode: .authDocLoadFail, summary: "Logo image failed to load", @@ -548,7 +616,7 @@ protocol AccountLogoDelegate: AnyObject { extension AccountDetails { override var debugDescription: String { - return """ + """ supportsSimplyESync=\(supportsSimplyESync) supportsCardCreator=\(supportsCardCreator) supportsReservations=\(supportsReservations) @@ -558,7 +626,7 @@ extension AccountDetails { extension Account { override var debugDescription: String { - return """ + """ name=\(name) uuid=\(uuid) catalogURL=\(String(describing: catalogUrl)) @@ -568,7 +636,8 @@ extension Account { } } -// MARK: URLType +// MARK: - URLType + @objc enum URLType: Int { case acknowledgements case contentLicenses @@ -577,7 +646,8 @@ extension Account { case annotations } -// MARK: LoginKeyboard +// MARK: - LoginKeyboard + @objc enum LoginKeyboard: Int, Codable { case standard case email diff --git a/Palace/Accounts/Library/AccountsManager.swift b/Palace/Accounts/Library/AccountsManager.swift index 58d08ca1b..c2c375a22 100644 --- a/Palace/Accounts/Library/AccountsManager.swift +++ b/Palace/Accounts/Library/AccountsManager.swift @@ -2,19 +2,24 @@ import Foundation let currentAccountIdentifierKey = "TPPCurrentAccountIdentifier" +// MARK: - TPPCurrentLibraryAccountProvider + @objc protocol TPPCurrentLibraryAccountProvider: NSObjectProtocol { var currentAccount: Account? { get } } +// MARK: - TPPLibraryAccountsProvider + @objc protocol TPPLibraryAccountsProvider: TPPCurrentLibraryAccountProvider { var tppAccountUUID: String { get } var currentAccountId: String? { get } func account(_ uuid: String) -> Account? } +// MARK: - AccountsManager + /// Manages library accounts asynchronously with authentication & image loading @objcMembers final class AccountsManager: NSObject, TPPLibraryAccountsProvider { - static let shared = AccountsManager() class func sharedInstance() -> AccountsManager { shared } @@ -23,7 +28,7 @@ let currentAccountIdentifierKey = "TPPCurrentAccountIdentifier" static let TPPAccountUUIDs = [ "urn:uuid:065c0c11-0d0f-42a3-82e4-277b18786949", // NYPL proper "urn:uuid:edef2358-9f6a-4ce6-b64f-9b351ec68ac4", // Brooklyn - "urn:uuid:56906f26-2c9a-4ae9-bd02-552557720b99" // Simplified Instant Classics + "urn:uuid:56906f26-2c9a-4ae9-bd02-552557720b99" // Simplified Instant Classics ] static let TPPNationalAccountUUIDs = [ @@ -41,12 +46,13 @@ let currentAccountIdentifierKey = "TPPCurrentAccountIdentifier" private var loadingCompletionHandlers = [String: [(Bool) -> Void]]() private let loadingHandlersQueue = DispatchQueue(label: "com.tpp.loadingHandlers", attributes: .concurrent) - private override init() { - self.accountSet = TPPConfiguration.customUrlHash() - ?? (TPPSettings.shared.useBetaLibraries + override private init() { + accountSet = TPPConfiguration.customUrlHash() + ?? (TPPSettings.shared.useBetaLibraries ? TPPConfiguration.betaUrlHash - : TPPConfiguration.prodUrlHash) - self.ageCheck = TPPAgeCheck(ageCheckChoiceStorage: TPPSettings.shared) + : TPPConfiguration.prodUrlHash + ) + ageCheck = TPPAgeCheck(ageCheckChoiceStorage: TPPSettings.shared) super.init() NotificationCenter.default.addObserver( self, @@ -63,7 +69,7 @@ let currentAccountIdentifierKey = "TPPCurrentAccountIdentifier" // MARK: – Thread‐safe accountSets access private func performRead(_ block: () -> T) -> T { - return accountSetsLock.sync { + accountSetsLock.sync { block() } } @@ -75,9 +81,12 @@ let currentAccountIdentifierKey = "TPPCurrentAccountIdentifier" } // MARK: - Account Retrieval + var currentAccount: Account? { get { - guard let uuid = currentAccountId else { return nil } + guard let uuid = currentAccountId else { + return nil + } return account(uuid) } set { @@ -98,7 +107,7 @@ let currentAccountIdentifierKey = "TPPCurrentAccountIdentifier" } func account(_ uuid: String) -> Account? { - return performRead { + performRead { accountSets.values .first { $0.contains(where: { $0.uuid == uuid }) }? .first(where: { $0.uuid == uuid }) @@ -106,14 +115,14 @@ let currentAccountIdentifierKey = "TPPCurrentAccountIdentifier" } func accounts(_ key: String? = nil) -> [Account] { - return performRead { + performRead { let k = key ?? self.accountSet return self.accountSets[k] ?? [] } } var accountsHaveLoaded: Bool { - return performRead { + performRead { !(self.accountSets[self.accountSet]?.isEmpty ?? true) } } @@ -146,7 +155,7 @@ let currentAccountIdentifierKey = "TPPCurrentAccountIdentifier" /// Calls & clears all handlers for the given hash private func callAndClearLoadingHandlers(for hash: String, _ success: Bool) { - var handlers: [(Bool)->Void] = [] + var handlers: [(Bool) -> Void] = [] loadingHandlersQueue.sync { handlers = loadingCompletionHandlers[hash] ?? [] } @@ -157,11 +166,12 @@ let currentAccountIdentifierKey = "TPPCurrentAccountIdentifier" } /// Public entrypoint - func loadCatalogs(completion: ((Bool) -> ())?) { + func loadCatalogs(completion: ((Bool) -> Void)?) { let targetUrl = TPPConfiguration.customUrl() - ?? (TPPSettings.shared.useBetaLibraries + ?? (TPPSettings.shared.useBetaLibraries ? TPPConfiguration.betaUrl - : TPPConfiguration.prodUrl) + : TPPConfiguration.prodUrl + ) let hash = targetUrl.absoluteString .md5() .base64EncodedStringUrlSafe() @@ -174,29 +184,33 @@ let currentAccountIdentifierKey = "TPPCurrentAccountIdentifier" } // dedupe concurrent loads - if addLoadingHandler(for: hash, completion) { return } + if addLoadingHandler(for: hash, completion) { + return + } Log.debug(#file, "Loading catalogs for hash \(hash)…") TPPNetworkExecutor(cachingStrategy: .fallback).GET(targetUrl, useTokenIfAvailable: false) { [weak self] result in - guard let self = self else { return } + guard let self = self else { + return + } switch result { - case .success(let data, _): - self.cacheAccountsCatalogData(data, hash: hash) - self.loadAccountSetsAndAuthDoc(fromCatalogData: data, key: hash) { success in + case let .success(data, _): + cacheAccountsCatalogData(data, hash: hash) + loadAccountSetsAndAuthDoc(fromCatalogData: data, key: hash) { success in NotificationCenter.default.post(name: .TPPCatalogDidLoad, object: nil) self.callAndClearLoadingHandlers(for: hash, success) } case .failure: // fallback to disk - if let data = self.readCachedAccountsCatalogData(hash: hash) { - self.loadAccountSetsAndAuthDoc(fromCatalogData: data, key: hash) { success in + if let data = readCachedAccountsCatalogData(hash: hash) { + loadAccountSetsAndAuthDoc(fromCatalogData: data, key: hash) { success in NotificationCenter.default.post(name: .TPPCatalogDidLoad, object: nil) self.callAndClearLoadingHandlers(for: hash, success) } } else { // truly failed - self.callAndClearLoadingHandlers(for: hash, false) + callAndClearLoadingHandlers(for: hash, false) } } } @@ -209,18 +223,25 @@ let currentAccountIdentifierKey = "TPPCurrentAccountIdentifier" for: .applicationSupportDirectory, in: .userDomainMask, appropriateFor: nil, - create: true) - else { return nil } + create: true + ) + else { + return nil + } return appSupport.appendingPathComponent("accounts_catalog_\(hash).json") } private func cacheAccountsCatalogData(_ data: Data, hash: String) { - guard let url = accountsCatalogUrl(hash: hash) else { return } + guard let url = accountsCatalogUrl(hash: hash) else { + return + } try? data.write(to: url) } private func readCachedAccountsCatalogData(hash: String) -> Data? { - guard let url = accountsCatalogUrl(hash: hash) else { return nil } + guard let url = accountsCatalogUrl(hash: hash) else { + return nil + } return try? Data(contentsOf: url) } @@ -233,16 +254,16 @@ let currentAccountIdentifierKey = "TPPCurrentAccountIdentifier" ) { do { let feed = try OPDS2CatalogsFeed.fromData(data) - let hadAccount = self.currentAccount != nil + let hadAccount = currentAccount != nil let newAccounts = feed.catalogs.map { Account(publication: $0, imageCache: ImageCache.shared) } - self.performWrite { + performWrite { self.accountSets[hash] = newAccounts } let group = DispatchGroup() - if hadAccount != (self.currentAccount != nil), let current = self.currentAccount { + if hadAccount != (currentAccount != nil), let current = currentAccount { group.enter() current.loadLogo() current.loadAuthenticationDocument(using: TPPUserAccount.sharedAccount()) { _ in @@ -282,15 +303,16 @@ let currentAccountIdentifierKey = "TPPCurrentAccountIdentifier" } } - @objc private func updateAccountSetFromNotification(_ notif: Notification) { + @objc private func updateAccountSetFromNotification(_: Notification) { updateAccountSet(completion: nil) } func updateAccountSet(completion: ((Bool) -> Void)?) { let newHash = TPPConfiguration.customUrlHash() - ?? (TPPSettings.shared.useBetaLibraries + ?? (TPPSettings.shared.useBetaLibraries ? TPPConfiguration.betaUrlHash - : TPPConfiguration.prodUrlHash) + : TPPConfiguration.prodUrlHash + ) performWrite { self.accountSet = newHash } if performRead({ self.accountSets[newHash]?.isEmpty ?? true }) || TPPConfiguration.customUrlHash() != nil { @@ -307,7 +329,12 @@ let currentAccountIdentifierKey = "TPPCurrentAccountIdentifier" // file caches let keys = ["library_list_", "accounts_catalog_", "authentication_document_"] let fm = FileManager.default - if let appSupport = try? fm.url(for: .applicationSupportDirectory, in: .userDomainMask, appropriateFor: nil, create: false) { + if let appSupport = try? fm.url( + for: .applicationSupportDirectory, + in: .userDomainMask, + appropriateFor: nil, + create: false + ) { for key in keys { let url = appSupport.appendingPathComponent("\(key).json") try? fm.removeItem(at: url) diff --git a/Palace/Accounts/User/TPPCredentials.swift b/Palace/Accounts/User/TPPCredentials.swift index 7711f3969..9c9ddb0f3 100644 --- a/Palace/Accounts/User/TPPCredentials.swift +++ b/Palace/Accounts/User/TPPCredentials.swift @@ -9,12 +9,16 @@ import Foundation import WebKit +// MARK: - TPPCredentials + enum TPPCredentials { - case token(authToken: String, barcode: String? = nil, pin: String? = nil, expirationDate: Date? = nil) + case token(authToken: String, barcode: String? = nil, pin: String? = nil, expirationDate: Date? = nil) case barcodeAndPin(barcode: String, pin: String) case cookies([HTTPCookie]) } +// MARK: Codable + extension TPPCredentials: Codable { // warning, order is important for proper decoding! enum TypeID: Int, Codable { @@ -25,9 +29,9 @@ extension TPPCredentials: Codable { private var typeID: TypeID { switch self { - case .token: return .token - case .barcodeAndPin: return .barcodeAndPin - case .cookies: return .cookies + case .token: .token + case .barcodeAndPin: .barcodeAndPin + case .cookies: .cookies } } @@ -62,13 +66,19 @@ extension TPPCredentials: Codable { let token = try additionalInfo.decode(String.self, forKey: .authToken) let expirationDate = try additionalInfo.decode(Date.self, forKey: .expirationDate) - let barcodePinInfo = try values.nestedContainer(keyedBy: BarcodeAndPinKeys.self, forKey: .associatedBarcodeAndPinData) + let barcodePinInfo = try values.nestedContainer( + keyedBy: BarcodeAndPinKeys.self, + forKey: .associatedBarcodeAndPinData + ) let barcode = try barcodePinInfo.decode(String.self, forKey: .barcode) let pin = try barcodePinInfo.decode(String.self, forKey: .pin) self = .token(authToken: token, barcode: barcode, pin: pin, expirationDate: expirationDate) case .barcodeAndPin: - let additionalInfo = try values.nestedContainer(keyedBy: BarcodeAndPinKeys.self, forKey: .associatedBarcodeAndPinData) + let additionalInfo = try values.nestedContainer( + keyedBy: BarcodeAndPinKeys.self, + forKey: .associatedBarcodeAndPinData + ) let barcode = try additionalInfo.decode(String.self, forKey: .barcode) let pin = try additionalInfo.decode(String.self, forKey: .pin) self = .barcodeAndPin(barcode: barcode, pin: pin) @@ -76,7 +86,9 @@ extension TPPCredentials: Codable { case .cookies: let additionalInfo = try values.nestedContainer(keyedBy: CookiesKeys.self, forKey: .associatedCookiesData) let cookiesData = try additionalInfo.decode(Data.self, forKey: .cookiesData) - guard let properties = try JSONSerialization.jsonObject(with: cookiesData, options: .allowFragments) as? [[HTTPCookiePropertyKey : Any]] else { + guard let properties = try JSONSerialization + .jsonObject(with: cookiesData, options: .allowFragments) as? [[HTTPCookiePropertyKey: Any]] + else { throw NSError() } let cookies = properties.compactMap { HTTPCookie(properties: $0) } @@ -93,19 +105,25 @@ extension TPPCredentials: Codable { var additionalInfo = container.nestedContainer(keyedBy: TokenKeys.self, forKey: .associatedTokenData) try additionalInfo.encode(token, forKey: .authToken) try additionalInfo.encode(date, forKey: .expirationDate) - - var barCodePinInfo = container.nestedContainer(keyedBy: BarcodeAndPinKeys.self, forKey: .associatedBarcodeAndPinData) + + var barCodePinInfo = container.nestedContainer( + keyedBy: BarcodeAndPinKeys.self, + forKey: .associatedBarcodeAndPinData + ) try barCodePinInfo.encode(barcode, forKey: .barcode) try barCodePinInfo.encode(pin, forKey: .pin) case let .barcodeAndPin(barcode: barcode, pin: pin): - var additionalInfo = container.nestedContainer(keyedBy: BarcodeAndPinKeys.self, forKey: .associatedBarcodeAndPinData) + var additionalInfo = container.nestedContainer( + keyedBy: BarcodeAndPinKeys.self, + forKey: .associatedBarcodeAndPinData + ) try additionalInfo.encode(barcode, forKey: .barcode) try additionalInfo.encode(pin, forKey: .pin) case let .cookies(cookies): var additionalInfo = container.nestedContainer(keyedBy: CookiesKeys.self, forKey: .associatedCookiesData) - let properties: [[HTTPCookiePropertyKey : Any]] = cookies.compactMap { $0.properties } + let properties: [[HTTPCookiePropertyKey: Any]] = cookies.compactMap(\.properties) let data = try JSONSerialization.data(withJSONObject: properties, options: []) try additionalInfo.encode(data, forKey: .cookiesData) } @@ -114,10 +132,12 @@ extension TPPCredentials: Codable { extension String { func asKeychainVariable(with accountInfoQueue: DispatchQueue) -> TPPKeychainVariable { - return TPPKeychainVariable(key: self, accountInfoQueue: accountInfoQueue) + TPPKeychainVariable(key: self, accountInfoQueue: accountInfoQueue) } - func asKeychainCodableVariable(with accountInfoQueue: DispatchQueue) -> TPPKeychainCodableVariable { - return TPPKeychainCodableVariable(key: self, accountInfoQueue: accountInfoQueue) + func asKeychainCodableVariable(with accountInfoQueue: DispatchQueue) + -> TPPKeychainCodableVariable + { + TPPKeychainCodableVariable(key: self, accountInfoQueue: accountInfoQueue) } } diff --git a/Palace/Accounts/User/TPPUserAccount.swift b/Palace/Accounts/User/TPPUserAccount.swift index 257645cb5..d83a1c081 100644 --- a/Palace/Accounts/User/TPPUserAccount.swift +++ b/Palace/Accounts/User/TPPUserAccount.swift @@ -1,5 +1,7 @@ import Foundation +// MARK: - StorageKey + private enum StorageKey: String { // .barcode, .PIN, .authToken became legacy, as storage for those types was moved into .credentials enum @@ -23,29 +25,36 @@ private enum StorageKey: String { // historically user data for NYPL has not used keys that contain the // library UUID. let libraryUUID = libraryUUID, - libraryUUID != AccountsManager.shared.tppAccountUUID else { - return self.rawValue + libraryUUID != AccountsManager.shared.tppAccountUUID + else { + return rawValue } - return "\(self.rawValue)_\(libraryUUID)" + return "\(rawValue)_\(libraryUUID)" } } +// MARK: - TPPUserAccountProvider + @objc protocol TPPUserAccountProvider: NSObjectProtocol { - var needsAuth:Bool { get } - + var needsAuth: Bool { get } + static func sharedAccount(libraryUUID: String?) -> TPPUserAccount } -@objcMembers class TPPUserAccount : NSObject, TPPUserAccountProvider { - static private let shared = TPPUserAccount() +// MARK: - TPPUserAccount + +@objcMembers class TPPUserAccount: NSObject, TPPUserAccountProvider { + private static let shared = TPPUserAccount() private let accountInfoQueue = DispatchQueue(label: "TPPUserAccount.accountInfoQueue") private lazy var keychainTransaction = TPPKeychainVariableTransaction(accountInfoQueue: accountInfoQueue) private var notifyAccountChange: Bool = true var libraryUUID: String? { didSet { - guard libraryUUID != oldValue else { return } + guard libraryUUID != oldValue else { + return + } let variables: [StorageKey: Keyable] = [ StorageKey.authorizationIdentifier: _authorizationIdentifier, StorageKey.adobeToken: _adobeToken, @@ -62,7 +71,7 @@ private enum StorageKey: String { // legacy StorageKey.barcode: _barcode, StorageKey.PIN: _pin, - StorageKey.authToken: _authToken, + StorageKey.authToken: _authToken ] for (key, var value) in variables { @@ -74,16 +83,18 @@ private enum StorageKey: String { var authDefinition: AccountDetails.Authentication? { get { guard let read = _authDefinition.read() else { - if let libraryUUID = self.libraryUUID { + if let libraryUUID = libraryUUID { return AccountsManager.shared.account(libraryUUID)?.details?.auths.first } - + return AccountsManager.shared.currentAccount?.details?.auths.first } return read } set { - guard let newValue = newValue else { return } + guard let newValue = newValue else { + return + } _authDefinition.write(newValue) DispatchQueue.main.async { @@ -97,12 +108,14 @@ private enum StorageKey: String { } self.notifyAccountChange = true - } if self.needsAgeCheck { - AccountsManager.shared.ageCheck.verifyCurrentAccountAgeRequirement(userAccountProvider: self, - currentLibraryAccountProvider: AccountsManager.shared) { [weak self] meetsAgeRequirement in + AccountsManager.shared.ageCheck.verifyCurrentAccountAgeRequirement( + userAccountProvider: self, + currentLibraryAccountProvider: AccountsManager + .shared + ) { [weak self] meetsAgeRequirement in DispatchQueue.main.async { mainFeed = self?.authDefinition?.coppaURL(isOfAge: meetsAgeRequirement) resolveFn() @@ -165,15 +178,15 @@ private enum StorageKey: String { } } - @objc class func sharedAccount() -> TPPUserAccount { + class func sharedAccount() -> TPPUserAccount { // Note: it's important to use `currentAccountId` instead of // `currentAccount.uuid` because the former is immediately available // (being saved into the UserDefaults) while the latter is only available // after the app startup sequence is complete (i.e. authentication // document has been loaded. - return sharedAccount(libraryUUID: AccountsManager.shared.currentAccountId) + sharedAccount(libraryUUID: AccountsManager.shared.currentAccountId) } - + class func sharedAccount(libraryUUID: String?) -> TPPUserAccount { shared.accountInfoQueue.sync { shared.libraryUUID = libraryUUID @@ -194,16 +207,17 @@ private enum StorageKey: String { } // MARK: - Storage + private lazy var _authorizationIdentifier: TPPKeychainVariable = StorageKey.authorizationIdentifier .keyForLibrary(uuid: libraryUUID) .asKeychainVariable(with: accountInfoQueue) private lazy var _adobeToken: TPPKeychainVariable = StorageKey.adobeToken .keyForLibrary(uuid: libraryUUID) .asKeychainVariable(with: accountInfoQueue) - private lazy var _licensor: TPPKeychainVariable<[String:Any]> = StorageKey.licensor + private lazy var _licensor: TPPKeychainVariable<[String: Any]> = StorageKey.licensor .keyForLibrary(uuid: libraryUUID) .asKeychainVariable(with: accountInfoQueue) - private lazy var _patron: TPPKeychainVariable<[String:Any]> = StorageKey.patron + private lazy var _patron: TPPKeychainVariable<[String: Any]> = StorageKey.patron .keyForLibrary(uuid: libraryUUID) .asKeychainVariable(with: accountInfoQueue) private lazy var _adobeVendor: TPPKeychainVariable = StorageKey.adobeVendor @@ -221,7 +235,8 @@ private enum StorageKey: String { private lazy var _credentials: TPPKeychainCodableVariable = StorageKey.credentials .keyForLibrary(uuid: libraryUUID) .asKeychainCodableVariable(with: accountInfoQueue) - private lazy var _authDefinition: TPPKeychainCodableVariable = StorageKey.authDefinition + private lazy var _authDefinition: TPPKeychainCodableVariable = StorageKey + .authDefinition .keyForLibrary(uuid: libraryUUID) .asKeychainCodableVariable(with: accountInfoQueue) private lazy var _cookies: TPPKeychainVariable<[HTTPCookie]> = StorageKey.cookies @@ -240,50 +255,50 @@ private enum StorageKey: String { .asKeychainVariable(with: accountInfoQueue) // MARK: - Check - + func hasBarcodeAndPIN() -> Bool { if let credentials = credentials, case TPPCredentials.barcodeAndPin = credentials { return true } return false } - + func hasAuthToken() -> Bool { if let credentials = credentials, case TPPCredentials.token = credentials { return true } return false } - + func isTokenRefreshRequired() -> Bool { let isTokenAuthAndMissing = (authDefinition?.isToken ?? false) && - !hasAuthToken() && - ((TPPUserAccount.sharedAccount().authDefinition?.tokenURL) != nil) - + !hasAuthToken() && + ((TPPUserAccount.sharedAccount().authDefinition?.tokenURL) != nil) + return (authTokenHasExpired || isTokenAuthAndMissing) && hasCredentials() } - + func hasAdobeToken() -> Bool { - return adobeToken != nil + adobeToken != nil } - + func hasLicensor() -> Bool { - return licensor != nil + licensor != nil } - + func hasCredentials() -> Bool { - return hasAuthToken() || hasBarcodeAndPIN() + hasAuthToken() || hasBarcodeAndPIN() } // Oauth requires login to load catalog var catalogRequiresAuthentication: Bool { - return authDefinition?.catalogRequiresAuthentication ?? false + authDefinition?.catalogRequiresAuthentication ?? false } // MARK: - Legacy - - private var legacyBarcode: String? { return _barcode.read() } - private var legacyPin: String? { return _pin.read() } + + private var legacyBarcode: String? { _barcode.read() } + private var legacyPin: String? { _pin.read() } var legacyAuthToken: String? { _authToken.read() } // MARK: - GET @@ -298,7 +313,9 @@ private enum StorageKey: String { /// features of platform.nypl.org will work if you give them a 14-digit /// barcode but not a 7-letter username or a 16-digit NYC ID. var barcode: String? { - guard let credentials = credentials else { return nil } + guard let credentials = credentials else { + return nil + } switch credentials { case let TPPCredentials.barcodeAndPin(barcode: barcode, pin: _): @@ -326,8 +343,10 @@ private enum StorageKey: String { var authorizationIdentifier: String? { _authorizationIdentifier.read() } var PIN: String? { - guard let credentials = credentials else { return nil } - + guard let credentials = credentials else { + return nil + } + switch credentials { case let TPPCredentials.barcodeAndPin(barcode: _, pin: pin): return pin @@ -338,13 +357,13 @@ private enum StorageKey: String { } } - var needsAuth:Bool { + var needsAuth: Bool { let authType = authDefinition?.authType ?? .none return authType == .basic || authType == .oauthIntermediary || authType == .saml || authType == .token } - var needsAgeCheck:Bool { - return authDefinition?.authType == .coppa + var needsAgeCheck: Bool { + authDefinition?.authType == .coppa } var deviceID: String? { _deviceID.read() } @@ -352,14 +371,15 @@ private enum StorageKey: String { var userID: String? { _userID.read() } var adobeVendor: String? { _adobeVendor.read() } var provider: String? { _provider.read() } - var patron: [String:Any]? { _patron.read() } + var patron: [String: Any]? { _patron.read() } var adobeToken: String? { _adobeToken.read() } - var licensor: [String:Any]? { _licensor.read() } + var licensor: [String: Any]? { _licensor.read() } var cookies: [HTTPCookie]? { _cookies.read() } var authToken: String? { if let credentials = _credentials.read(), - case let TPPCredentials.token(authToken: token, barcode: _, pin: _, expirationDate: _) = credentials { + case let TPPCredentials.token(authToken: token, barcode: _, pin: _, expirationDate: _) = credentials + { return token } return nil @@ -367,55 +387,54 @@ private enum StorageKey: String { var authTokenHasExpired: Bool { guard let credentials = credentials, - case let TPPCredentials.token(authToken: token) = credentials, - let expirationDate = token.expirationDate, expirationDate > Date() + case let TPPCredentials.token(authToken: token) = credentials, + let expirationDate = token.expirationDate, expirationDate > Date() else { return true } - - return false + + return false } var patronFullName: String? { if let patron = patron, - let name = patron["name"] as? [String:String] + let name = patron["name"] as? [String: String] { var fullname = "" - + if let first = name["first"] { fullname.append(first) } - + if let middle = name["middle"] { if fullname.count > 0 { fullname.append(" ") } fullname.append(middle) } - + if let last = name["last"] { if fullname.count > 0 { fullname.append(" ") } fullname.append(last) } - + return fullname.count > 0 ? fullname : nil } - + return nil } - - // MARK: - SET + @objc(setBarcode:PIN:) func setBarcode(_ barcode: String, PIN: String) { credentials = .barcodeAndPin(barcode: barcode, pin: PIN) } - + @objc(setAdobeToken:patron:) - func setAdobeToken(_ token: String, patron: [String : Any]) { + func setAdobeToken(_ token: String, patron: [String: Any]) { keychainTransaction.perform { _adobeToken.write(token) _patron.write(patron) @@ -423,21 +442,21 @@ private enum StorageKey: String { notifyAccountDidChange() } - + @objc(setAdobeVendor:) func setAdobeVendor(_ vendor: String) { _adobeVendor.write(vendor) notifyAccountDidChange() } - + @objc(setAdobeToken:) func setAdobeToken(_ token: String) { _adobeToken.write(token) notifyAccountDidChange() } - + @objc(setLicensor:) - func setLicensor(_ licensor: [String : Any]) { + func setLicensor(_ licensor: [String: Any]) { _licensor.write(licensor) } @@ -451,13 +470,13 @@ private enum StorageKey: String { func setAuthorizationIdentifier(_ identifier: String) { _authorizationIdentifier.write(identifier) } - + @objc(setPatron:) - func setPatron(_ patron: [String : Any]) { + func setPatron(_ patron: [String: Any]) { _patron.write(patron) notifyAccountDidChange() } - + @objc(setAuthToken::::) func setAuthToken(_ token: String, barcode: String?, pin: String?, expirationDate: Date?) { keychainTransaction.perform { @@ -483,13 +502,13 @@ private enum StorageKey: String { _userID.write(id) notifyAccountDidChange() } - + @objc(setDeviceID:) func setDeviceID(_ id: String) { _deviceID.write(id) notifyAccountDidChange() } - + // MARK: - Remove func removeAll() { @@ -514,25 +533,31 @@ private enum StorageKey: String { notifyAccountDidChange() - NotificationCenter.default.post(name: Notification.Name.TPPDidSignOut, - object: nil) + NotificationCenter.default.post( + name: Notification.Name.TPPDidSignOut, + object: nil + ) } } } } +// MARK: TPPSignedInStateProvider + extension TPPUserAccount: TPPSignedInStateProvider { func isSignedIn() -> Bool { - return hasCredentials() + hasCredentials() } } +// MARK: NYPLBasicAuthCredentialsProvider + extension TPPUserAccount: NYPLBasicAuthCredentialsProvider { var username: String? { - return barcode + barcode } - + var pin: String? { - return PIN + PIN } } diff --git a/Palace/Accounts/User/UserProfileDocument+Links.swift b/Palace/Accounts/User/UserProfileDocument+Links.swift index 47dd0a38c..86a45d579 100644 --- a/Palace/Accounts/User/UserProfileDocument+Links.swift +++ b/Palace/Accounts/User/UserProfileDocument+Links.swift @@ -12,7 +12,7 @@ extension UserProfileDocument { enum LinkRelation: String { case deviceRegistration = "http://palaceproject.io/terms/deviceRegistration" } - + func linksWith(_ rel: LinkRelation) -> [Link] { links?.filter { $0.rel == rel.rawValue } ?? [] } diff --git a/Palace/Accounts/User/UserProfileDocument.swift b/Palace/Accounts/User/UserProfileDocument.swift index 5aa990244..b521e699f 100644 --- a/Palace/Accounts/User/UserProfileDocument.swift +++ b/Palace/Accounts/User/UserProfileDocument.swift @@ -1,46 +1,46 @@ import Foundation -@objcMembers public class UserProfileDocument : NSObject, Codable { +@objcMembers public class UserProfileDocument: NSObject, Codable { static let parseErrorKey: String = "TPPParseProfileErrorKey" static let parseErrorDescription: String = "TPPParseProfileErrorDescription" static let parseErrorCodingPath: String = "TPPParseProfileErrorCodingPath" - - @objc @objcMembers public class DRMObject : NSObject, Codable { + + @objc @objcMembers public class DRMObject: NSObject, Codable { let vendor: String? let clientToken: String? let serverToken: String? let scheme: String? - - var licensor: [String : String] { - return [ + + var licensor: [String: String] { + [ "vendor": vendor ?? "", "clientToken": clientToken ?? "" ] } - + enum CodingKeys: String, CodingKey { - case vendor = "drm:vendor" + case vendor = "drm:vendor" case clientToken = "drm:clientToken" case serverToken = "drm:serverToken" case scheme = "drm:scheme" } } - - @objc public class Link : NSObject, Codable { + + @objc public class Link: NSObject, Codable { let href: String let type: String? let rel: String? let templated: Bool? } - - @objc public class Settings : NSObject, Codable { + + @objc public class Settings: NSObject, Codable { let synchronizeAnnotations: Bool? enum CodingKeys: String, CodingKey { - case synchronizeAnnotations = "simplified:synchronize_annotations" + case synchronizeAnnotations = "simplified:synchronize_annotations" } } - + let authorizationIdentifier: String? let drm: [DRMObject]? let links: [Link]? @@ -57,12 +57,12 @@ import Foundation enum CodingKeys: String, CodingKey { case authorizationIdentifier = "simplified:authorization_identifier" - case drm = "drm" - case links = "links" + case drm + case links case authorizationExpires = "simplified:authorization_expires" - case settings = "settings" + case settings } - + func toJson() -> String { let jsonEncoder = JSONEncoder() let jsonData = try? jsonEncoder.encode(self) @@ -71,8 +71,8 @@ import Foundation } return "" } - - @objc static func fromData(_ data: Data) throws -> UserProfileDocument { + + static func fromData(_ data: Data) throws -> UserProfileDocument { let jsonDecoder = JSONDecoder() jsonDecoder.keyDecodingStrategy = .useDefaultKeys jsonDecoder.dateDecodingStrategy = .formatted(dateFormatter) @@ -80,29 +80,45 @@ import Foundation do { return try jsonDecoder.decode(UserProfileDocument.self, from: data) } catch let DecodingError.dataCorrupted(context) { - throw NSError(domain: NSCocoaErrorDomain, - code: NSCoderReadCorruptError, - userInfo: [parseErrorKey: TPPErrorCode.parseProfileDataCorrupted.rawValue, - parseErrorDescription: context.debugDescription, - parseErrorCodingPath: context.codingPath]) + throw NSError( + domain: NSCocoaErrorDomain, + code: NSCoderReadCorruptError, + userInfo: [ + parseErrorKey: TPPErrorCode.parseProfileDataCorrupted.rawValue, + parseErrorDescription: context.debugDescription, + parseErrorCodingPath: context.codingPath + ] + ) } catch let DecodingError.typeMismatch(_, context) { - throw NSError(domain: NSCocoaErrorDomain, - code: NSCoderReadCorruptError, - userInfo: [parseErrorKey: TPPErrorCode.parseProfileTypeMismatch.rawValue, - parseErrorDescription: context.debugDescription, - parseErrorCodingPath: context.codingPath]) + throw NSError( + domain: NSCocoaErrorDomain, + code: NSCoderReadCorruptError, + userInfo: [ + parseErrorKey: TPPErrorCode.parseProfileTypeMismatch.rawValue, + parseErrorDescription: context.debugDescription, + parseErrorCodingPath: context.codingPath + ] + ) } catch let DecodingError.valueNotFound(_, context) { - throw NSError(domain: NSCocoaErrorDomain, - code: NSCoderValueNotFoundError, - userInfo: [parseErrorKey: TPPErrorCode.parseProfileValueNotFound.rawValue, - parseErrorDescription: context.debugDescription, - parseErrorCodingPath: context.codingPath]) + throw NSError( + domain: NSCocoaErrorDomain, + code: NSCoderValueNotFoundError, + userInfo: [ + parseErrorKey: TPPErrorCode.parseProfileValueNotFound.rawValue, + parseErrorDescription: context.debugDescription, + parseErrorCodingPath: context.codingPath + ] + ) } catch let DecodingError.keyNotFound(_, context) { - throw NSError(domain: NSCocoaErrorDomain, - code: NSCoderValueNotFoundError, - userInfo: [parseErrorKey: TPPErrorCode.parseProfileKeyNotFound.rawValue, - parseErrorDescription: context.debugDescription, - parseErrorCodingPath: context.codingPath]) + throw NSError( + domain: NSCocoaErrorDomain, + code: NSCoderValueNotFoundError, + userInfo: [ + parseErrorKey: TPPErrorCode.parseProfileKeyNotFound.rawValue, + parseErrorDescription: context.debugDescription, + parseErrorCodingPath: context.codingPath + ] + ) } } } diff --git a/Palace/Announcements/TPPAnnouncementBusinessLogic.swift b/Palace/Announcements/TPPAnnouncementBusinessLogic.swift index afad96181..8115a0629 100644 --- a/Palace/Announcements/TPPAnnouncementBusinessLogic.swift +++ b/Palace/Announcements/TPPAnnouncementBusinessLogic.swift @@ -2,98 +2,116 @@ import Foundation private let announcementsFilename: String = "TPPPresentedAnnouncementsList" +// MARK: - TPPAnnouncementBusinessLogic + /// This class is not thread safe class TPPAnnouncementBusinessLogic { typealias DisplayStrings = Strings.Announcments static let shared = TPPAnnouncementBusinessLogic() - private var presentedAnnouncements: Set = Set() - + private var presentedAnnouncements: Set = .init() + init() { restorePresentedAnnouncements() } - + /// Present the announcement in a view controller /// This method should be called on main thread func presentAnnouncements(_ announcements: [Announcement]) { let presentableAnnouncements = announcements.filter { shouldPresentAnnouncement(id: $0.id) } - guard let alert = self.alert(announcements: presentableAnnouncements) else { + guard let alert = alert(announcements: presentableAnnouncements) else { return } TPPPresentationUtils.safelyPresent(alert, animated: true, completion: nil) } - + // MARK: - Read - + private func restorePresentedAnnouncements() { - guard let filePathURL = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first?.appendingPathComponent(announcementsFilename), + guard let filePathURL = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first? + .appendingPathComponent(announcementsFilename), let filePathData = try? Data(contentsOf: filePathURL), let unarchived = try? NSKeyedUnarchiver.unarchiveTopLevelObjectWithData(filePathData), - let presented = unarchived as? Set else { - return + let presented = unarchived as? Set + else { + return } presentedAnnouncements = presented } - + private func shouldPresentAnnouncement(id: String) -> Bool { - return !presentedAnnouncements.contains(id) + !presentedAnnouncements.contains(id) } - + // MARK: - Write func addPresentedAnnouncement(id: String) { presentedAnnouncements.insert(id) - + storePresentedAnnouncementsToFile() } private func deletePresentedAnnouncement(id: String) { presentedAnnouncements.remove(id) - + storePresentedAnnouncementsToFile() } - + private func storePresentedAnnouncementsToFile() { - guard let filePathURL = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first?.appendingPathComponent(announcementsFilename) else { - TPPErrorLogger.logError(withCode: .directoryURLCreateFail, summary: "Unable to create directory URL for storing presented announcements") + guard let filePathURL = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first? + .appendingPathComponent(announcementsFilename) + else { + TPPErrorLogger.logError( + withCode: .directoryURLCreateFail, + summary: "Unable to create directory URL for storing presented announcements" + ) return } - + do { - let codedData = try NSKeyedArchiver.archivedData(withRootObject: presentedAnnouncements, requiringSecureCoding: false) + let codedData = try NSKeyedArchiver.archivedData( + withRootObject: presentedAnnouncements, + requiringSecureCoding: false + ) try codedData.write(to: filePathURL) } catch { - TPPErrorLogger.logError(error, - summary: "Fail to write Presented Announcements file to local storage", - metadata: ["filePathURL": filePathURL, - "presentedAnnouncements": presentedAnnouncements]) + TPPErrorLogger.logError( + error, + summary: "Fail to write Presented Announcements file to local storage", + metadata: [ + "filePathURL": filePathURL, + "presentedAnnouncements": presentedAnnouncements + ] + ) } } - + // MARK: - Helper - + /** - Generates an alert view that presents another alert when being dismissed - - Parameter announcements: an array of announcements that goes into alert message. - - Returns: The alert controller to be presented. - */ + Generates an alert view that presents another alert when being dismissed + - Parameter announcements: an array of announcements that goes into alert message. + - Returns: The alert controller to be presented. + */ private func alert(announcements: [Announcement]) -> UIAlertController? { let title = DisplayStrings.alertTitle - var currentAlert: UIAlertController? = nil - + var currentAlert: UIAlertController? + let alerts = announcements.map { - UIAlertController.init(title: title, message: $0.content, preferredStyle: .alert) + UIAlertController(title: title, message: $0.content, preferredStyle: .alert) } - + // Present another alert when the current alert is being dismiss // Add the presented announcement to the presentedAnnouncement document for (i, alert) in alerts.enumerated() { if i > 0 { - let action = UIAlertAction.init(title: DisplayStrings.ok, - style: .default) { [weak self] _ in + let action = UIAlertAction( + title: DisplayStrings.ok, + style: .default + ) { [weak self] _ in TPPPresentationUtils.safelyPresent(alert, animated: true, completion: nil) self?.addPresentedAnnouncement(id: announcements[i - 1].id) } @@ -101,14 +119,14 @@ class TPPAnnouncementBusinessLogic { } currentAlert = alert } - + // Add dismiss button to the last announcement if let last = announcements.last { - currentAlert?.addAction(UIAlertAction.init(title: DisplayStrings.ok, style: .default) { [weak self] _ in + currentAlert?.addAction(UIAlertAction(title: DisplayStrings.ok, style: .default) { [weak self] _ in self?.addPresentedAnnouncement(id: last.id) }) } - + return alerts.first } } @@ -118,7 +136,7 @@ extension TPPAnnouncementBusinessLogic { func testing_shouldPresentAnnouncement(id: String) -> Bool { shouldPresentAnnouncement(id: id) } - + func testing_deletePresentedAnnouncement(id: String) { deletePresentedAnnouncement(id: id) } diff --git a/Palace/AppInfrastructure/AppTabHostView.swift b/Palace/AppInfrastructure/AppTabHostView.swift index 3d56f6026..10c5d9162 100644 --- a/Palace/AppInfrastructure/AppTabHostView.swift +++ b/Palace/AppInfrastructure/AppTabHostView.swift @@ -1,10 +1,12 @@ import SwiftUI import UIKit +// MARK: - AppTabHostView + struct AppTabHostView: View { @StateObject private var router = AppTabRouter() @State private var holdsBadgeCount: Int = 0 - + var body: some View { TabView(selection: $router.selected) { NavigationHostView(rootView: catalogView) @@ -47,7 +49,8 @@ struct AppTabHostView: View { NavigationCoordinatorHub.shared.coordinator?.popToRoot() } if let appDelegate = UIApplication.shared.delegate as? TPPAppDelegate, - let top = appDelegate.topViewController() { + let top = appDelegate.topViewController() + { top.dismiss(animated: true) } NotificationCenter.default.post(name: .AppTabSelectionDidChange, object: nil) @@ -77,11 +80,13 @@ private extension AppTabHostView { let held = TPPBookRegistry.shared.heldBooks var readyCount = 0 for book in held { - book.defaultAcquisition?.availability.matchUnavailable(nil, - limited: nil, - unlimited: nil, - reserved: nil, - ready: { _ in readyCount += 1 }) + book.defaultAcquisition?.availability.matchUnavailable( + nil, + limited: nil, + unlimited: nil, + reserved: nil, + ready: { _ in readyCount += 1 } + ) } holdsBadgeCount = readyCount } @@ -90,4 +95,3 @@ private extension AppTabHostView { extension Notification.Name { static let AppTabSelectionDidChange = Notification.Name("AppTabSelectionDidChange") } - diff --git a/Palace/AppInfrastructure/AppTabRouter.swift b/Palace/AppInfrastructure/AppTabRouter.swift index d88af2e7c..e7df306d2 100644 --- a/Palace/AppInfrastructure/AppTabRouter.swift +++ b/Palace/AppInfrastructure/AppTabRouter.swift @@ -1,5 +1,7 @@ import SwiftUI +// MARK: - AppTab + enum AppTab: Hashable { case catalog case myBooks @@ -7,15 +9,17 @@ enum AppTab: Hashable { case settings } +// MARK: - AppTabRouter + @MainActor final class AppTabRouter: ObservableObject { @Published var selected: AppTab = .catalog } +// MARK: - AppTabRouterHub + final class AppTabRouterHub { static let shared = AppTabRouterHub() private init() {} weak var router: AppTabRouter? } - - diff --git a/Palace/AppInfrastructure/DLNavigator.swift b/Palace/AppInfrastructure/DLNavigator.swift index 5e63ab220..69c1c7414 100644 --- a/Palace/AppInfrastructure/DLNavigator.swift +++ b/Palace/AppInfrastructure/DLNavigator.swift @@ -6,15 +6,14 @@ // Copyright © 2023 The Palace Project. All rights reserved. // -import Foundation import FirebaseDynamicLinks +import Foundation class DLNavigator { - typealias Destination = (screen: String, params: [String: String]) - + static let shared = DLNavigator() - + /// Checks if DLNavigator can parse the link /// - Parameter dynamicLink: Firebase dynamic link /// - Returns: `true` if DLNavigator supports parameters provided with the link @@ -24,7 +23,7 @@ class DLNavigator { } return false } - + /// Navigates to the screen in the link /// - Parameter dynamicLink: Firebase dynamic link func navigate(to dynamicLink: DynamicLink) { @@ -33,7 +32,7 @@ class DLNavigator { } navigate(to: destination.screen, params: destination.params) } - + /// Navigates to the screen with the provided parameters /// - Parameters: /// - screen: `screen` parameter @@ -47,19 +46,20 @@ class DLNavigator { default: break } } - + private func parseLink(_ dynamicLink: DynamicLink) -> Destination? { guard let url = dynamicLink.url, let components = URLComponents(url: url, resolvingAgainstBaseURL: true), - let screen = components.queryItems?.first(where: { $0.name.lowercased() == "screen" })?.value?.lowercased() as? String + let screen = components.queryItems?.first(where: { $0.name.lowercased() == "screen" })?.value? + .lowercased() as? String else { return nil } var params = [String: String]() components.queryItems?.forEach { params[$0.name.lowercased()] = $0.value } - return Destination(screen: screen, params: params ) + return Destination(screen: screen, params: params) } - + private func login(libraryId: String, barcode: String) { let accountsManager = AccountsManager.shared guard let topViewController = (UIApplication.shared.delegate as? TPPAppDelegate)?.topViewController(), @@ -94,7 +94,7 @@ class DLNavigator { } } } - + /// Runs `block` when receives a notification with `name` once. /// - Parameters: /// - name: Notification name diff --git a/Palace/AppInfrastructure/NSNotification+TPP.swift b/Palace/AppInfrastructure/NSNotification+TPP.swift index b4743cd6e..cbf8fe1cc 100644 --- a/Palace/AppInfrastructure/NSNotification+TPP.swift +++ b/Palace/AppInfrastructure/NSNotification+TPP.swift @@ -35,26 +35,28 @@ extension Notification.Name { static let TPPReachabilityChanged = Notification.Name("TPPReachabilityChanged") } -@objc extension NSNotification { - public static let TPPSettingsDidChange = Notification.Name.TPPSettingsDidChange - public static let TPPCurrentAccountDidChange = Notification.Name.TPPCurrentAccountDidChange - public static let TPPCatalogDidLoad = Notification.Name.TPPCatalogDidLoad - public static let TPPSyncBegan = Notification.Name.TPPSyncBegan - public static let TPPSyncEnded = Notification.Name.TPPSyncEnded - public static let TPPUseBetaDidChange = Notification.Name.TPPUseBetaDidChange - public static let TPPUserAccountDidChange = Notification.Name.TPPUserAccountDidChange - public static let TPPDidSignOut = Notification.Name.TPPDidSignOut - public static let TPPIsSigningIn = Notification.Name.TPPIsSigningIn - public static let TPPAppDelegateDidReceiveCleverRedirectURL = Notification.Name.TPPAppDelegateDidReceiveCleverRedirectURL - public static let TPPBookRegistryDidChange = Notification.Name.TPPBookRegistryDidChange - public static let TPPBookRegistryStateDidChange = Notification.Name.TPPBookRegistryStateDidChange - public static let TPPBookProcessingDidChange = Notification.Name.TPPBookProcessingDidChange - public static let TPPMyBooksDownloadCenterDidChange = Notification.Name.TPPMyBooksDownloadCenterDidChange - public static let TPPBookDetailDidClose = Notification.Name.TPPBookDetailDidClose - public static let TPPAccountSetDidLoad = Notification.Name.TPPAccountSetDidLoad - public static let TPPReachabilityChanged = Notification.Name.TPPReachabilityChanged +@objc public extension NSNotification { + static let TPPSettingsDidChange = Notification.Name.TPPSettingsDidChange + static let TPPCurrentAccountDidChange = Notification.Name.TPPCurrentAccountDidChange + static let TPPCatalogDidLoad = Notification.Name.TPPCatalogDidLoad + static let TPPSyncBegan = Notification.Name.TPPSyncBegan + static let TPPSyncEnded = Notification.Name.TPPSyncEnded + static let TPPUseBetaDidChange = Notification.Name.TPPUseBetaDidChange + static let TPPUserAccountDidChange = Notification.Name.TPPUserAccountDidChange + static let TPPDidSignOut = Notification.Name.TPPDidSignOut + static let TPPIsSigningIn = Notification.Name.TPPIsSigningIn + static let TPPAppDelegateDidReceiveCleverRedirectURL = Notification.Name.TPPAppDelegateDidReceiveCleverRedirectURL + static let TPPBookRegistryDidChange = Notification.Name.TPPBookRegistryDidChange + static let TPPBookRegistryStateDidChange = Notification.Name.TPPBookRegistryStateDidChange + static let TPPBookProcessingDidChange = Notification.Name.TPPBookProcessingDidChange + static let TPPMyBooksDownloadCenterDidChange = Notification.Name.TPPMyBooksDownloadCenterDidChange + static let TPPBookDetailDidClose = Notification.Name.TPPBookDetailDidClose + static let TPPAccountSetDidLoad = Notification.Name.TPPAccountSetDidLoad + static let TPPReachabilityChanged = Notification.Name.TPPReachabilityChanged } +// MARK: - TPPNotificationKeys + class TPPNotificationKeys: NSObject { @objc public static let bookProcessingBookIDKey = "identifier" @objc public static let bookProcessingValueKey = "value" diff --git a/Palace/AppInfrastructure/NavigationCoordinator.swift b/Palace/AppInfrastructure/NavigationCoordinator.swift index d5328b230..26fd083c3 100644 --- a/Palace/AppInfrastructure/NavigationCoordinator.swift +++ b/Palace/AppInfrastructure/NavigationCoordinator.swift @@ -1,6 +1,8 @@ +import PalaceAudiobookToolkit import SwiftUI import UIKit -import PalaceAudiobookToolkit + +// MARK: - AppRoute /// High-level app routes for SwiftUI NavigationStack. /// Extend incrementally as new flows migrate to SwiftUI. @@ -13,16 +15,22 @@ enum AppRoute: Hashable { case epub(BookRoute) } +// MARK: - BookRoute + /// Lightweight, hashable identifier for a book navigation route. /// Holds only stable identity for navigation path hashing. struct BookRoute: Hashable { let id: String } +// MARK: - SearchRoute + struct SearchRoute: Hashable { let id: UUID } +// MARK: - NavigationCoordinator + /// Centralized coordinator for NavigationStack-based routing. /// Owns a NavigationPath and transient payload storage to resolve non-hashable models. @MainActor @@ -39,7 +47,7 @@ final class NavigationCoordinator: ObservableObject { private var audioModelById: [String: AudiobookPlaybackModel] = [:] private var pdfContentById: [String: (TPPPDFDocument, TPPPDFDocumentMetadata)] = [:] private var catalogFilterStatesByURL: [String: CatalogLaneFilterState] = [:] - + private let maxStoredItems = 100 private var cleanupTimer: Timer? @@ -52,14 +60,18 @@ final class NavigationCoordinator: ObservableObject { } func pop() { - guard !path.isEmpty else { return } + guard !path.isEmpty else { + return + } withAnimation(.easeInOut) { path.removeLast() } } func popToRoot() { - guard !path.isEmpty else { return } + guard !path.isEmpty else { + return + } withAnimation(.easeInOut) { path.removeLast(path.count) } @@ -69,12 +81,12 @@ final class NavigationCoordinator: ObservableObject { bookById[book.identifier] = book scheduleCleanupIfNeeded() } - + private func scheduleCleanupIfNeeded() { - let totalItems = bookById.count + searchBooksById.count + pdfControllerById.count + - audioControllerById.count + epubControllerById.count + audioModelById.count + - pdfContentById.count + catalogFilterStatesByURL.count - + let totalItems = bookById.count + searchBooksById.count + pdfControllerById.count + + audioControllerById.count + epubControllerById.count + audioModelById.count + + pdfContentById.count + catalogFilterStatesByURL.count + if totalItems > maxStoredItems { cleanupTimer?.invalidate() cleanupTimer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: false) { [weak self] _ in @@ -82,28 +94,28 @@ final class NavigationCoordinator: ObservableObject { } } } - + private func performCleanup() { let keepCount = maxStoredItems / 2 - + if bookById.count > keepCount { let keysToRemove = Array(bookById.keys.prefix(bookById.count - keepCount)) keysToRemove.forEach { bookById.removeValue(forKey: $0) } } - + if searchBooksById.count > keepCount { let keysToRemove = Array(searchBooksById.keys.prefix(searchBooksById.count - keepCount)) keysToRemove.forEach { searchBooksById.removeValue(forKey: $0) } } - + // Clear old controllers and models pdfControllerById.removeAll() - audioControllerById.removeAll() + audioControllerById.removeAll() epubControllerById.removeAll() audioModelById.removeAll() pdfContentById.removeAll() catalogFilterStatesByURL.removeAll() - + Log.info(#file, "🧹 NavigationCoordinator: Cleaned up cached items") } @@ -122,6 +134,7 @@ final class NavigationCoordinator: ObservableObject { } // MARK: - Controllers + func storePDFController(_ controller: UIViewController, forBookId id: String) { pdfControllerById[id] = controller } @@ -147,6 +160,7 @@ final class NavigationCoordinator: ObservableObject { } // MARK: - SwiftUI payloads + func storeAudioModel(_ model: AudiobookPlaybackModel, forBookId id: String) { audioModelById[id] = model scheduleCleanupIfNeeded() @@ -163,39 +177,37 @@ final class NavigationCoordinator: ObservableObject { func resolvePDF(for route: BookRoute) -> (TPPPDFDocument, TPPPDFDocumentMetadata)? { pdfContentById[route.id] } - + // MARK: - Catalog Filter State Management - + func storeCatalogFilterState(_ state: CatalogLaneFilterState, for url: URL) { let key = makeURLKey(url) catalogFilterStatesByURL[key] = state } - + func resolveCatalogFilterState(for url: URL) -> CatalogLaneFilterState? { let key = makeURLKey(url) return catalogFilterStatesByURL[key] } - + func clearCatalogFilterState(for url: URL) { let key = makeURLKey(url) catalogFilterStatesByURL.removeValue(forKey: key) } - + func clearAllCatalogFilterStates() { catalogFilterStatesByURL.removeAll() } - + private func makeURLKey(_ url: URL) -> String { - return "\(url.path)?\(url.query ?? "")" + "\(url.path)?\(url.query ?? "")" } } -// MARK: - Catalog Filter State +// MARK: - CatalogLaneFilterState struct CatalogLaneFilterState { let appliedSelections: Set - let currentSort: String // Store as string to avoid enum duplication + let currentSort: String // Store as string to avoid enum duplication let facetGroups: [CatalogFilterGroup] } - - diff --git a/Palace/AppInfrastructure/NavigationCoordinatorHub.swift b/Palace/AppInfrastructure/NavigationCoordinatorHub.swift index d8bfd536a..35480b451 100644 --- a/Palace/AppInfrastructure/NavigationCoordinatorHub.swift +++ b/Palace/AppInfrastructure/NavigationCoordinatorHub.swift @@ -5,5 +5,3 @@ final class NavigationCoordinatorHub { private init() {} weak var coordinator: NavigationCoordinator? } - - diff --git a/Palace/AppInfrastructure/NavigationHostView.swift b/Palace/AppInfrastructure/NavigationHostView.swift index be5ddde63..290d230ec 100644 --- a/Palace/AppInfrastructure/NavigationHostView.swift +++ b/Palace/AppInfrastructure/NavigationHostView.swift @@ -1,5 +1,5 @@ -import SwiftUI import PalaceAudiobookToolkit +import SwiftUI /// Generic host that provides a NavigationStack and a NavigationCoordinator environment object. struct NavigationHostView: View { @@ -15,19 +15,18 @@ struct NavigationHostView: View { rootView .onAppear { NavigationCoordinatorHub.shared.coordinator = coordinator } .navigationDestination(for: AppRoute.self) { route in - switch route { - case .bookDetail(let bookRoute): + case let .bookDetail(bookRoute): if let book = coordinator.resolveBook(for: bookRoute) { BookDetailView(book: book) .environmentObject(coordinator) } else { Text("Missing book") } - case .catalogLaneMore(let title, let url): + case let .catalogLaneMore(title, url): CatalogLaneMoreView(title: title, url: url) .environmentObject(coordinator) - case .search(let searchRoute): + case let .search(searchRoute): CatalogSearchView( books: coordinator.resolveSearchBooks(for: searchRoute), onBookSelected: { book in @@ -35,14 +34,14 @@ struct NavigationHostView: View { coordinator.push(.bookDetail(BookRoute(id: book.identifier))) } ) - case .pdf(let bookRoute): + case let .pdf(bookRoute): if let (document, metadata) = coordinator.resolvePDF(for: bookRoute) { TPPPDFReaderView(document: document) .environmentObject(metadata) } else { EmptyView() } - case .epub(let bookRoute): + case let .epub(bookRoute): if let vc = coordinator.resolveEPUBController(for: bookRoute) { UIViewControllerWrapper(vc, updater: { _ in }) .navigationBarBackButtonHidden(true) @@ -50,7 +49,7 @@ struct NavigationHostView: View { } else { EmptyView() } - case .audio(let bookRoute): + case let .audio(bookRoute): if let model = coordinator.resolveAudioModel(for: bookRoute) { AudiobookPlayerView(model: model) } else if let vc = coordinator.resolveAudioController(for: bookRoute) { @@ -66,5 +65,3 @@ struct NavigationHostView: View { .environmentObject(coordinator) } } - - diff --git a/Palace/AppInfrastructure/ReaderService.swift b/Palace/AppInfrastructure/ReaderService.swift index 3f2ac3712..289f248f9 100644 --- a/Palace/AppInfrastructure/ReaderService.swift +++ b/Palace/AppInfrastructure/ReaderService.swift @@ -4,18 +4,20 @@ final class ReaderService { static let shared = ReaderService() private init() {} - private lazy var r3Owner: TPPR3Owner = TPPR3Owner() + private lazy var r3Owner: TPPR3Owner = .init() private func topPresenter() -> UIViewController { if let scene = UIApplication.shared.connectedScenes.compactMap({ $0 as? UIWindowScene }).first, let win = scene.windows.first(where: { $0.isKeyWindow }), - let root = win.rootViewController { + let root = win.rootViewController + { var base: UIViewController = root while let presented = base.presentedViewController { base = presented } return base } if let win = UIApplication.shared.windows.first(where: { $0.isKeyWindow }), - let root = win.rootViewController { + let root = win.rootViewController + { var base: UIViewController = root while let presented = base.presentedViewController { base = presented } return base @@ -27,7 +29,7 @@ final class ReaderService { func openEPUB(_ book: TPPBook) { r3Owner.libraryService.openBook(book, sender: topPresenter()) { result in switch result { - case .success(let publication): + case let .success(publication): let nav = UINavigationController() self.r3Owner.readerModule.presentPublication(publication, book: book, in: nav, forSample: false) if let coordinator = NavigationCoordinatorHub.shared.coordinator { @@ -36,9 +38,14 @@ final class ReaderService { } else { TPPPresentationUtils.safelyPresent(nav, animated: true, completion: nil) } - case .failure(let error): + case let .failure(error): let alert = TPPAlertUtils.alert(title: "Content Protection Error", message: error.localizedDescription) - TPPAlertUtils.presentFromViewControllerOrNil(alertController: alert, viewController: nil, animated: true, completion: nil) + TPPAlertUtils.presentFromViewControllerOrNil( + alertController: alert, + viewController: nil, + animated: true, + completion: nil + ) } } } @@ -47,7 +54,7 @@ final class ReaderService { func openSample(_ book: TPPBook, url: URL) { r3Owner.libraryService.openSample(book, sampleURL: url, sender: topPresenter()) { result in switch result { - case .success(let publication): + case let .success(publication): let nav = UINavigationController() self.r3Owner.readerModule.presentPublication(publication, book: book, in: nav, forSample: true) if let coordinator = NavigationCoordinatorHub.shared.coordinator { @@ -56,12 +63,15 @@ final class ReaderService { } else { TPPPresentationUtils.safelyPresent(nav, animated: true, completion: nil) } - case .failure(let error): + case let .failure(error): let alert = TPPAlertUtils.alert(title: "Content Protection Error", message: error.localizedDescription) - TPPAlertUtils.presentFromViewControllerOrNil(alertController: alert, viewController: nil, animated: true, completion: nil) + TPPAlertUtils.presentFromViewControllerOrNil( + alertController: alert, + viewController: nil, + animated: true, + completion: nil + ) } } } } - - diff --git a/Palace/AppInfrastructure/TPPAppDelegate+Extensions.swift b/Palace/AppInfrastructure/TPPAppDelegate+Extensions.swift index c40938e63..d2abf95bf 100644 --- a/Palace/AppInfrastructure/TPPAppDelegate+Extensions.swift +++ b/Palace/AppInfrastructure/TPPAppDelegate+Extensions.swift @@ -10,29 +10,43 @@ import Foundation extension TPPAppDelegate { @objc func topViewController(_ viewController: UIViewController? = nil) -> UIViewController? { - if let viewController { return traverseTop(from: viewController) } + if let viewController { + return traverseTop(from: viewController) + } // Prefer active foreground scene, then fallback to keyWindow on older APIs if let scene = UIApplication.shared.connectedScenes .compactMap({ $0 as? UIWindowScene }) .first(where: { $0.activationState == .foregroundActive }), - let keyWin = scene.windows.first(where: { $0.isKeyWindow }), - let root = keyWin.rootViewController { + let keyWin = scene.windows.first(where: { $0.isKeyWindow }), + let root = keyWin.rootViewController + { return traverseTop(from: root) } if let win = UIApplication.shared.windows.first(where: { $0.isKeyWindow }), - let root = win.rootViewController { + let root = win.rootViewController + { return traverseTop(from: root) } return nil } private func traverseTop(from controller: UIViewController?) -> UIViewController? { - guard let controller else { return nil } - if let nav = controller as? UINavigationController { return traverseTop(from: nav.visibleViewController) } - if let tab = controller as? UITabBarController, let selected = tab.selectedViewController { return traverseTop(from: selected) } - if let presented = controller.presentedViewController { return traverseTop(from: presented) } + guard let controller else { + return nil + } + if let nav = controller as? UINavigationController { + return traverseTop(from: nav.visibleViewController) + } + if let tab = controller as? UITabBarController, + let selected = tab.selectedViewController + { + return traverseTop(from: selected) + } + if let presented = controller.presentedViewController { + return traverseTop(from: presented) + } return controller } } diff --git a/Palace/AppInfrastructure/TPPAppDelegate.swift b/Palace/AppInfrastructure/TPPAppDelegate.swift index ce96cd5ed..903e7cb47 100644 --- a/Palace/AppInfrastructure/TPPAppDelegate.swift +++ b/Palace/AppInfrastructure/TPPAppDelegate.swift @@ -1,13 +1,14 @@ -import Foundation +import BackgroundTasks import FirebaseCore import FirebaseDynamicLinks -import BackgroundTasks -import SwiftUI +import Foundation import PalaceAudiobookToolkit +import SwiftUI + +// MARK: - TPPAppDelegate @main class TPPAppDelegate: UIResponder, UIApplicationDelegate { - var window: UIWindow? let audiobookLifecycleManager = AudiobookLifecycleManager() var notificationsManager: TPPUserNotifications! @@ -15,14 +16,14 @@ class TPPAppDelegate: UIResponder, UIApplicationDelegate { // MARK: - Application Lifecycle - func applicationDidFinishLaunching(_ application: UIApplication) { + func applicationDidFinishLaunching(_: UIApplication) { let startupQueue = DispatchQueue.global(qos: .userInitiated) FirebaseApp.configure() TPPErrorLogger.configureCrashAnalytics() TPPErrorLogger.logNewAppLaunch() - + GeneralCache.clearCacheOnUpdate() setupWindow() @@ -39,10 +40,12 @@ class TPPAppDelegate: UIResponder, UIApplicationDelegate { registerBackgroundTasks() MemoryPressureMonitor.shared.start() - + DispatchQueue.main.async { [weak self] in - guard let self else { return } - self.presentFirstRunFlowIfNeeded() + guard let self else { + return + } + presentFirstRunFlowIfNeeded() } } @@ -58,9 +61,10 @@ class TPPAppDelegate: UIResponder, UIApplicationDelegate { TransifexManager.setup() - NotificationCenter.default.addObserver(forName: .TPPIsSigningIn, object: nil, queue: nil) { [weak self] notification in - self?.signingIn(notification) - } + NotificationCenter.default + .addObserver(forName: .TPPIsSigningIn, object: nil, queue: nil) { [weak self] notification in + self?.signingIn(notification) + } } private func setupBookRegistryAndNotifications() { @@ -75,7 +79,12 @@ class TPPAppDelegate: UIResponder, UIApplicationDelegate { private func registerBackgroundTasks() { BGTaskScheduler.shared.register(forTaskWithIdentifier: "org.thepalaceproject.palace.refresh", using: nil) { task in - self.handleAppRefresh(task: task as! BGAppRefreshTask) + guard let refreshTask = task as? BGAppRefreshTask else { + Log.error(#file, "Expected BGAppRefreshTask but got \(type(of: task))") + task.setTaskCompleted(success: false) + return + } + self.handleAppRefresh(task: refreshTask) } } @@ -88,7 +97,10 @@ class TPPAppDelegate: UIResponder, UIApplicationDelegate { Log.log("[Background Refresh] Failed. Error Document Present. Elapsed Time: \(-startDate.timeIntervalSinceNow)") task.setTaskCompleted(success: false) } else { - Log.log("[Background Refresh] \(newBooks ? "New books available" : "No new books fetched"). Elapsed Time: \(-startDate.timeIntervalSinceNow)") + Log + .log( + "[Background Refresh] \(newBooks ? "New books available" : "No new books fetched"). Elapsed Time: \(-startDate.timeIntervalSinceNow)" + ) task.setTaskCompleted(success: true) } } @@ -112,7 +124,11 @@ class TPPAppDelegate: UIResponder, UIApplicationDelegate { // MARK: - URL Handling (Dynamic Links) - func application(_ application: UIApplication, continue userActivity: NSUserActivity, restorationHandler: @escaping ([UIUserActivityRestoring]?) -> Void) -> Bool { + func application( + _: UIApplication, + continue userActivity: NSUserActivity, + restorationHandler _: @escaping ([UIUserActivityRestoring]?) -> Void + ) -> Bool { if let url = userActivity.webpageURL { return DynamicLinks.dynamicLinks().handleUniversalLink(url) { dynamicLink, error in if let error = error { @@ -126,14 +142,15 @@ class TPPAppDelegate: UIResponder, UIApplicationDelegate { } if userActivity.activityType == NSUserActivityTypeBrowsingWeb && - userActivity.webpageURL?.host == TPPSettings.shared.universalLinksURL.host { + userActivity.webpageURL?.host == TPPSettings.shared.universalLinksURL.host + { NotificationCenter.default.post(name: .TPPAppDelegateDidReceiveCleverRedirectURL, object: userActivity.webpageURL) return true } return false } - func application(_ app: UIApplication, open url: URL, options: [UIApplication.OpenURLOptionsKey: Any] = [:]) -> Bool { + func application(_: UIApplication, open url: URL, options _: [UIApplication.OpenURLOptionsKey: Any] = [:]) -> Bool { if let dynamicLink = DynamicLinks.dynamicLinks().dynamicLink(fromCustomSchemeURL: url) { if DLNavigator.shared.isValidLink(dynamicLink) { DLNavigator.shared.navigate(to: dynamicLink) @@ -143,18 +160,22 @@ class TPPAppDelegate: UIResponder, UIApplicationDelegate { return false } - func applicationDidBecomeActive(_ application: UIApplication) { + func applicationDidBecomeActive(_: UIApplication) { TPPErrorLogger.setUserID(TPPUserAccount.sharedAccount().barcode) } - func applicationWillTerminate(_ application: UIApplication) { + func applicationWillTerminate(_: UIApplication) { audiobookLifecycleManager.willTerminate() NotificationCenter.default.removeObserver(self) Reachability.shared.stopMonitoring() MyBooksDownloadCenter.shared.purgeAllAudiobookCaches(force: false) } - internal func application(_ application: UIApplication, handleEventsForBackgroundURLSession identifier: String, completionHandler: @escaping () -> Void) { + internal func application( + _: UIApplication, + handleEventsForBackgroundURLSession identifier: String, + completionHandler: @escaping () -> Void + ) { audiobookLifecycleManager.handleEventsForBackgroundURLSession(for: identifier, completionHandler: completionHandler) } @@ -194,6 +215,7 @@ class TPPAppDelegate: UIResponder, UIApplicationDelegate { } // MARK: - First Run Flow + extension TPPAppDelegate { private func presentFirstRunFlowIfNeeded() { // Defer until accounts have loaded to avoid false negatives on currentAccount @@ -208,9 +230,13 @@ extension TPPAppDelegate { let showOnboarding = !TPPSettings.shared.userHasSeenWelcomeScreen // Use persisted currentAccountId rather than computed currentAccount to avoid timing issues let needsAccount = (AccountsManager.shared.currentAccountId == nil) - guard showOnboarding || needsAccount else { return } + guard showOnboarding || needsAccount else { + return + } - guard let top = topViewController() else { return } + guard let top = topViewController() else { + return + } func presentOnboarding(over presenter: UIViewController) { let onboardingVC = TPPOnboardingViewController.makeSwiftUIView(dismissHandler: { @@ -259,8 +285,11 @@ extension TPPAppDelegate { } // MARK: - Memory and Disk Pressure Handling + import UIKit +// MARK: - MemoryPressureMonitor + /// Centralized observer for memory pressure, thermal state, and disk space cleanup. /// Performs cache purges, download throttling, and space reclamation when needed. final class MemoryPressureMonitor { @@ -312,7 +341,6 @@ final class MemoryPressureMonitor { MyBooksDownloadCenter.shared.pauseAllDownloads() self.reclaimDiskSpaceIfNeeded(minimumFreeMegabytes: 256) - } } @@ -328,7 +356,7 @@ final class MemoryPressureMonitor { monitorQueue.async { let processInfo = ProcessInfo.processInfo var maxActive = 10 - + if #available(iOS 11.0, *) { switch processInfo.thermalState { case .critical: @@ -353,7 +381,9 @@ final class MemoryPressureMonitor { func reclaimDiskSpaceIfNeeded(minimumFreeMegabytes: Int) { let minimumFreeBytes = Int64(minimumFreeMegabytes) * 1024 * 1024 let freeBytes = FileSystem.freeDiskSpaceInBytes() - guard freeBytes < minimumFreeBytes else { return } + guard freeBytes < minimumFreeBytes else { + return + } // Clear caches first URLCache.shared.removeAllCachedResponses() @@ -369,13 +399,25 @@ final class MemoryPressureMonitor { private func pruneOldFilesFromCachesDirectory(olderThanDays days: Int) { let fm = FileManager.default - guard let cachesDir = fm.urls(for: .cachesDirectory, in: .userDomainMask).first else { return } + guard let cachesDir = fm.urls(for: .cachesDirectory, in: .userDomainMask).first else { + return + } let cutoff = Date().addingTimeInterval(TimeInterval(-days * 24 * 60 * 60)) - if let contents = try? fm.contentsOfDirectory(at: cachesDir, includingPropertiesForKeys: [.contentAccessDateKey, .contentModificationDateKey, .isDirectoryKey], options: [.skipsHiddenFiles]) { + if let contents = try? fm.contentsOfDirectory( + at: cachesDir, + includingPropertiesForKeys: [.contentAccessDateKey, .contentModificationDateKey, .isDirectoryKey], + options: [.skipsHiddenFiles] + ) { for url in contents { do { - let rvalues = try url.resourceValues(forKeys: [.isDirectoryKey, .contentAccessDateKey, .contentModificationDateKey]) - if rvalues.isDirectory == true { continue } + let rvalues = try url.resourceValues(forKeys: [ + .isDirectoryKey, + .contentAccessDateKey, + .contentModificationDateKey + ]) + if rvalues.isDirectory == true { + continue + } let last = rvalues.contentAccessDate ?? rvalues.contentModificationDate ?? Date.distantPast if last < cutoff { try? fm.removeItem(at: url) @@ -388,13 +430,16 @@ final class MemoryPressureMonitor { } } +// MARK: - FileSystem + private enum FileSystem { static func freeDiskSpaceInBytes() -> Int64 { do { let attrs = try FileManager.default.attributesOfFileSystem(forPath: NSHomeDirectory()) - if let free = attrs[.systemFreeSize] as? NSNumber { return free.int64Value } - } catch { } + if let free = attrs[.systemFreeSize] as? NSNumber { + return free.int64Value + } + } catch {} return 0 } } - diff --git a/Palace/AppInfrastructure/TPPAppReviewPrompt.swift b/Palace/AppInfrastructure/TPPAppReviewPrompt.swift index 67a32ca1f..001b3e15b 100644 --- a/Palace/AppInfrastructure/TPPAppReviewPrompt.swift +++ b/Palace/AppInfrastructure/TPPAppReviewPrompt.swift @@ -1,19 +1,17 @@ import StoreKit @objcMembers final class TPPAppStoreReviewPrompt: NSObject { - /// The total numbers of times the app has checked to make a request private static let ReviewPromptChecksKey = "NYPLAvailabilityChecksTallyKey" - class func presentIfAvailable() - { + class func presentIfAvailable() { if #available(iOS 10.3, *) { var count = UserDefaults.standard.value(forKey: ReviewPromptChecksKey) as? UInt ?? 0 count += 1 UserDefaults.standard.setValue(count, forKey: ReviewPromptChecksKey) // System will limit to 3 requests/yr as of 12/2018 - if (count == 1 || count == 5 || count == 15) { + if count == 1 || count == 5 || count == 15 { SKStoreReviewController.requestReview() } } diff --git a/Palace/AppInfrastructure/TPPAppTheme.swift b/Palace/AppInfrastructure/TPPAppTheme.swift index bab0e6147..0c010637a 100644 --- a/Palace/AppInfrastructure/TPPAppTheme.swift +++ b/Palace/AppInfrastructure/TPPAppTheme.swift @@ -1,5 +1,4 @@ @objcMembers final class TPPAppTheme: NSObject { - private enum NYPLAppThemeColor: String { case red case pink @@ -30,51 +29,53 @@ } private class func colorFromHex(_ hex: Int) -> UIColor { - return UIColor(red: CGFloat((hex & 0xFF0000) >> 16)/255, - green: CGFloat((hex & 0xFF00) >> 8)/255, - blue: CGFloat(hex & 0xFF)/255, - alpha: 1.0) + UIColor( + red: CGFloat((hex & 0xFF0000) >> 16) / 255, + green: CGFloat((hex & 0xFF00) >> 8) / 255, + blue: CGFloat(hex & 0xFF) / 255, + alpha: 1.0 + ) } // Currently using 'primary-dark' variant of // Android Color Palette 500 series. https://material.io/tools/color/ // An updated palette should update hex, but leave the enum values. private class func hex(_ theme: NYPLAppThemeColor) -> Int { - switch(theme) { + switch theme { case .red: - return 0xb9000d + 0xB9000D case .pink: - return 0xb0003a + 0xB0003A case .purple: - return 0x6a0080 + 0x6A0080 case .deepPurple: - return 0x320b86 + 0x320B86 case .indigo: - return 0x002984 + 0x002984 case .blue: - return 0x0069c0 + 0x0069C0 case .lightBlue: - return 0x007ac1 + 0x007AC1 case .cyan: - return 0x008ba3 + 0x008BA3 case .teal: - return 0x087f23 + 0x087F23 case .green: - return 0x087f23 + 0x087F23 case .amber: - return 0xc79100 + 0xC79100 case .orange: - return 0xc66900 + 0xC66900 case .deepOrange: - return 0xc41c00 + 0xC41C00 case .brown: - return 0x4b2c20 + 0x4B2C20 case .grey: - return 0x707070 + 0x707070 case .blueGrey: - return 0x34515e + 0x34515E case .black: - return 0x000000 + 0x000000 } } } diff --git a/Palace/AppInfrastructure/TPPBookContentMetadataFilesHelper.swift b/Palace/AppInfrastructure/TPPBookContentMetadataFilesHelper.swift index 89236add1..3d737be48 100644 --- a/Palace/AppInfrastructure/TPPBookContentMetadataFilesHelper.swift +++ b/Palace/AppInfrastructure/TPPBookContentMetadataFilesHelper.swift @@ -2,32 +2,36 @@ import Foundation /// Returns the URL of the directory used for storing content and metadata. /// The directory is not guaranteed to exist at the time this method is called. -@objcMembers final class TPPBookContentMetadataFilesHelper : NSObject { - +@objcMembers final class TPPBookContentMetadataFilesHelper: NSObject { class func currentAccountDirectory() -> URL? { guard let accountId = AccountsManager.shared.currentAccountId else { return nil } return directory(for: accountId) } - + class func directory(for account: String) -> URL? { let paths = NSSearchPathForDirectoriesInDomains(.applicationSupportDirectory, .userDomainMask, true) - + if paths.count < 1 { - TPPErrorLogger.logError(withCode: .missingSystemPaths, - summary: "No valid search paths in iOS's ApplicationSupport directory in UserDomain", - metadata: ["account": account]) + TPPErrorLogger.logError( + withCode: .missingSystemPaths, + summary: "No valid search paths in iOS's ApplicationSupport directory in UserDomain", + metadata: ["account": account] + ) return nil } - let bundleID = Bundle.main.object(forInfoDictionaryKey: "CFBundleIdentifier") as! String + guard let bundleID = Bundle.main.object(forInfoDictionaryKey: "CFBundleIdentifier") as? String else { + Log.error(#file, "Failed to get CFBundleIdentifier from bundle info") + return nil + } var dirURL = URL(fileURLWithPath: paths[0]).appendingPathComponent(bundleID) - - if (account != AccountsManager.TPPAccountUUIDs[0]) { + + if account != AccountsManager.TPPAccountUUIDs[0] { dirURL = dirURL.appendingPathComponent(String(account)) } - + return dirURL } } diff --git a/Palace/AppInfrastructure/TPPConfiguration+SE.swift b/Palace/AppInfrastructure/TPPConfiguration+SE.swift index ff545586e..8f7baa8ba 100644 --- a/Palace/AppInfrastructure/TPPConfiguration+SE.swift +++ b/Palace/AppInfrastructure/TPPConfiguration+SE.swift @@ -9,9 +9,8 @@ import Foundation extension TPPConfiguration { - static let registryHashKey = "registryHashKey" - + static let betaUrl = URL(string: "https://registry.palaceproject.io/libraries/qa")! static let prodUrl = URL(string: "https://registry.palaceproject.io/libraries")! @@ -19,38 +18,40 @@ extension TPPConfiguration { static let prodUrlHash = prodUrl.absoluteString.md5().base64EncodedStringUrlSafe().trimmingCharacters(in: ["="]) static func customUrl() -> URL? { - guard let server = TPPSettings.shared.customLibraryRegistryServer else { return nil } + guard let server = TPPSettings.shared.customLibraryRegistryServer else { + return nil + } return URL(string: "https://\(server)/libraries/qa") } - + /// Checks if registry changed @objc static var registryChanged: Bool { (UserDefaults.standard.string(forKey: registryHashKey) ?? "") != prodUrlHash } - + /// Updates registry key @objc static func updateSavedeRegistryKey() { UserDefaults.standard.set(prodUrlHash, forKey: registryHashKey) } - + static func customUrlHash() -> String? { customUrl()?.absoluteString.md5().base64EncodedStringUrlSafe().trimmingCharacters(in: ["="]) } - + @objc static func mainColor() -> UIColor { UIColor.defaultLabelColor() } - + @objc static func palaceRed() -> UIColor { if #available(iOS 13, *) { if let color = UIColor(named: "PalaceRed") { return color } } - - return UIColor(red: 248.0/255.0, green: 56.0/255.0, blue: 42.0/255.0, alpha: 1.0) + + return UIColor(red: 248.0 / 255.0, green: 56.0 / 255.0, blue: 42.0 / 255.0, alpha: 1.0) } @objc static func iconLogoBlueColor() -> UIColor { @@ -61,36 +62,35 @@ extension TPPConfiguration { UIColor(named: "ColorAudiobookBackground")! } - @objc static func iconLogoGreenColor() -> UIColor { - UIColor(red: 141.0/255.0, green: 199.0/255.0, blue: 64.0/255.0, alpha: 1.0) + UIColor(red: 141.0 / 255.0, green: 199.0 / 255.0, blue: 64.0 / 255.0, alpha: 1.0) } static func cardCreationEnabled() -> Bool { - return true + true } - + @objc static func iconColor() -> UIColor { if #available(iOS 13, *) { - return UIColor(named: "ColorIcon")! + UIColor(named: "ColorIcon")! } else { - return .black + .black } } - + @objc static func compatiblePrimaryColor() -> UIColor { if #available(iOS 13, *) { - return UIColor.label + UIColor.label } else { - return .black + .black } } - + @objc static func compatibleTextColor() -> UIColor { if #available(iOS 13, *) { - return UIColor(named: "ColorInverseLabel")! + UIColor(named: "ColorInverseLabel")! } else { - return .white; + .white } } } diff --git a/Palace/AppInfrastructure/TPPUserNotifications.swift b/Palace/AppInfrastructure/TPPUserNotifications.swift index 3d8396095..adef1a4c8 100644 --- a/Palace/AppInfrastructure/TPPUserNotifications.swift +++ b/Palace/AppInfrastructure/TPPUserNotifications.swift @@ -4,8 +4,9 @@ let HoldNotificationCategoryIdentifier = "NYPLHoldToReserveNotificationCategory" let CheckOutActionIdentifier = "NYPLCheckOutNotificationAction" let DefaultActionIdentifier = "UNNotificationDefaultActionIdentifier" -@objcMembers class TPPUserNotifications: NSObject -{ +// MARK: - TPPUserNotifications + +@objcMembers class TPPUserNotifications: NSObject { typealias DisplayStrings = Strings.UserNotifications private let unCenter = UNUserNotificationCenter.current() @@ -13,10 +14,9 @@ let DefaultActionIdentifier = "UNNotificationDefaultActionIdentifier" /// defer the presentation for later to maximize acceptance rate. Otherwise, /// Apple documents authorization to be preformed at app-launch to correctly /// enable the delegate. - func authorizeIfNeeded() - { + func authorizeIfNeeded() { unCenter.delegate = self - unCenter.getNotificationSettings { (settings) in + unCenter.getNotificationSettings { settings in if settings.authorizationStatus == .notDetermined { } else { self.registerNotificationCategories() @@ -25,48 +25,54 @@ let DefaultActionIdentifier = "UNNotificationDefaultActionIdentifier" } } - class func requestAuthorization() - { + class func requestAuthorization() { let unCenter = UNUserNotificationCenter.current() - unCenter.requestAuthorization(options: [.badge,.sound,.alert]) { (granted, error) in - Log.info(#file, "Notification Authorization Results: 'Granted': \(granted)." + - " 'Error': \(error?.localizedDescription ?? "nil")") + unCenter.requestAuthorization(options: [.badge, .sound, .alert]) { granted, error in + Log.info( + #file, + "Notification Authorization Results: 'Granted': \(granted)." + + " 'Error': \(error?.localizedDescription ?? "nil")" + ) } } /// Create a local notification if a book has moved from the "holds queue" to /// the "reserved queue", and is available for the patron to checkout. - class func compareAvailability(cachedRecord:TPPBookRegistryRecord, andNewBook newBook:TPPBook) - { + class func compareAvailability(cachedRecord: TPPBookRegistryRecord, andNewBook newBook: TPPBook) { var wasOnHold = false var isNowReady = false let oldAvail = cachedRecord.book.defaultAcquisition?.availability - oldAvail?.matchUnavailable(nil, - limited: nil, - unlimited: nil, - reserved: { _ in wasOnHold = true }, - ready: nil) + oldAvail?.matchUnavailable( + nil, + limited: nil, + unlimited: nil, + reserved: { _ in wasOnHold = true }, + ready: nil + ) let newAvail = newBook.defaultAcquisition?.availability - newAvail?.matchUnavailable(nil, - limited: nil, - unlimited: nil, - reserved: nil, - ready: { _ in isNowReady = true }) - - if (wasOnHold && isNowReady) { + newAvail?.matchUnavailable( + nil, + limited: nil, + unlimited: nil, + reserved: nil, + ready: { _ in isNowReady = true } + ) + + if wasOnHold && isNowReady { createNotificationForReadyCheckout(book: newBook) } } - class func updateAppIconBadge(heldBooks: [TPPBook]) - { + class func updateAppIconBadge(heldBooks: [TPPBook]) { var readyBooks = 0 for book in heldBooks { - book.defaultAcquisition?.availability.matchUnavailable(nil, - limited: nil, - unlimited: nil, - reserved: nil, - ready: { _ in readyBooks += 1 }) + book.defaultAcquisition?.availability.matchUnavailable( + nil, + limited: nil, + unlimited: nil, + reserved: nil, + ready: { _ in readyBooks += 1 } + ) } if UIApplication.shared.applicationIconBadgeNumber != readyBooks { UIApplication.shared.applicationIconBadgeNumber = readyBooks @@ -80,11 +86,12 @@ let DefaultActionIdentifier = "UNNotificationDefaultActionIdentifier" return TPPBookRegistry.shared.heldBooks.count > 0 } - private class func createNotificationForReadyCheckout(book: TPPBook) - { + private class func createNotificationForReadyCheckout(book: TPPBook) { let unCenter = UNUserNotificationCenter.current() - unCenter.getNotificationSettings { (settings) in - guard settings.authorizationStatus == .authorized else { return } + unCenter.getNotificationSettings { settings in + guard settings.authorizationStatus == .authorized else { + return + } let title = DisplayStrings.downloadReady let content = UNMutableNotificationContent() @@ -92,48 +99,59 @@ let DefaultActionIdentifier = "UNNotificationDefaultActionIdentifier" content.title = title content.sound = UNNotificationSound.default content.categoryIdentifier = HoldNotificationCategoryIdentifier - content.userInfo = ["bookID" : book.identifier] + content.userInfo = ["bookID": book.identifier] - let request = UNNotificationRequest.init(identifier: book.identifier, - content: content, - trigger: nil) + let request = UNNotificationRequest( + identifier: book.identifier, + content: content, + trigger: nil + ) unCenter.add(request) { error in if let error = error { - TPPErrorLogger.logError(error as NSError, - summary: "Error creating notification for ready checkout", - metadata: ["book": book.loggableDictionary()]) + TPPErrorLogger.logError( + error as NSError, + summary: "Error creating notification for ready checkout", + metadata: ["book": book.loggableDictionary()] + ) } } } } - private func registerNotificationCategories() - { - let checkOutNotificationAction = UNNotificationAction(identifier: CheckOutActionIdentifier, - title: DisplayStrings.checkoutTitle, - options: []) - let holdToReserveCategory = UNNotificationCategory(identifier: HoldNotificationCategoryIdentifier, - actions: [checkOutNotificationAction], - intentIdentifiers: [], - options: []) + private func registerNotificationCategories() { + let checkOutNotificationAction = UNNotificationAction( + identifier: CheckOutActionIdentifier, + title: DisplayStrings.checkoutTitle, + options: [] + ) + let holdToReserveCategory = UNNotificationCategory( + identifier: HoldNotificationCategoryIdentifier, + actions: [checkOutNotificationAction], + intentIdentifiers: [], + options: [] + ) UNUserNotificationCenter.current().setNotificationCategories([holdToReserveCategory]) } } -@available (iOS 10.0, *) -extension TPPUserNotifications: UNUserNotificationCenterDelegate -{ - func userNotificationCenter(_ center: UNUserNotificationCenter, - willPresent notification: UNNotification, - withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) - { +// MARK: UNUserNotificationCenterDelegate + +@available(iOS 10.0, *) +extension TPPUserNotifications: UNUserNotificationCenterDelegate { + func userNotificationCenter( + _: UNUserNotificationCenter, + willPresent _: UNNotification, + withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) + -> Void + ) { completionHandler([.alert]) } - func userNotificationCenter(_ center: UNUserNotificationCenter, - didReceive response: UNNotificationResponse, - withCompletionHandler completionHandler: @escaping () -> Void) - { + func userNotificationCenter( + _: UNUserNotificationCenter, + didReceive response: UNNotificationResponse, + withCompletionHandler completionHandler: @escaping () -> Void + ) { if response.actionIdentifier == DefaultActionIdentifier { guard let currentAccount = AccountsManager.shared.currentAccount else { Log.error(#file, "Error moving to Holds tab from notification; there was no current account.") @@ -147,8 +165,7 @@ extension TPPUserNotifications: UNUserNotificationCenterDelegate } } completionHandler() - } - else if response.actionIdentifier == CheckOutActionIdentifier { + } else if response.actionIdentifier == CheckOutActionIdentifier { let userInfo = response.notification.request.content.userInfo let downloadCenter = MyBooksDownloadCenter.shared @@ -157,20 +174,21 @@ extension TPPUserNotifications: UNUserNotificationCenterDelegate return } guard let book = TPPBookRegistry.shared.book(forIdentifier: bookID) else { - completionHandler() - return + completionHandler() + return } borrow(book, inBackgroundFrom: downloadCenter, completion: completionHandler) - } - else { + } else { completionHandler() } } - private func borrow(_ book: TPPBook, - inBackgroundFrom downloadCenter: MyBooksDownloadCenter, - completion: @escaping () -> Void) { + private func borrow( + _ book: TPPBook, + inBackgroundFrom downloadCenter: MyBooksDownloadCenter, + completion: @escaping () -> Void + ) { var bgTask: UIBackgroundTaskIdentifier = .invalid bgTask = UIApplication.shared.beginBackgroundTask { if bgTask != .invalid { @@ -181,7 +199,6 @@ extension TPPUserNotifications: UNUserNotificationCenterDelegate } } - downloadCenter.startBorrow(for: book, attemptDownload: false) { completion() guard bgTask != .invalid else { diff --git a/Palace/Audiobooks/AudioBookVendors+Extensions.swift b/Palace/Audiobooks/AudioBookVendors+Extensions.swift index 4be001141..91c81bd60 100644 --- a/Palace/Audiobooks/AudioBookVendors+Extensions.swift +++ b/Palace/Audiobooks/AudioBookVendors+Extensions.swift @@ -10,40 +10,39 @@ import Foundation import PalaceAudiobookToolkit extension AudioBookVendors { - /// Vendor tag private var tag: String { - "\(FeedbookDRMPublicKeyTag)\(self.rawValue)" + "\(FeedbookDRMPublicKeyTag)\(rawValue)" } - + /// UserDefaults key to store certificate date private var validThroughDateKey: String { "\(tag)_validThroughDate" } - + /// Update vendor's DRM key /// - Parameter completion: Completion - func updateDrmCertificate(completion: ((_ error: Error?) -> ())? = nil) { + func updateDrmCertificate(completion: ((_ error: Error?) -> Void)? = nil) { switch self { case .cantook: updateCantookDRMCertificate(completion: completion) default: return } } - + /// Update Cantook DRM public key /// /// If the key is saved and its saved expiration date is later than today, the function doesn't request a new public key. /// - Parameter completion: Completion - private func updateCantookDRMCertificate(completion: ((_ error: Error?) -> ())? = nil) { + private func updateCantookDRMCertificate(completion: ((_ error: Error?) -> Void)? = nil) { // Check if we have a valid key if let date = UserDefaults.standard.value(forKey: validThroughDateKey) as? Date, Date() < date { // we have a certificate with a valid date completion?(nil) return } - + // Fetch a new drmKey - DPLAAudiobooks.drmKey { (data, date, error) in + DPLAAudiobooks.drmKey { data, date, error in if let error = error { if error is DPLAAudiobooks.DPLAError { Log.error(#file, "DPLA key-fetch error: \(error)") @@ -56,7 +55,9 @@ extension AudioBookVendors { // drmKey completion handler returns either non-empty data value or an error guard let keyData = data else { Log.error(#file, "Public key data is empty, URL: \(DPLAAudiobooks.certificateUrl)") - completion?(DPLAAudiobooks.DPLAError.drmKeyError("Public key data is empty, URL: \(DPLAAudiobooks.certificateUrl)")) + completion?(DPLAAudiobooks.DPLAError + .drmKeyError("Public key data is empty, URL: \(DPLAAudiobooks.certificateUrl)") + ) return } // Check if we have a valid date @@ -64,7 +65,7 @@ extension AudioBookVendors { // Save this date to avoid fetching this certificate untill it becomes invalid UserDefaults.standard.set(date, forKey: self.validThroughDateKey) } - + // Save SecKey let addQuery: [String: Any] = [ kSecClass as String: kSecClassKey, @@ -79,11 +80,14 @@ extension AudioBookVendors { SecItemDelete(addQuery as CFDictionary) let status = SecItemAdd(addQuery as CFDictionary, nil) if status != errSecSuccess && status != errSecDuplicateItem { - TPPKeychainManager.logKeychainError(forVendor: self.rawValue, status: status, message: "FeedbookDrmPrivateKeyManagement Error:") + TPPKeychainManager.logKeychainError( + forVendor: self.rawValue, + status: status, + message: "FeedbookDrmPrivateKeyManagement Error:" + ) } - + completion?(nil) } } - } diff --git a/Palace/Audiobooks/AudioBookVendorsHelper.swift b/Palace/Audiobooks/AudioBookVendorsHelper.swift index b0dbd4453..136ea3824 100644 --- a/Palace/Audiobooks/AudioBookVendorsHelper.swift +++ b/Palace/Audiobooks/AudioBookVendorsHelper.swift @@ -8,32 +8,30 @@ import Foundation - /// This is a helper class to use with Objective-C code @objc public class AudioBookVendorsHelper: NSObject { - /// Get vendor for the book JSON data /// - Parameter book: Book JSON dictionary /// - Returns: AudioBookVendors vendor item, if found, `nil` otherwise private static func feedbookVendor(for book: [String: Any]) -> AudioBookVendors? { guard let metadata = book["metadata"] as? [String: Any], - let signature = metadata["http://www.feedbooks.com/audiobooks/signature"] as? [String: Any], - let issuer = signature["issuer"] as? String - else { - return nil + let signature = metadata["http://www.feedbooks.com/audiobooks/signature"] as? [String: Any], + let issuer = signature["issuer"] as? String + else { + return nil } switch issuer { case "https://www.cantookaudio.com": return .cantook default: return nil } } - + /// Check if vendor key is valid and update it if not. /// - Parameters: /// - book: Book JSON dictionary /// - completion: completion - @objc public static func updateVendorKey(book: [String: Any], completion: @escaping (_ error: NSError?) -> ()) { - if let vendor = self.feedbookVendor(for: book) { + @objc public static func updateVendorKey(book: [String: Any], completion: @escaping (_ error: NSError?) -> Void) { + if let vendor = feedbookVendor(for: book) { vendor.updateDrmCertificate { error in completion(self.nsError(for: error)) } @@ -41,7 +39,7 @@ import Foundation completion(nil) } } - + /// Creates an NSError for Objective-C code providing a readable error message for `DPLAError` errors /// - Parameter error: Error object /// - Returns: NSError object diff --git a/Palace/Audiobooks/DPLA/DPLAAudiobooks.swift b/Palace/Audiobooks/DPLA/DPLAAudiobooks.swift index 4e661ea6b..7bfafaa71 100644 --- a/Palace/Audiobooks/DPLA/DPLAAudiobooks.swift +++ b/Palace/Audiobooks/DPLA/DPLAAudiobooks.swift @@ -10,33 +10,32 @@ import Foundation /// DPLA Audiobooks DRM helper class class DPLAAudiobooks { - enum DPLAError: Error { case requestError(_ url: URL, _ error: Error) case drmKeyError(_ message: String) - + var localisedDescription: String { switch self { - case .requestError(let url, let error): return "Error requesting key data from \(url): \(error.localizedDescription)" - case .drmKeyError(let message): return message + case let .requestError(url, error): "Error requesting key data from \(url): \(error.localizedDescription)" + case let .drmKeyError(message): message } } - + var readableError: String { switch self { - case .requestError: return "Error receiving DRM key." - case .drmKeyError: return "Error decoding DRM key data." + case .requestError: "Error receiving DRM key." + case .drmKeyError: "Error decoding DRM key data." } } } - + /// Cache-Control header private static let cacheControlField = "Cache-Control" /// max-age parameter of Cache-Control field private static let maxAge = "max-age" /// Certificate URL static let certificateUrl = URL(string: "https://listen.cantookaudio.com/.well-known/jwks.json")! - + /// Requests and returns a private key for audiobooks DRM /// - Parameter completion: private key data /// - Parameter keyData: Public RSA key data @@ -44,8 +43,8 @@ class DPLAAudiobooks { /// - Parameter error: Error object /// /// `completion` either returns `keyData` value or an `error`. `validThrough` date is optional even when `keyData` is not nil. - static func drmKey(completion: @escaping (_ keyData: Data?, _ validThrough: Date?, _ error: Error?) -> ()) { - let task = URLSession.shared.dataTask(with: DPLAAudiobooks.certificateUrl) { (data, response, error) in + static func drmKey(completion: @escaping (_ keyData: Data?, _ validThrough: Date?, _ error: Error?) -> Void) { + let task = URLSession.shared.dataTask(with: DPLAAudiobooks.certificateUrl) { data, response, error in // In case of an error if let error = error { completion(nil, nil, DPLAError.requestError(DPLAAudiobooks.certificateUrl, error)) @@ -54,8 +53,11 @@ class DPLAAudiobooks { // DRM is valid during a certain period of time // Check "Cache-Control" header for max-age value in seconds var validThroughDate: Date? - if let response = response as? HTTPURLResponse, let cacheControlHeader = response.allHeaderFields[cacheControlField] as? String { - let cacheControlComponents = cacheControlHeader.components(separatedBy: "=").map { $0.trimmingCharacters(in: .whitespaces) } + if let response = response as? HTTPURLResponse, + let cacheControlHeader = response.allHeaderFields[cacheControlField] as? String + { + let cacheControlComponents = cacheControlHeader.components(separatedBy: "=") + .map { $0.trimmingCharacters(in: .whitespaces) } if cacheControlComponents.count == 2 && cacheControlComponents[0].lowercased() == maxAge { if let seconds = Int(cacheControlComponents[1]) { validThroughDate = Date().addingTimeInterval(TimeInterval(seconds)) @@ -64,18 +66,26 @@ class DPLAAudiobooks { } // Decode JWK data guard let jwkData = data else { - completion(nil, nil, DPLAError.drmKeyError("Error decoding \(DPLAAudiobooks.certificateUrl): response data is empty")) + completion( + nil, + nil, + DPLAError.drmKeyError("Error decoding \(DPLAAudiobooks.certificateUrl): response data is empty") + ) return } guard let jwkResponse = try? JSONDecoder().decode(JWKResponse.self, from: jwkData), - let jwk = jwkResponse.keys.first, - let keyData = jwk.publicKeyData - else { - // If data can't be parsed or the key is missing, return its content in error message - let dataString: String = String(data: jwkData, encoding: .utf8) ?? "" - completion(nil, nil, DPLAError.drmKeyError("Error decoding \(DPLAAudiobooks.certificateUrl) response:\n\(dataString)")) - return - } + let jwk = jwkResponse.keys.first, + let keyData = jwk.publicKeyData + else { + // If data can't be parsed or the key is missing, return its content in error message + let dataString = String(data: jwkData, encoding: .utf8) ?? "" + completion( + nil, + nil, + DPLAError.drmKeyError("Error decoding \(DPLAAudiobooks.certificateUrl) response:\n\(dataString)") + ) + return + } // All is good completion(keyData, validThroughDate, nil) } diff --git a/Palace/Audiobooks/DPLA/JWKResponse.swift b/Palace/Audiobooks/DPLA/JWKResponse.swift index f403c5a0a..be58cc0a2 100644 --- a/Palace/Audiobooks/DPLA/JWKResponse.swift +++ b/Palace/Audiobooks/DPLA/JWKResponse.swift @@ -9,20 +9,28 @@ import Foundation import PalaceAudiobookToolkit +// MARK: - JWKResponse + struct JWKResponse: Codable { let keys: [JWK] } +// MARK: - JWK + struct JWK: Codable { /// Custom JWK structure in response private enum JWKKeys: String, CodingKey { case publickKeyEncoded = "http://www.feedbooks.com/audiobooks/signature/pem-key" } + let publicKeyData: Data? // Need to manually parse the key with the Url as the key init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: JWKKeys.self) - let pemString = try container.decode(String.self, forKey: .publickKeyEncoded).replacingOccurrences(of: "\n", with: "") - self.publicKeyData = Data(base64Encoded: RSAUtils.stripPEMKeyHeader(pemString)) + let pemString = try container.decode(String.self, forKey: .publickKeyEncoded).replacingOccurrences( + of: "\n", + with: "" + ) + publicKeyData = Data(base64Encoded: RSAUtils.stripPEMKeyHeader(pemString)) } } diff --git a/Palace/Audiobooks/LCP/LCPAudiobooks.swift b/Palace/Audiobooks/LCP/LCPAudiobooks.swift index 8d8d26368..1c33dc770 100644 --- a/Palace/Audiobooks/LCP/LCPAudiobooks.swift +++ b/Palace/Audiobooks/LCP/LCPAudiobooks.swift @@ -9,21 +9,20 @@ #if LCP import Foundation +import PalaceAudiobookToolkit +import ReadiumLCP import ReadiumShared import ReadiumStreamer -import ReadiumLCP -import PalaceAudiobookToolkit @objc class LCPAudiobooks: NSObject { - private static let expectedAcquisitionType = "application/vnd.readium.lcp.license.v1.0+json" - + private let audiobookUrl: AbsoluteURL private let licenseUrl: URL? private let assetRetriever: AssetRetriever private let publicationOpener: PublicationOpener private let httpClient: DefaultHTTPClient - + private var cachedPublication: Publication? private let publicationCacheLock = NSLock() private var currentPrefetchTask: Task? @@ -32,7 +31,6 @@ import PalaceAudiobookToolkit /// - Parameter audiobookUrl: can be a local `.lcpa` package URL OR an `.lcpl` license URL for streaming /// - Parameter licenseUrl: optional license URL for streaming authentication (deprecated, use audiobookUrl) @objc init?(for audiobookUrl: URL, licenseUrl: URL? = nil) { - if let fileUrl = FileURL(url: audiobookUrl) { self.audiobookUrl = fileUrl } else if let httpUrl = HTTPURL(url: audiobookUrl) { @@ -45,7 +43,7 @@ import PalaceAudiobookToolkit let httpClient = DefaultHTTPClient() self.httpClient = httpClient - self.assetRetriever = AssetRetriever(httpClient: httpClient) + assetRetriever = AssetRetriever(httpClient: httpClient) guard let contentProtection = lcpService.contentProtection else { return nil @@ -57,13 +55,13 @@ import PalaceAudiobookToolkit pdfFactory: DefaultPDFDocumentFactory() ) - self.publicationOpener = PublicationOpener( + publicationOpener = PublicationOpener( parser: parser, contentProtections: [contentProtection] ) } - @objc func contentDictionary(completion: @escaping (_ json: NSDictionary?, _ error: NSError?) -> ()) { + @objc func contentDictionary(completion: @escaping (_ json: NSDictionary?, _ error: NSError?) -> Void) { DispatchQueue.global(qos: .userInitiated).async { self.loadContentDictionary { json, error in DispatchQueue.main.async { @@ -72,15 +70,19 @@ import PalaceAudiobookToolkit } } } - - private func loadContentDictionary(completion: @escaping (_ json: NSDictionary?, _ error: NSError?) -> ()) { + + private func loadContentDictionary(completion: @escaping (_ json: NSDictionary?, _ error: NSError?) -> Void) { publicationCacheLock.lock() currentPrefetchTask?.cancel() publicationCacheLock.unlock() let task = Task { [weak self] in - guard let self else { return } - if Task.isCancelled { return } + guard let self else { + return + } + if Task.isCancelled { + return + } var urlToOpen: AbsoluteURL = audiobookUrl if let licenseUrl { @@ -89,34 +91,46 @@ import PalaceAudiobookToolkit } else if let httpUrl = HTTPURL(url: licenseUrl) { urlToOpen = httpUrl } else { - completion(nil, NSError(domain: "LCPAudiobooks", code: 1, userInfo: [NSLocalizedDescriptionKey: "Invalid license URL"])) + completion( + nil, + NSError(domain: "LCPAudiobooks", code: 1, userInfo: [NSLocalizedDescriptionKey: "Invalid license URL"]) + ) return } } - + let result = await assetRetriever.retrieve(url: urlToOpen) - + switch result { - case .success(let asset): - if Task.isCancelled { return } - - var credentials: String? = nil + case let .success(asset): + if Task.isCancelled { + return + } + + var credentials: String? if let licenseUrl = licenseUrl, licenseUrl.isFileURL { credentials = try? String(contentsOf: licenseUrl) } - - let result = await publicationOpener.open(asset: asset, allowUserInteraction: true, credentials: credentials, sender: nil) + + let result = await publicationOpener.open( + asset: asset, + allowUserInteraction: true, + credentials: credentials, + sender: nil + ) switch result { - case .success(let publication): - - if Task.isCancelled { return } + case let .success(publication): + if Task.isCancelled { + return + } publicationCacheLock.lock() cachedPublication = publication publicationCacheLock.unlock() - + if let jsonManifestString = publication.jsonManifest, let jsonData = jsonManifestString.data(using: .utf8), - let jsonObject = try? JSONSerialization.jsonObject(with: jsonData, options: []) as? NSDictionary { + let jsonObject = try? JSONSerialization.jsonObject(with: jsonData, options: []) as? NSDictionary + { completion(jsonObject, nil) } else { let links = publication.readingOrder.map { link in @@ -136,17 +150,19 @@ import PalaceAudiobookToolkit ] completion(minimal as NSDictionary, nil) } - case .failure(let error): + case let .failure(error): completion(nil, LCPAudiobooks.nsError(for: error)) } - case .failure(let error): + case let .failure(error): completion(nil, LCPAudiobooks.nsError(for: error)) } - self.publicationCacheLock.lock() - if self.currentPrefetchTask?.isCancelled == true { self.currentPrefetchTask = nil } - self.publicationCacheLock.unlock() + publicationCacheLock.lock() + if currentPrefetchTask?.isCancelled == true { + currentPrefetchTask = nil + } + publicationCacheLock.unlock() } publicationCacheLock.lock() @@ -158,15 +174,17 @@ import PalaceAudiobookToolkit /// - Parameter book: audiobook /// - Returns: `true` if the book is an LCP DRM protected audiobook, `false` otherwise @objc static func canOpenBook(_ book: TPPBook) -> Bool { - guard let defaultAcquisition = book.defaultAcquisition else { return false } + guard let defaultAcquisition = book.defaultAcquisition else { + return false + } return book.defaultBookContentType == .audiobook && defaultAcquisition.type == expectedAcquisitionType } - + /// Creates an NSError for Objective-C code /// - Parameter error: Error object /// - Returns: NSError object private static func nsError(for error: Error) -> NSError { - return NSError(domain: "Palace.LCPAudiobooks", code: 0, userInfo: [ + NSError(domain: "Palace.LCPAudiobooks", code: 0, userInfo: [ NSLocalizedDescriptionKey: error.localizedDescription, "Error": error ]) @@ -174,7 +192,6 @@ import PalaceAudiobookToolkit } extension LCPAudiobooks: LCPStreamingProvider { - public func getPublication() -> Publication? { publicationCacheLock.lock() defer { publicationCacheLock.unlock() } @@ -184,53 +201,54 @@ extension LCPAudiobooks: LCPStreamingProvider { return nil } } - + public func supportsStreaming() -> Bool { - return true + true } - + public func setupStreamingFor(_ player: Any) -> Bool { guard let streamingPlayer = player as? StreamingCapablePlayer else { return false } streamingPlayer.setStreamingProvider(self) - + publicationCacheLock.lock() let hasPublication = cachedPublication != nil publicationCacheLock.unlock() - + if !hasPublication { DispatchQueue.global(qos: .userInteractive).async { [weak self] in - self?.loadContentDictionary { _, error in + self?.loadContentDictionary { _, error in if let error = error { Log.error(#file, "Failed to load LCP publication for streaming: \(error)") } else { Log.info(#file, "Successfully loaded LCP publication for streaming") - DispatchQueue.main.async { - streamingPlayer.publicationDidLoad() + DispatchQueue.main.async { + streamingPlayer.publicationDidLoad() } } } } } - + return true } - } // MARK: - Cached manifest access + extension LCPAudiobooks { /// Returns the cached content dictionary if the publication has already been opened. /// This avoids re-opening the asset and enables immediate UI presentation. - public func cachedContentDictionary() -> NSDictionary? { + func cachedContentDictionary() -> NSDictionary? { publicationCacheLock.lock() let publication = cachedPublication publicationCacheLock.unlock() guard let publication, let jsonManifestString = publication.jsonManifest, - let jsonData = jsonManifestString.data(using: .utf8) else { + let jsonData = jsonManifestString.data(using: .utf8) + else { return nil } @@ -240,13 +258,13 @@ extension LCPAudiobooks { return nil } - public func startPrefetch() { + func startPrefetch() { DispatchQueue.global(qos: .userInteractive).async { [weak self] in self?.loadContentDictionary { _, _ in } } } - + func decrypt(url: URL, to resultUrl: URL, completion: @escaping (Error?) -> Void) { if let publication = getPublication() { decryptWithPublication(publication, url: url, to: resultUrl, completion: completion) @@ -254,27 +272,32 @@ extension LCPAudiobooks { Task { let result = await self.assetRetriever.retrieve(url: audiobookUrl) switch result { - case .success(let asset): + case let .success(asset): let publicationResult = await publicationOpener.open(asset: asset, allowUserInteraction: false, sender: nil) switch publicationResult { - case .success(let publication): + case let .success(publication): publicationCacheLock.lock() cachedPublication = publication publicationCacheLock.unlock() - + self.decryptWithPublication(publication, url: url, to: resultUrl, completion: completion) - - case .failure(let error): + + case let .failure(error): completion(error) } - case .failure(let error): + case let .failure(error): completion(error) } } } } - - private func decryptWithPublication(_ publication: Publication, url: URL, to resultUrl: URL, completion: @escaping (Error?) -> Void) { + + private func decryptWithPublication( + _ publication: Publication, + url: URL, + to resultUrl: URL, + completion: @escaping (Error?) -> Void + ) { if let resource = publication.getResource(at: url.path) { Task { do { @@ -290,11 +313,15 @@ extension LCPAudiobooks { } } } else { - completion(NSError(domain: "AudiobookResourceError", code: 404, userInfo: [NSLocalizedDescriptionKey: "Resource not found at path: \(url.path)"])) + completion(NSError( + domain: "AudiobookResourceError", + code: 404, + userInfo: [NSLocalizedDescriptionKey: "Resource not found at path: \(url.path)"] + )) } } - public func cancelPrefetch() { + func cancelPrefetch() { publicationCacheLock.lock() currentPrefetchTask?.cancel() currentPrefetchTask = nil @@ -302,7 +329,7 @@ extension LCPAudiobooks { } /// Release all held resources for the current publication and cancel any background work - public func releaseResources() { + func releaseResources() { cancelPrefetch() publicationCacheLock.lock() cachedPublication = nil @@ -322,4 +349,3 @@ private extension Publication { } #endif - diff --git a/Palace/Audiobooks/LatestAudiobookLocation.swift b/Palace/Audiobooks/LatestAudiobookLocation.swift index a92114bf1..46d1d2bd9 100644 --- a/Palace/Audiobooks/LatestAudiobookLocation.swift +++ b/Palace/Audiobooks/LatestAudiobookLocation.swift @@ -17,6 +17,3 @@ var latestAudiobookLocation: (book: String, location: String)? { } } } - - - diff --git a/Palace/Audiobooks/TPPReturnPromptHelper.swift b/Palace/Audiobooks/TPPReturnPromptHelper.swift index 910282bff..9db8f00d8 100644 --- a/Palace/Audiobooks/TPPReturnPromptHelper.swift +++ b/Palace/Audiobooks/TPPReturnPromptHelper.swift @@ -4,12 +4,10 @@ // @objcMembers final class TPPReturnPromptHelper: NSObject { - - class func audiobookPrompt(completion:@escaping (_ returnWasChosen:Bool)->()) -> UIAlertController - { + class func audiobookPrompt(completion: @escaping (_ returnWasChosen: Bool) -> Void) -> UIAlertController { let title = Strings.ReturnPromptHelper.audiobookPromptTitle let message = Strings.ReturnPromptHelper.audiobookPromptMessage - let alert = UIAlertController.init(title: title, message: message, preferredStyle: .alert) + let alert = UIAlertController(title: title, message: message, preferredStyle: .alert) let keepBook = keepAction { completion(false) } @@ -21,17 +19,19 @@ return alert } - private class func keepAction(handler: @escaping () -> ()) -> UIAlertAction { - return UIAlertAction( + private class func keepAction(handler: @escaping () -> Void) -> UIAlertAction { + UIAlertAction( title: Strings.ReturnPromptHelper.keepActionAlertTitle, style: .cancel, - handler: { _ in handler() }) + handler: { _ in handler() } + ) } - private class func returnAction(handler: @escaping () -> ()) -> UIAlertAction { - return UIAlertAction( + private class func returnAction(handler: @escaping () -> Void) -> UIAlertAction { + UIAlertAction( title: Strings.ReturnPromptHelper.returnActionTitle, style: .default, - handler: { _ in handler() }) + handler: { _ in handler() } + ) } } diff --git a/Palace/Audiobooks/Tracker/AudiobookDataManager.swift b/Palace/Audiobooks/Tracker/AudiobookDataManager.swift index 7437d5489..e23e65c4e 100644 --- a/Palace/Audiobooks/Tracker/AudiobookDataManager.swift +++ b/Palace/Audiobooks/Tracker/AudiobookDataManager.swift @@ -1,5 +1,7 @@ -import Foundation import Combine +import Foundation + +// MARK: - LibraryBook struct LibraryBook: Codable, Hashable { var bookId: String @@ -15,6 +17,8 @@ struct LibraryBook: Codable, Hashable { } } +// MARK: - RequestData + struct RequestData: Codable { struct TimeEntry: Codable { let id: String @@ -28,13 +32,13 @@ struct RequestData: Codable { } init(time: AudiobookTimeEntry) { - self.id = time.id - self.duringMinute = time.duringMinute - self.secondsPlayed = Int(time.duration) + id = time.id + duringMinute = time.duringMinute + secondsPlayed = Int(time.duration) } var description: String { - return "TimeEntry(id: \(id), duringMinute: \(duringMinute), secondsPlayed: \(secondsPlayed))" + "TimeEntry(id: \(id), duringMinute: \(duringMinute), secondsPlayed: \(secondsPlayed))" } } @@ -49,16 +53,18 @@ struct RequestData: Codable { } init(libraryBook: LibraryBook, timeEntries: [AudiobookTimeEntry]) { - self.libraryId = libraryBook.libraryId - self.bookId = libraryBook.bookId + libraryId = libraryBook.libraryId + bookId = libraryBook.bookId self.timeEntries = timeEntries.map { TimeEntry(time: $0) } } var jsonRepresentation: Data? { - return try? JSONEncoder().encode(self) + try? JSONEncoder().encode(self) } } +// MARK: - ResponseData + struct ResponseData: Codable { struct ResponseEntry: Codable { let status: Int @@ -80,11 +86,13 @@ struct ResponseData: Codable { } } +// MARK: - AudiobookDataManagerStore + struct AudiobookDataManagerStore: Codable { var urls: [LibraryBook: URL] = [:] var queue: [AudiobookTimeEntry] = [] - init() { } + init() {} init?(data: Data) { guard let value = try? JSONDecoder().decode(AudiobookDataManagerStore.self, from: data) else { @@ -98,6 +106,8 @@ struct AudiobookDataManagerStore: Codable { } } +// MARK: - AudiobookDataManager + class AudiobookDataManager { private let syncTimeInterval: TimeInterval private var subscriptions: Set = [] @@ -144,35 +154,39 @@ class AudiobookDataManager { func syncValues(_: Date? = nil) { syncQueue.async { [weak self] in - guard let self = self else { return } + guard let self = self else { + return + } - let queuedLibraryBooks: Set = Set(self.store.queue.map { LibraryBook(time: $0) }) + let queuedLibraryBooks: Set = Set(store.queue.map { LibraryBook(time: $0) }) for libraryBook in queuedLibraryBooks { let requestData = RequestData( libraryBook: libraryBook, - timeEntries: self.store.queue.filter { libraryBook == LibraryBook(time: $0) } + timeEntries: store.queue.filter { libraryBook == LibraryBook(time: $0) } ) - self.audiobookLogger.logEvent( + audiobookLogger.logEvent( forBookId: libraryBook.bookId, event: """ - Preparing to upload time entries: - Book ID: \(libraryBook.bookId) - Library ID: \(libraryBook.libraryId) - Time Entries: \(requestData.timeEntries.map { "\($0)" }.joined(separator: ", ")) - """ + Preparing to upload time entries: + Book ID: \(libraryBook.bookId) + Library ID: \(libraryBook.libraryId) + Time Entries: \(requestData.timeEntries.map { "\($0)" }.joined(separator: ", ")) + """ ) - if let requestUrl = self.store.urls[libraryBook], let requestBody = requestData.jsonRepresentation { + if let requestUrl = store.urls[libraryBook], let requestBody = requestData.jsonRepresentation { var request = TPPNetworkExecutor.shared.request(for: requestUrl) request.httpMethod = "POST" request.httpBody = requestBody request.setValue("application/json", forHTTPHeaderField: "Content-Type") request.applyCustomUserAgent() - self.networkService.POST(request, useTokenIfAvailable: true) { [weak self] result, response, error in - guard let self = self else { return } + networkService.POST(request, useTokenIfAvailable: true) { [weak self] result, response, error in + guard let self = self else { + return + } if let response = response as? HTTPURLResponse { if response.statusCode == 404 { @@ -183,15 +197,15 @@ class AudiobookDataManager { "requestBody": String(data: requestBody, encoding: .utf8) ?? "" ]) - self.audiobookLogger.logEvent(forBookId: libraryBook.bookId, event: """ - Removing time entries due to 404: - Book ID: \(libraryBook.bookId) - Library ID: \(libraryBook.libraryId) - """) + audiobookLogger.logEvent(forBookId: libraryBook.bookId, event: """ + Removing time entries due to 404: + Book ID: \(libraryBook.bookId) + Library ID: \(libraryBook.libraryId) + """) - self.store.queue.removeAll { $0.bookId == libraryBook.bookId && $0.libraryId == libraryBook.libraryId } - self.store.urls.removeValue(forKey: libraryBook) - self.saveStore() + store.queue.removeAll { $0.bookId == libraryBook.bookId && $0.libraryId == libraryBook.libraryId } + store.urls.removeValue(forKey: libraryBook) + saveStore() return } else if !response.isSuccess() { TPPErrorLogger.logError(error, summary: "Error uploading audiobook tracker data", metadata: [ @@ -200,15 +214,15 @@ class AudiobookDataManager { "requestUrl": requestUrl, "requestBody": String(data: requestBody, encoding: .utf8) ?? "", "responseCode": response.statusCode, - "responseBody": String(data: (result ?? Data()), encoding: .utf8) ?? "" + "responseBody": String(data: result ?? Data(), encoding: .utf8) ?? "" ]) - self.audiobookLogger.logEvent(forBookId: libraryBook.bookId, event: """ - Failed to upload time entries: - Book ID: \(libraryBook.bookId) - Error: \(error?.localizedDescription ?? "Unknown error") - Response Code: \(response.statusCode) - """) + audiobookLogger.logEvent(forBookId: libraryBook.bookId, event: """ + Failed to upload time entries: + Book ID: \(libraryBook.bookId) + Error: \(error?.localizedDescription ?? "Unknown error") + Response Code: \(response.statusCode) + """) } } @@ -225,14 +239,14 @@ class AudiobookDataManager { "entryMessage": responseEntry.message ]) } else { - self.audiobookLogger.logEvent(forBookId: libraryBook.bookId, event: """ - Successfully uploaded time entry: \(responseEntry.id) - """) + audiobookLogger.logEvent(forBookId: libraryBook.bookId, event: """ + Successfully uploaded time entry: \(responseEntry.id) + """) } } - self.removeSynchronizedEntries(ids: responseData.responses.map { $0.id }) - self.cleanUpUrls() + removeSynchronizedEntries(ids: responseData.responses.map(\.id)) + cleanUpUrls() } } } @@ -269,11 +283,11 @@ class AudiobookDataManager { } private var storeDirectoryUrl: URL? { - return TPPBookContentMetadataFilesHelper.directory(for: "timetracker") + TPPBookContentMetadataFilesHelper.directory(for: "timetracker") } private var storeUrl: URL? { - return storeDirectoryUrl?.appendingPathComponent("store.json") + storeDirectoryUrl?.appendingPathComponent("store.json") } private func cleanUpUrls() { @@ -288,6 +302,8 @@ class AudiobookDataManager { } } +// MARK: DataManager + extension AudiobookDataManager: DataManager { func save(time: TimeEntry) { guard let timeEntry = time as? AudiobookTimeEntry else { diff --git a/Palace/Audiobooks/Tracker/AudiobookTimeEntry.swift b/Palace/Audiobooks/Tracker/AudiobookTimeEntry.swift index 87ca18918..2712e31d5 100644 --- a/Palace/Audiobooks/Tracker/AudiobookTimeEntry.swift +++ b/Palace/Audiobooks/Tracker/AudiobookTimeEntry.swift @@ -12,10 +12,10 @@ import Foundation /// /// Implements `DataManager` `TimeEntry` protocol struct AudiobookTimeEntry: TimeEntry, Codable, Hashable { - let id: String - let bookId: String - let libraryId: String - let timeTrackingUrl: URL - let duringMinute: String - let duration: Int + let id: String + let bookId: String + let libraryId: String + let timeTrackingUrl: URL + let duringMinute: String + let duration: Int } diff --git a/Palace/Audiobooks/Tracker/AudiobookTimeTracker.swift b/Palace/Audiobooks/Tracker/AudiobookTimeTracker.swift index 7ea4438ca..9dcfa2218 100644 --- a/Palace/Audiobooks/Tracker/AudiobookTimeTracker.swift +++ b/Palace/Audiobooks/Tracker/AudiobookTimeTracker.swift @@ -6,14 +6,13 @@ // Copyright © 2023 The Palace Project. All rights reserved. // -import Foundation import Combine -import ULID +import Foundation import PalaceAudiobookToolkit +import ULID @objc class AudiobookTimeTracker: NSObject, AudiobookPlaybackTrackerDelegate { - private var subscriptions: Set = [] private let dataManager: DataManager private let libraryId: String @@ -21,7 +20,7 @@ class AudiobookTimeTracker: NSObject, AudiobookPlaybackTrackerDelegate { private let timeTrackingUrl: URL private var currentMinute: String private var duration: TimeInterval = 0 - private var timeEntryId: ULID = ULID(timestamp: Date()) + private var timeEntryId: ULID = .init(timestamp: Date()) private let syncQueue = DispatchQueue(label: "com.audiobook.timeTracker", attributes: .concurrent) private let minuteFormatter: DateFormatter @@ -36,7 +35,7 @@ class AudiobookTimeTracker: NSObject, AudiobookPlaybackTrackerDelegate { self.bookId = bookId self.timeTrackingUrl = timeTrackingUrl self.dataManager = dataManager - self.minuteFormatter = DateFormatter() + minuteFormatter = DateFormatter() minuteFormatter.dateFormat = "yyyy-MM-dd'T'HH:mm'Z'" minuteFormatter.timeZone = TimeZone(identifier: "UTC") currentMinute = minuteFormatter.string(from: Date()) @@ -74,14 +73,16 @@ class AudiobookTimeTracker: NSObject, AudiobookPlaybackTrackerDelegate { func receiveValue(_ value: Date) { syncQueue.async(flags: .barrier) { [weak self] in - guard let self = self else { return } + guard let self = self else { + return + } - self.duration += self.tick - let minute = self.minuteFormatter.string(from: value) + duration += tick + let minute = minuteFormatter.string(from: value) - if minute != self.currentMinute { - self.saveCurrentDuration(date: value) - self.currentMinute = minute + if minute != currentMinute { + saveCurrentDuration(date: value) + currentMinute = minute } } } @@ -91,7 +92,10 @@ class AudiobookTimeTracker: NSObject, AudiobookPlaybackTrackerDelegate { timeEntryId = ULID(timestamp: date) dataManager.save(time: timeEntry) - audiobookLogger.logEvent(forBookId: bookId, event: "Time entry saved for minute \(currentMinute), \(min(60, Int(duration))) seconds played.") + audiobookLogger.logEvent( + forBookId: bookId, + event: "Time entry saved for minute \(currentMinute), \(min(60, Int(duration))) seconds played." + ) duration = 0 } diff --git a/Palace/Audiobooks/Tracker/DataManager.swift b/Palace/Audiobooks/Tracker/DataManager.swift index 1a91958b2..2cf3717a1 100644 --- a/Palace/Audiobooks/Tracker/DataManager.swift +++ b/Palace/Audiobooks/Tracker/DataManager.swift @@ -8,6 +8,8 @@ import Foundation +// MARK: - TimeEntry + /// Tracked time entry for DataManager internal storage public protocol TimeEntry { /// Unique entry identifier @@ -24,10 +26,11 @@ public protocol TimeEntry { var duration: Int { get } } +// MARK: - DataManager + /// Data Manager. public protocol DataManager { /// Save tracked time /// - Parameter time: Time entry. func save(time: TimeEntry) } - diff --git a/Palace/Book/Models/TPPBook+Additions.swift b/Palace/Book/Models/TPPBook+Additions.swift index 5bf28db4c..e12f760cb 100644 --- a/Palace/Book/Models/TPPBook+Additions.swift +++ b/Palace/Book/Models/TPPBook+Additions.swift @@ -12,6 +12,6 @@ extension TPPBook { // TODO: SIMPLY-2656 Remove this hack if possible, or at least use DI for // instead of implicitly using NYPLMyBooksDownloadCenter var url: URL? { - return MyBooksDownloadCenter.shared.fileUrl(for: identifier) + MyBooksDownloadCenter.shared.fileUrl(for: identifier) } } diff --git a/Palace/Book/Models/TPPBook+Extensions.swift b/Palace/Book/Models/TPPBook+Extensions.swift index 5c31eb3b4..5d17e04e8 100644 --- a/Palace/Book/Models/TPPBook+Extensions.swift +++ b/Palace/Book/Models/TPPBook+Extensions.swift @@ -13,44 +13,52 @@ import Foundation var bearerToken: String? { get { - let _bearerToken: TPPKeychainVariable = self.identifier.asKeychainVariable(with: bookTokenQueue) + let _bearerToken: TPPKeychainVariable = identifier.asKeychainVariable(with: bookTokenQueue) return _bearerToken.read() } set { let keychainTransaction = TPPKeychainVariableTransaction(accountInfoQueue: bookTokenQueue) - let _bearerToken: TPPKeychainVariable = self.identifier.asKeychainVariable(with: bookTokenQueue) + let _bearerToken: TPPKeychainVariable = identifier.asKeychainVariable(with: bookTokenQueue) keychainTransaction.perform { _bearerToken.write(newValue) } } } - + /// Readable book format based on its content type var format: String { switch defaultBookContentType { - case .epub: return DisplayStrings.epubContentType - case .pdf: return DisplayStrings.pdfContentType - case .audiobook: return DisplayStrings.audiobookContentType - case .unsupported: return DisplayStrings.unsupportedContentType + case .epub: DisplayStrings.epubContentType + case .pdf: DisplayStrings.pdfContentType + case .audiobook: DisplayStrings.audiobookContentType + case .unsupported: DisplayStrings.unsupportedContentType } } var hasSample: Bool { sample != nil } var hasAudiobookSample: Bool { hasSample && defaultBookContentType == .audiobook } - var showAudiobookToolbar: Bool { hasAudiobookSample && SampleType(rawValue: sampleAcquisition?.type ?? "")?.needsDownload ?? false } + var showAudiobookToolbar: Bool { + hasAudiobookSample && SampleType(rawValue: sampleAcquisition?.type ?? "")?.needsDownload ?? false + } } extension TPPBook { var sample: Sample? { - guard let acquisition = self.sampleAcquisition else { return nil } - switch self.defaultBookContentType { + guard let acquisition = sampleAcquisition else { + return nil + } + switch defaultBookContentType { case .epub, .pdf: - guard let sampleType = SampleType(rawValue: acquisition.type) else { return nil } - return EpubSample(url: acquisition.hrefURL, type: sampleType) + guard let sampleType = SampleType(rawValue: acquisition.type) else { + return nil + } + return EpubSample(url: acquisition.hrefURL, type: sampleType) case .audiobook: - guard let sampleType = SampleType(rawValue: acquisition.type) else { return nil } - return AudiobookSample(url: acquisition.hrefURL, type: sampleType) + guard let sampleType = SampleType(rawValue: acquisition.type) else { + return nil + } + return AudiobookSample(url: acquisition.hrefURL, type: sampleType) default: return nil } diff --git a/Palace/Book/Models/TPPBook.swift b/Palace/Book/Models/TPPBook.swift index 5aa9bbd6b..4f274c232 100644 --- a/Palace/Book/Models/TPPBook.swift +++ b/Palace/Book/Models/TPPBook.swift @@ -6,10 +6,10 @@ // Copyright © 2022 The Palace Project. All rights reserved. // -import Foundation import Combine import CoreImage import CoreImage.CIFilterBuiltins +import Foundation let DeprecatedAcquisitionKey = "acquisition" let DeprecatedAvailableCopiesKey = "available-copies" @@ -42,6 +42,8 @@ let TitleKey = "title" let UpdatedKey = "updated" let TimeTrackingURLURLKey = "time-tracking-url" +// MARK: - TPPBook + public class TPPBook: NSObject, ObservableObject { @objc var acquisitions: [TPPOPDSAcquisition] @objc var bookAuthors: [TPPBookAuthor]? @@ -68,17 +70,18 @@ public class TPPBook: NSObject, ObservableObject { @objc var contributors: [String: Any]? @objc var bookTokenQueue: DispatchQueue @objc var bookDuration: String? - + @Published var coverImage: UIImage? @Published var thumbnailImage: UIImage? @Published var isCoverLoading: Bool = false @Published var isThumbnailLoading: Bool = false @Published var dominantUIColor: UIColor = .gray - + static let SimplifiedScheme = "http://librarysimplified.org/terms/genres/Simplified/" static func categoryStringsFromCategories(categories: [TPPOPDSCategory]) -> [String] { - categories.compactMap { $0.scheme == nil || $0.scheme?.absoluteString == SimplifiedScheme ? $0.label ?? $0.term : nil } + categories + .compactMap { $0.scheme == nil || $0.scheme?.absoluteString == SimplifiedScheme ? $0.label ?? $0.term : nil } } @objc var isAudiobook: Bool { @@ -119,7 +122,7 @@ public class TPPBook: NSObject, ObservableObject { imageCache: ImageCacheType ) { self.acquisitions = acquisitions - self.bookAuthors = authors + bookAuthors = authors self.categoryStrings = categoryStrings self.distributor = distributor self.identifier = identifier @@ -141,18 +144,18 @@ public class TPPBook: NSObject, ObservableObject { self.reportURL = reportURL self.timeTrackingURL = timeTrackingURL self.contributors = contributors - self.bookTokenQueue = DispatchQueue(label: "TPPBook.bookTokenQueue.\(identifier)") + bookTokenQueue = DispatchQueue(label: "TPPBook.bookTokenQueue.\(identifier)") self.bookDuration = bookDuration self.imageCache = imageCache - + super.init() - self.fetchThumbnailImage() - self.fetchCoverImage() + fetchThumbnailImage() + fetchCoverImage() } @objc convenience init?(entry: TPPOPDSEntry?) { guard let entry = entry else { - Log.debug(#file, ("Failed to create book with nil entry.")) + Log.debug(#file, "Failed to create book with nil entry.") return nil } @@ -215,7 +218,8 @@ public class TPPBook: NSObject, ObservableObject { @objc convenience init?(dictionary: [String: Any]) { guard let categoryStrings = dictionary[CategoriesKey] as? [String], let identifier = dictionary[IdentifierKey] as? String, - let title = dictionary[TitleKey] as? String else { + let title = dictionary[TitleKey] as? String + else { return nil } @@ -225,21 +229,21 @@ public class TPPBook: NSObject, ObservableObject { let authorStrings: [String] = { if let authorObject = dictionary[AuthorsKey] as? [[String]], let values = authorObject.first { - return values + values } else if let authorObject = dictionary[AuthorsKey] as? [String] { - return authorObject + authorObject } else { - return [] + [] } }() let authorLinkStrings: [String] = { if let authorLinkObject = dictionary[AuthorLinksKey] as? [[String]], let values = authorLinkObject.first { - return values + values } else if let authorLinkObject = dictionary[AuthorLinksKey] as? [String] { - return authorLinkObject + authorLinkObject } else { - return [] + [] } }() @@ -253,7 +257,9 @@ public class TPPBook: NSObject, ObservableObject { let revokeURL = URL(string: dictionary[RevokeURLKey] as? String ?? "") let reportURL = URL(string: dictionary[ReportURLKey] as? String ?? "") - guard let updated = NSDate(iso8601DateString: dictionary[UpdatedKey] as? String ?? "") as? Date else { return nil } + guard let updated = NSDate(iso8601DateString: dictionary[UpdatedKey] as? String ?? "") as? Date else { + return nil + } self.init( acquisitions: acquisitions, @@ -283,14 +289,14 @@ public class TPPBook: NSObject, ObservableObject { imageCache: ImageCache.shared ) } - + @objc func bookWithMetadata(from book: TPPBook) -> TPPBook { TPPBook( - acquisitions: self.acquisitions, + acquisitions: acquisitions, authors: book.bookAuthors, categoryStrings: book.categoryStrings, distributor: book.distributor, - identifier: self.identifier, + identifier: identifier, imageURL: book.imageURL, imageThumbnailURL: book.imageThumbnailURL, published: book.published, @@ -305,18 +311,18 @@ public class TPPBook: NSObject, ObservableObject { relatedWorksURL: book.relatedWorksURL, previewLink: book.previewLink, seriesURL: book.seriesURL, - revokeURL: self.revokeURL, - reportURL: self.reportURL, - timeTrackingURL: self.timeTrackingURL, + revokeURL: revokeURL, + reportURL: reportURL, + timeTrackingURL: timeTrackingURL, contributors: book.contributors, bookDuration: book.bookDuration, - imageCache: self.imageCache + imageCache: imageCache ) } - + @objc func dictionaryRepresentation() -> [String: Any] { - let acquisitions = self.acquisitions.map { $0.dictionaryRepresentation() } - + let acquisitions = acquisitions.map { $0.dictionaryRepresentation() } + return [ AcquisitionsKey: acquisitions, AlternateURLKey: alternateURL?.absoluteString ?? "", @@ -343,27 +349,27 @@ public class TPPBook: NSObject, ObservableObject { TimeTrackingURLURLKey: timeTrackingURL?.absoluteString as Any ] } - + @objc var authorNameArray: [String]? { - bookAuthors?.compactMap { $0.name } + bookAuthors?.compactMap(\.name) } - + @objc var authorLinkArray: [String]? { bookAuthors?.compactMap { $0.relatedBooksURL?.absoluteString } } - + @objc var authors: String? { authorNameArray?.joined(separator: "; ") } - + @objc var categories: String? { categoryStrings?.joined(separator: "; ") } - + @objc var narrators: String? { (contributors?["nrt"] as? [String])?.joined(separator: "; ") } - + @objc var defaultAcquisition: TPPOPDSAcquisition? { acquisitions.first(where: { !TPPOPDSAcquisitionPath.supportedAcquisitionPaths( @@ -373,7 +379,7 @@ public class TPPBook: NSObject, ObservableObject { ).isEmpty }) } - + @objc var sampleAcquisition: TPPOPDSAcquisition? { acquisitions.first(where: { !TPPOPDSAcquisitionPath.supportedAcquisitionPaths( @@ -383,34 +389,40 @@ public class TPPBook: NSObject, ObservableObject { ).isEmpty }) ?? previewLink } - + @objc var isExpired: Bool { - guard let date = getExpirationDate() else { return false } + guard let date = getExpirationDate() else { + return false + } return date < Date() } - + @objc func getExpirationDate() -> Date? { var date: Date? defaultAcquisition?.availability.matchUnavailable( nil, limited: { limited in - if let until = limited.until, until.timeIntervalSinceNow > 0 { date = until } + if let until = limited.until, until.timeIntervalSinceNow > 0 { + date = until + } }, unlimited: nil, reserved: nil, ready: { ready in - if let until = ready.until, until.timeIntervalSinceNow > 0 { date = until } + if let until = ready.until, until.timeIntervalSinceNow > 0 { + date = until + } } ) - + return date } - + @objc func getReservationDetails() -> ReservationDetails { var untilDate: Date? let reservationDetails = ReservationDetails() - + defaultAcquisition?.availability.matchUnavailable( nil, limited: nil, @@ -422,9 +434,9 @@ public class TPPBook: NSObject, ObservableObject { if let until = reserved.until, until.timeIntervalSinceNow > 0 { untilDate = until } - + reservationDetails.copiesAvailable = Int(reserved.copiesTotal) - + }, ready: { ready in if let until = ready.until, until.timeIntervalSinceNow > 0 { @@ -432,22 +444,22 @@ public class TPPBook: NSObject, ObservableObject { } } ) - + // Convert untilDate into a readable format if let untilDate = untilDate { let now = Date() let calendar = Calendar.current let components = calendar.dateComponents([.day], from: now, to: untilDate) - + if let days = components.day { reservationDetails.remainingTime = days reservationDetails.timeUnit = "day\(days == 1 ? "" : "s")" } } - + return reservationDetails } - + func getAvailabilityDetails() -> AvailabilityDetails { var details = AvailabilityDetails() defaultAcquisition?.availability.matchUnavailable(nil, limited: { limited in @@ -455,7 +467,7 @@ public class TPPBook: NSObject, ObservableObject { let (value, unit) = sinceDate.timeUntil() details.availableSince = "\(value) \(unit)" } - + if let untilDate = limited.until, untilDate.timeIntervalSinceNow > 0 { let (value, unit) = untilDate.timeUntil() details.availableUntil = "\(value) \(unit)" @@ -465,16 +477,16 @@ public class TPPBook: NSObject, ObservableObject { let (value, unit) = sinceDate.timeUntil() details.availableSince = "\(value) \(unit)" } - + if let untilDate = unlimited.until, untilDate.timeIntervalSinceNow > 0 { let (value, unit) = untilDate.timeUntil() details.availableUntil = "\(value) \(unit)" } }, reserved: nil) - + return details } - + @objc var defaultAcquisitionIfBorrow: TPPOPDSAcquisition? { defaultAcquisition?.relation == .borrow ? defaultAcquisition : nil } @@ -497,80 +509,95 @@ public class TPPBook: NSObject, ObservableObject { } } +// MARK: Identifiable + extension TPPBook: Identifiable {} + +// MARK: Comparable + extension TPPBook: Comparable { public static func < (lhs: TPPBook, rhs: TPPBook) -> Bool { lhs.identifier < rhs.identifier } } +// MARK: @unchecked Sendable + extension TPPBook: @unchecked Sendable {} extension TPPBook { func requiresAuthForReturnOrDeletion() -> Bool { let userAuthRequired = TPPUserAccount.sharedAccount().authDefinition?.needsAuth ?? false - return self.defaultAcquisitionIfOpenAccess == nil && userAuthRequired + return defaultAcquisitionIfOpenAccess == nil && userAuthRequired } } extension TPPBook { private static let coverRegistry = TPPBookCoverRegistry.shared - - func fetchCoverImage() { - let simpleKey = identifier - let coverKey = "\(identifier)_cover" - - if let img = imageCache.get(for: simpleKey) ?? imageCache.get(for: coverKey) { - coverImage = img - updateDominantColor(using: img) - return - } - guard !isCoverLoading else { return } - isCoverLoading = true + func fetchCoverImage() { + let simpleKey = identifier + let coverKey = "\(identifier)_cover" - TPPBookCoverRegistryBridge.shared.coverImageForBook(self) { [weak self] image in - guard let self = self else { return } - let final = image ?? self.thumbnailImage + if let img = imageCache.get(for: simpleKey) ?? imageCache.get(for: coverKey) { + coverImage = img + updateDominantColor(using: img) + return + } - self.coverImage = final - if let img = final { - self.imageCache.set(img, for: self.identifier) - self.imageCache.set(img, for: coverKey) - self.updateDominantColor(using: img) - } - self.isCoverLoading = false - } + guard !isCoverLoading else { + return } + isCoverLoading = true - func fetchThumbnailImage() { - let simpleKey = identifier - let thumbnailKey = "\(identifier)_thumbnail" - - if let img = imageCache.get(for: simpleKey) ?? imageCache.get(for: thumbnailKey) { - thumbnailImage = img + TPPBookCoverRegistryBridge.shared.coverImageForBook(self) { [weak self] image in + guard let self = self else { return } + let final = image ?? thumbnailImage + + coverImage = final + if let img = final { + imageCache.set(img, for: identifier) + imageCache.set(img, for: coverKey) + updateDominantColor(using: img) + } + isCoverLoading = false + } + } - guard !isThumbnailLoading else { return } - isThumbnailLoading = true + func fetchThumbnailImage() { + let simpleKey = identifier + let thumbnailKey = "\(identifier)_thumbnail" - TPPBookCoverRegistryBridge.shared.thumbnailImageForBook(self) { [weak self] image in - guard let self = self else { return } - let final = image ?? UIImage(systemName: "book") + if let img = imageCache.get(for: simpleKey) ?? imageCache.get(for: thumbnailKey) { + thumbnailImage = img + return + } - self.thumbnailImage = final - if let img = final { - self.imageCache.set(img, for: self.identifier) - self.imageCache.set(img, for: thumbnailKey) - if self.coverImage == nil { - self.updateDominantColor(using: img) - } + guard !isThumbnailLoading else { + return + } + isThumbnailLoading = true + + TPPBookCoverRegistryBridge.shared.thumbnailImageForBook(self) { [weak self] image in + guard let self = self else { + return + } + let final = image ?? UIImage(systemName: "book") + + thumbnailImage = final + if let img = final { + imageCache.set(img, for: identifier) + imageCache.set(img, for: thumbnailKey) + if coverImage == nil { + updateDominantColor(using: img) } - self.isThumbnailLoading = false } + isThumbnailLoading = false } - + } + func clearCachedImages() { imageCache.remove(for: identifier) imageCache.remove(for: "\(identifier)_cover") @@ -587,25 +614,30 @@ extension TPPBook { var wrappedCoverImage: UIImage? { coverImage } - + @objc public class func ordinalString(for n: Int) -> String { - return n.ordinal() + n.ordinal() } } // MARK: - Dominant Color (async, off main thread) + private extension TPPBook { func updateDominantColor(using image: UIImage) { let inputImage = image DispatchQueue.global(qos: .userInitiated).async { [weak self] in - guard let self = self else { return } + guard let self = self else { + return + } let ciImage = CIImage(image: inputImage) let filter = CIFilter.areaAverage() filter.inputImage = ciImage filter.extent = ciImage?.extent ?? .zero - guard let outputImage = filter.outputImage else { return } + guard let outputImage = filter.outputImage else { + return + } var bitmap = [UInt8](repeating: 0, count: 4) let context = CIContext(options: [CIContextOption.useSoftwareRenderer: false]) @@ -632,6 +664,8 @@ private extension TPPBook { } } +// MARK: - ReservationDetails + @objcMembers public class ReservationDetails: NSObject { public var holdPosition: Int = 0 @@ -640,6 +674,8 @@ public class ReservationDetails: NSObject { public var copiesAvailable: Int = 0 } +// MARK: - AvailabilityDetails + struct AvailabilityDetails { var availableSince: String? var availableUntil: String? diff --git a/Palace/Book/Models/TPPBookAuthor.swift b/Palace/Book/Models/TPPBookAuthor.swift index d00872315..e97d73492 100644 --- a/Palace/Book/Models/TPPBookAuthor.swift +++ b/Palace/Book/Models/TPPBookAuthor.swift @@ -1,12 +1,11 @@ import Foundation @objcMembers final class TPPBookAuthor: NSObject { - let name: String let relatedBooksURL: URL? init(authorName: String, relatedBooksURL: URL?) { - self.name = authorName + name = authorName self.relatedBooksURL = relatedBooksURL } } diff --git a/Palace/Book/Models/TPPBookContentTypeConverter.swift b/Palace/Book/Models/TPPBookContentTypeConverter.swift index ec1abd8ae..c592e26d9 100644 --- a/Palace/Book/Models/TPPBookContentTypeConverter.swift +++ b/Palace/Book/Models/TPPBookContentTypeConverter.swift @@ -12,15 +12,15 @@ class TPPBookContentTypeConverter: NSObject { @objc class func stringValue(of bookContentType: TPPBookContentType) -> String { switch bookContentType { case .epub: - return "Epub" + "Epub" case .audiobook: - return "AudioBook" + "AudioBook" case .pdf: - return "PDF" + "PDF" case .unsupported: - return "Unsupported" + "Unsupported" default: - return "Unexpected enum value: \(bookContentType.rawValue)" + "Unexpected enum value: \(bookContentType.rawValue)" } } } diff --git a/Palace/Book/Models/TPPBookCoverRegistry.swift b/Palace/Book/Models/TPPBookCoverRegistry.swift index 23a504329..0fed9bd22 100644 --- a/Palace/Book/Models/TPPBookCoverRegistry.swift +++ b/Palace/Book/Models/TPPBookCoverRegistry.swift @@ -1,63 +1,70 @@ import Foundation import UIKit -// MARK: - Swift Concurrency Actor +// MARK: - TPPBookCoverRegistry + actor TPPBookCoverRegistry { let imageCache: ImageCacheType - + static let shared = TPPBookCoverRegistry(imageCache: ImageCache.shared) - + private var inProgressTasks: [URL: Task] = [:] init(imageCache: ImageCacheType) { self.imageCache = imageCache } - + func coverImage(for book: TPPBook) async -> UIImage? { - guard let url = book.imageURL else { return await thumbnailImage(for: book) } + guard let url = book.imageURL else { + return await thumbnailImage(for: book) + } return await fetchImage(from: url, for: book, isCover: true) } - + func thumbnailImage(for book: TPPBook) async -> UIImage? { guard let url = book.imageThumbnailURL else { return await placeholder(for: book) } - + return await fetchImage(from: url, for: book, isCover: false) } - + private func fetchImage(from url: URL, for book: TPPBook, isCover: Bool) async -> UIImage? { let key = cacheKey(for: book, isCover: isCover) if let img = imageCache.get(for: key as String) { return img } - + if let existing = inProgressTasks[url] { return await existing.value } - + let task = Task { [weak self] in - guard let self else { return UIImage() } - + guard let self else { + return UIImage() + } + do { let (data, _) = try await URLSession.shared.data(from: url) - guard let image = UIImage(data: data) else { return nil } - - self.imageCache.set(image, for: key as String, expiresIn: nil) + guard let image = UIImage(data: data) else { + return nil + } + + imageCache.set(image, for: key as String, expiresIn: nil) return image } catch { Log.error(#file, "Failed to fetch image: \(error.localizedDescription)") return nil } } - + inProgressTasks[url] = task let image = await task.value - + inProgressTasks[url] = nil - + return image } - + private func placeholder(for book: TPPBook) async -> UIImage? { await MainActor.run { let size = CGSize(width: 80, height: 120) @@ -76,47 +83,47 @@ actor TPPBookCoverRegistry { } } } - + private func cost(for image: UIImage) -> Int { Int(image.size.width * image.size.height * 4) } - + private func cacheKey(for book: TPPBook, isCover: Bool) -> NSString { NSString(string: "\(book.identifier)_\(isCover ? "cover" : "thumbnail")") } } +// MARK: - TPPBookCoverRegistryBridge -// MARK: - Objective-C Bridge @objcMembers public class TPPBookCoverRegistryBridge: NSObject { public static let shared = TPPBookCoverRegistryBridge() - + /// Asynchronous, Objective-C friendly cover fetch /// - Parameters: /// - book: The TPPBook instance /// - completion: Block called on main thread with the UIImage or nil - @objc public func coverImageForBook(_ book: TPPBook, completion: @escaping (UIImage?) -> Void) { + public func coverImageForBook(_ book: TPPBook, completion: @escaping (UIImage?) -> Void) { Task { let img = await TPPBookCoverRegistry.shared.coverImage(for: book) - DispatchQueue.main.async { + DispatchQueue.main.async { if let img = img { book.imageCache.set(img, for: book.identifier) } - completion(img) + completion(img) } } } - + /// Asynchronous, Objective-C friendly thumbnail fetch - @objc public func thumbnailImageForBook(_ book: TPPBook, completion: @escaping (UIImage?) -> Void) { + public func thumbnailImageForBook(_ book: TPPBook, completion: @escaping (UIImage?) -> Void) { Task { let img = await TPPBookCoverRegistry.shared.thumbnailImage(for: book) - DispatchQueue.main.async { + DispatchQueue.main.async { if let img = img { book.imageCache.set(img, for: book.identifier) } - completion(img) + completion(img) } } } diff --git a/Palace/Book/Models/TPPBookLocation.swift b/Palace/Book/Models/TPPBookLocation.swift index cf8e90a8a..b3d080ee2 100644 --- a/Palace/Book/Models/TPPBookLocation.swift +++ b/Palace/Book/Models/TPPBookLocation.swift @@ -12,18 +12,21 @@ typealias TPPBookLocationData = [String: Any] extension TPPBookLocationData { func string(for key: TPPBookLocationKey) -> String? { - return self[key.rawValue] as? String + self[key.rawValue] as? String } } +// MARK: - TPPBookLocationKey + public enum TPPBookLocationKey: String { - case locationString = "locationString" - case renderer = "renderer" + case locationString + case renderer } +// MARK: - TPPBookLocation + @objcMembers public class TPPBookLocation: NSObject { - /// Due to differences in how different renderers (e.g. Readium, RMSDK, et cetera) want to handle /// location information, it is necessary to store location information in an unstructured manner. /// When creating an instance of this class, |locationString| is the renderer-specific data and @@ -34,11 +37,12 @@ public class TPPBookLocation: NSObject { // Renderer var renderer: String - + init?(locationString: String, renderer: String) { self.locationString = locationString self.renderer = renderer } + init?(dictionary: [String: Any]) { let locationData = dictionary as TPPBookLocationData guard let locationString = locationData.string(for: .locationString), @@ -49,10 +53,11 @@ public class TPPBookLocation: NSObject { self.locationString = locationString self.renderer = renderer } + var dictionaryRepresentation: [String: Any] { - return [ - TPPBookLocationKey.locationString.rawValue: self.locationString, - TPPBookLocationKey.renderer.rawValue: self.renderer + [ + TPPBookLocationKey.locationString.rawValue: locationString, + TPPBookLocationKey.renderer.rawValue: renderer ] } } diff --git a/Palace/Book/Models/TPPBookRegistry.swift b/Palace/Book/Models/TPPBookRegistry.swift index b26baa6fe..6538e00d1 100644 --- a/Palace/Book/Models/TPPBookRegistry.swift +++ b/Palace/Book/Models/TPPBookRegistry.swift @@ -1,7 +1,9 @@ -import Foundation import Combine +import Foundation import UIKit +// MARK: - TPPBookRegistryProvider + protocol TPPBookRegistryProvider { var registryPublisher: AnyPublisher<[String: TPPBookRegistryRecord], Never> { get } var bookStatePublisher: AnyPublisher<(String, TPPBookState), Never> { get } @@ -14,13 +16,24 @@ protocol TPPBookRegistryProvider { func location(forIdentifier identifier: String) -> TPPBookLocation? func add(_ bookmark: TPPReadiumBookmark, forIdentifier identifier: String) func delete(_ bookmark: TPPReadiumBookmark, forIdentifier identifier: String) - func replace(_ oldBookmark: TPPReadiumBookmark, with newBookmark: TPPReadiumBookmark, forIdentifier identifier: String) + func replace( + _ oldBookmark: TPPReadiumBookmark, + with newBookmark: TPPReadiumBookmark, + forIdentifier identifier: String + ) func genericBookmarksForIdentifier(_ bookIdentifier: String) -> [TPPBookLocation] func addOrReplaceGenericBookmark(_ location: TPPBookLocation, forIdentifier bookIdentifier: String) func addGenericBookmark(_ location: TPPBookLocation, forIdentifier bookIdentifier: String) func deleteGenericBookmark(_ location: TPPBookLocation, forIdentifier bookIdentifier: String) func replaceGenericBookmark(_ oldLocation: TPPBookLocation, with newLocation: TPPBookLocation, forIdentifier: String) - func addBook(_ book: TPPBook, location: TPPBookLocation?, state: TPPBookState, fulfillmentId: String?, readiumBookmarks: [TPPReadiumBookmark]?, genericBookmarks: [TPPBookLocation]?) + func addBook( + _ book: TPPBook, + location: TPPBookLocation?, + state: TPPBookState, + fulfillmentId: String?, + readiumBookmarks: [TPPReadiumBookmark]?, + genericBookmarks: [TPPBookLocation]? + ) func removeBook(forIdentifier bookIdentifier: String) func updateAndRemoveBook(_ book: TPPBook) func setState(_ state: TPPBookState, for bookIdentifier: String) @@ -34,37 +47,45 @@ typealias TPPBookRegistryData = [String: Any] extension TPPBookRegistryData { func value(for key: TPPBookRegistryKey) -> Any? { - return self[key.rawValue] + self[key.rawValue] } + mutating func setValue(_ value: Any?, for key: TPPBookRegistryKey) { self[key.rawValue] = value } + func object(for key: TPPBookRegistryKey) -> TPPBookRegistryData? { self[key.rawValue] as? TPPBookRegistryData } + func array(for key: TPPBookRegistryKey) -> [TPPBookRegistryData]? { self[key.rawValue] as? [TPPBookRegistryData] } } +// MARK: - TPPBookRegistryKey + enum TPPBookRegistryKey: String { - case records = "records" + case records case book = "metadata" - case state = "state" - case fulfillmentId = "fulfillmentId" - case location = "location" + case state + case fulfillmentId + case location case readiumBookmarks = "bookmarks" - case genericBookmarks = "genericBookmarks" + case genericBookmarks } -fileprivate class BoolWithDelay { +// MARK: - BoolWithDelay + +private class BoolWithDelay { private var switchBackDelay: Double private var resetTask: DispatchWorkItem? private var onChange: ((_ value: Bool) -> Void)? init(delay: Double = 5, onChange: ((_ value: Bool) -> Void)? = nil) { - self.switchBackDelay = delay + switchBackDelay = delay self.onChange = onChange } + var value: Bool = false { willSet { if value != newValue { @@ -84,12 +105,18 @@ fileprivate class BoolWithDelay { } } +// MARK: - TPPBookRegistry + @objcMembers class TPPBookRegistry: NSObject, TPPBookRegistrySyncing { private let syncQueueKey = DispatchSpecificKey() @objc enum RegistryState: Int { - case unloaded, loading, loaded, syncing, synced + case unloaded + case loading + case loaded + case syncing + case synced } private let registryFolderName = "registry" @@ -105,7 +132,9 @@ class TPPBookRegistry: NSObject, TPPBookRegistrySyncing { private var registry = [String: TPPBookRegistryRecord]() { didSet { DispatchQueue.main.async { [weak self] in - guard let self else { return } + guard let self else { + return + } registrySubject.send(registry) NotificationCenter.default.post(name: .TPPBookRegistryDidChange, object: nil, userInfo: nil) } @@ -122,8 +151,8 @@ class TPPBookRegistry: NSObject, TPPBookRegistrySyncing { static let shared = TPPBookRegistry() private(set) var isSyncing: Bool { - get { return syncState.value } - set { } + get { syncState.value } + set {} } private let registrySubject = CurrentValueSubject<[String: TPPBookRegistryRecord], Never>([:]) @@ -134,6 +163,7 @@ class TPPBookRegistry: NSObject, TPPBookRegistrySyncing { .receive(on: RunLoop.main) .eraseToAnyPublisher() } + var bookStatePublisher: AnyPublisher<(String, TPPBookState), Never> { bookStateSubject .receive(on: RunLoop.main) @@ -161,7 +191,7 @@ class TPPBookRegistry: NSObject, TPPBookRegistrySyncing { /// TPPBookRegistry is a shared object, this value is used to cancel synchronisation callback when the user changes library account. private var syncUrl: URL? - private override init() { + override private init() { super.init() syncQueue.setSpecific(key: syncQueueKey, value: ()) } @@ -176,26 +206,31 @@ class TPPBookRegistry: NSObject, TPPBookRegistrySyncing { } func registryUrl(for account: String) -> URL? { - return TPPBookContentMetadataFilesHelper.directory(for: account)? + TPPBookContentMetadataFilesHelper.directory(for: account)? .appendingPathComponent(registryFolderName) .appendingPathComponent(registryFileName) } func load(account: String? = nil) { guard let account = account ?? AccountsManager.shared.currentAccountId, - let url = registryUrl(for: account) - else { return } + let url = registryUrl(for: account) + else { + return + } DispatchQueue.main.async { self.state = .loading } syncQueue.async(flags: .barrier) { - var newRegistry = [String:TPPBookRegistryRecord]() + var newRegistry = [String: TPPBookRegistryRecord]() if FileManager.default.fileExists(atPath: url.path), - let data = try? Data(contentsOf: url), - let json = try? JSONSerialization.jsonObject(with: data) as? TPPBookRegistryData, - let records = json.array(for: .records) { + let data = try? Data(contentsOf: url), + let json = try? JSONSerialization.jsonObject(with: data) as? TPPBookRegistryData, + let records = json.array(for: .records) + { for obj in records { - guard var record = TPPBookRegistryRecord(record: obj) else { continue } + guard var record = TPPBookRegistryRecord(record: obj) else { + continue + } if record.state == .downloading || record.state == .SAMLStarted { record.state = .downloadFailed } @@ -229,7 +264,9 @@ class TPPBookRegistry: NSObject, TPPBookRegistrySyncing { } func sync(completion: ((_ errorDocument: [AnyHashable: Any]?, _ newBooks: Bool) -> Void)? = nil) { - guard let loansUrl = AccountsManager.shared.currentAccount?.loansUrl else { return } + guard let loansUrl = AccountsManager.shared.currentAccount?.loansUrl else { + return + } if state == .syncing { return @@ -238,68 +275,78 @@ class TPPBookRegistry: NSObject, TPPBookRegistrySyncing { state = .syncing syncUrl = loansUrl - TPPOPDSFeed.withURL(loansUrl, shouldResetCache: true, useTokenIfAvailable: true) { [weak self] feed, errorDocument in - DispatchQueue.main.async { [weak self] in - guard let self = self else { return } - if self.syncUrl != loansUrl { return } + TPPOPDSFeed + .withURL(loansUrl, shouldResetCache: true, useTokenIfAvailable: true) { [weak self] feed, errorDocument in + DispatchQueue.main.async { [weak self] in + guard let self = self else { + return + } + if syncUrl != loansUrl { + return + } - if let errorDocument = errorDocument { - self.state = .loaded - self.syncUrl = nil - completion?(errorDocument, false) - return - } + if let errorDocument = errorDocument { + state = .loaded + syncUrl = nil + completion?(errorDocument, false) + return + } - guard let feed = feed else { - self.state = .loaded - self.syncUrl = nil - completion?(nil, false) - return - } + guard let feed = feed else { + state = .loaded + syncUrl = nil + completion?(nil, false) + return + } - var changesMade = false - self.syncQueue.sync { - var recordsToDelete = Set(self.registry.keys) - for entry in feed.entries { - guard let opdsEntry = entry as? TPPOPDSEntry, - let book = TPPBook(entry: opdsEntry) - else { continue } - recordsToDelete.remove(book.identifier) - - if self.registry[book.identifier] != nil { - self.updateBook(book) - changesMade = true - } else { - self.addBook(book) - changesMade = true + var changesMade = false + syncQueue.sync { + var recordsToDelete = Set(self.registry.keys) + for entry in feed.entries { + guard let opdsEntry = entry as? TPPOPDSEntry, + let book = TPPBook(entry: opdsEntry) + else { + continue + } + recordsToDelete.remove(book.identifier) + + if self.registry[book.identifier] != nil { + self.updateBook(book) + changesMade = true + } else { + self.addBook(book) + changesMade = true + } } - } - recordsToDelete.forEach { identifier in - if let recordState = self.registry[identifier]?.state, - recordState == .downloadSuccessful || recordState == .used { - MyBooksDownloadCenter.shared.deleteLocalContent(for: identifier) + recordsToDelete.forEach { identifier in + if let recordState = self.registry[identifier]?.state, + recordState == .downloadSuccessful || recordState == .used + { + MyBooksDownloadCenter.shared.deleteLocalContent(for: identifier) + } + self.registry[identifier]?.state = .unregistered + self.removeBook(forIdentifier: identifier) + changesMade = true } - self.registry[identifier]?.state = .unregistered - self.removeBook(forIdentifier: identifier) - changesMade = true + self.save() } - self.save() - } - self.state = .synced - self.syncUrl = nil - completion?(nil, changesMade) + state = .synced + syncUrl = nil + completion?(nil, changesMade) + } } - } } private func save() { guard let account = AccountsManager.shared.currentAccount?.uuid, let registryUrl = registryUrl(for: account) - else { return } + else { + return + } let snapshot: [[String: Any]] = performSync { - self.registry.values.map { $0.dictionaryRepresentation } + self.registry.values.map(\.dictionaryRepresentation) } let registryObject = [TPPBookRegistryKey.records.rawValue: snapshot] @@ -325,24 +372,24 @@ class TPPBookRegistry: NSObject, TPPBookRegistrySyncing { private func performSync(_ block: () -> T) -> T { if DispatchQueue.getSpecific(key: syncQueueKey) != nil { - return block() + block() } else { - return syncQueue.sync { block() } + syncQueue.sync { block() } } } var allBooks: [TPPBook] { - return performSync { - registry.values.filter { TPPBookStateHelper.allBookStates().contains($0.state.rawValue) }.map { $0.book } + performSync { + registry.values.filter { TPPBookStateHelper.allBookStates().contains($0.state.rawValue) }.map(\.book) } } var heldBooks: [TPPBook] { - return performSync { + performSync { registry - .map { $0.value } + .map(\.value) .filter { $0.state == .holding } - .map { $0.book } + .map(\.book) } } @@ -352,75 +399,83 @@ class TPPBookRegistry: NSObject, TPPBookRegistrySyncing { ] return performSync { registry - .map { $0.value } + .map(\.value) .filter { matchingStates.contains($0.state) } - .map { $0.book } + .map(\.book) } } - + /// Adds a book to the book registry until it is manually removed. It allows the application to /// present information about obtained books when offline. Attempting to add a book already present /// will overwrite the existing book as if `updateBook` were called. The location may be nil. The /// state provided must be one of `TPPBookState` and must not be `TPPBookState.unregistered`. - func addBook(_ book: TPPBook, - location: TPPBookLocation? = nil, - state: TPPBookState = .downloadNeeded, - fulfillmentId: String? = nil, - readiumBookmarks: [TPPReadiumBookmark]? = nil, - genericBookmarks: [TPPBookLocation]? = nil) { - TPPBookCoverRegistryBridge.shared.thumbnailImageForBook(book) { _ in } - + func addBook( + _ book: TPPBook, + location: TPPBookLocation? = nil, + state: TPPBookState = .downloadNeeded, + fulfillmentId: String? = nil, + readiumBookmarks: [TPPReadiumBookmark]? = nil, + genericBookmarks: [TPPBookLocation]? = nil + ) { + TPPBookCoverRegistryBridge.shared.thumbnailImageForBook(book) { _ in } + syncQueue.async(flags: .barrier) { [weak self] in - guard let self else { return } - self.registry[book.identifier] = TPPBookRegistryRecord( - book: book, - location: location, - state: state, - fulfillmentId: fulfillmentId, - readiumBookmarks: readiumBookmarks, - genericBookmarks: genericBookmarks - ) - self.save() - DispatchQueue.main.async { - self.registrySubject.send(self.registry) + guard let self else { + return + } + registry[book.identifier] = TPPBookRegistryRecord( + book: book, + location: location, + state: state, + fulfillmentId: fulfillmentId, + readiumBookmarks: readiumBookmarks, + genericBookmarks: genericBookmarks + ) + save() + DispatchQueue.main.async { + self.registrySubject.send(self.registry) } } } - func updateAndRemoveBook(_ book: TPPBook) { - TPPBookCoverRegistryBridge.shared.thumbnailImageForBook(book) { _ in } + func updateAndRemoveBook(_ book: TPPBook) { + TPPBookCoverRegistryBridge.shared.thumbnailImageForBook(book) { _ in } - syncQueue.async(flags: .barrier) { [weak self] in - guard let self, let record = self.registry[book.identifier] else { return } - record.book = book - record.state = .unregistered - self.save() + syncQueue.async(flags: .barrier) { [weak self] in + guard let self, let record = registry[book.identifier] else { + return } + record.book = book + record.state = .unregistered + save() } + } - func removeBook(forIdentifier bookIdentifier: String) { - guard !bookIdentifier.isEmpty else { - Log.error(#file, "removeBook called with empty bookIdentifier") - return - } - - syncQueue.async(flags: .barrier) { - let removedBook = self.registry[bookIdentifier]?.book - self.registry.removeValue(forKey: bookIdentifier) - self.save() - DispatchQueue.main.async { - self.registrySubject.send(self.registry) - if let book = removedBook { - TPPBookCoverRegistryBridge.shared.thumbnailImageForBook(book) { _ in } - } + func removeBook(forIdentifier bookIdentifier: String) { + guard !bookIdentifier.isEmpty else { + Log.error(#file, "removeBook called with empty bookIdentifier") + return + } + + syncQueue.async(flags: .barrier) { + let removedBook = self.registry[bookIdentifier]?.book + self.registry.removeValue(forKey: bookIdentifier) + self.save() + DispatchQueue.main.async { + self.registrySubject.send(self.registry) + if let book = removedBook { + TPPBookCoverRegistryBridge.shared.thumbnailImageForBook(book) { _ in } } } } - + } + func updateBook(_ book: TPPBook) { syncQueue.async(flags: .barrier) { [weak self] in - guard let self, let record = self.registry[book.identifier] else { return } - + guard let self, let record = registry[book.identifier] else { + return + } + var nextState = record.state if record.state == .unregistered { book.defaultAcquisition?.availability.matchUnavailable( @@ -433,7 +488,7 @@ class TPPBookRegistry: NSObject, TPPBookRegistrySyncing { } TPPUserNotifications.compareAvailability(cachedRecord: record, andNewBook: book) - self.registry[book.identifier] = TPPBookRegistryRecord( + registry[book.identifier] = TPPBookRegistryRecord( book: book, location: record.location, state: nextState, @@ -450,7 +505,9 @@ class TPPBookRegistry: NSObject, TPPBookRegistrySyncing { func updatedBookMetadata(_ book: TPPBook) -> TPPBook? { performSync { - guard let bookRecord = self.registry[book.identifier] else { return nil } + guard let bookRecord = self.registry[book.identifier] else { + return nil + } let updatedBook = bookRecord.book.bookWithMetadata(from: book) self.registry[book.identifier]?.book = updatedBook self.save() @@ -459,7 +516,9 @@ class TPPBookRegistry: NSObject, TPPBookRegistrySyncing { } func state(for bookIdentifier: String?) -> TPPBookState { - guard let bookIdentifier = bookIdentifier, !bookIdentifier.isEmpty else { return .unregistered } + guard let bookIdentifier = bookIdentifier, !bookIdentifier.isEmpty else { + return .unregistered + } return performSync { self.registry[bookIdentifier]?.state ?? .unregistered } @@ -467,11 +526,13 @@ class TPPBookRegistry: NSObject, TPPBookRegistrySyncing { func setState(_ state: TPPBookState, for bookIdentifier: String) { syncQueue.async(flags: .barrier) { [weak self] in - guard let self else { return } + guard let self else { + return + } - self.registry[bookIdentifier]?.state = state - self.postStateNotification(bookIdentifier: bookIdentifier, state: state) - self.save() + registry[bookIdentifier]?.state = state + postStateNotification(bookIdentifier: bookIdentifier, state: state) + save() DispatchQueue.main.async { self.bookStateSubject.send((bookIdentifier, state)) @@ -495,34 +556,46 @@ class TPPBookRegistry: NSObject, TPPBookRegistrySyncing { /// Returns the book for a given identifier if it is registered, else nil. func book(forIdentifier bookIdentifier: String?) -> TPPBook? { - guard let bookIdentifier = bookIdentifier, !bookIdentifier.isEmpty else { return nil } - guard let record = performSync({ self.registry[bookIdentifier] }) else { return nil } + guard let bookIdentifier = bookIdentifier, !bookIdentifier.isEmpty else { + return nil + } + guard let record = performSync({ self.registry[bookIdentifier] }) else { + return nil + } return record.book } func setFulfillmentId(_ fulfillmentId: String, for bookIdentifier: String) { syncQueue.async(flags: .barrier) { [weak self] in - guard let self else { return } + guard let self else { + return + } - self.registry[bookIdentifier]?.fulfillmentId = fulfillmentId - self.save() + registry[bookIdentifier]?.fulfillmentId = fulfillmentId + save() } } func fulfillmentId(forIdentifier bookIdentifier: String?) -> String? { - guard let bookIdentifier = bookIdentifier, !bookIdentifier.isEmpty else { return nil } - guard let record = performSync({ self.registry[bookIdentifier] }) else { return nil } + guard let bookIdentifier = bookIdentifier, !bookIdentifier.isEmpty else { + return nil + } + guard let record = performSync({ self.registry[bookIdentifier] }) else { + return nil + } return record.fulfillmentId } func setProcessing(_ processing: Bool, for bookIdentifier: String) { syncQueue.async(flags: .barrier) { [weak self] in - guard let self else { return } + guard let self else { + return + } if processing { - self.processingIdentifiers.insert(bookIdentifier) + processingIdentifiers.insert(bookIdentifier) } else { - self.processingIdentifiers.remove(bookIdentifier) + processingIdentifiers.remove(bookIdentifier) } DispatchQueue.main.async { NotificationCenter.default.post(name: .TPPBookProcessingDidChange, object: nil, userInfo: [ @@ -534,7 +607,7 @@ class TPPBookRegistry: NSObject, TPPBookRegistrySyncing { } func processing(forIdentifier bookIdentifier: String) -> Bool { - return performSync { + performSync { self.processingIdentifiers.contains(bookIdentifier) } } @@ -545,142 +618,170 @@ class TPPBookRegistry: NSObject, TPPBookRegistrySyncing { return book.imageCache.get(for: simpleKey) ?? book.imageCache.get(for: thumbnailKey) } - func thumbnailImage( - for book: TPPBook?, - handler: @escaping (_ image: UIImage?) -> Void - ) { - guard let book = book else { - handler(nil) - return - } - + func thumbnailImage( + for book: TPPBook?, + handler: @escaping (_ image: UIImage?) -> Void + ) { + guard let book = book else { + handler(nil) + return + } + + TPPBookCoverRegistryBridge + .shared + .thumbnailImageForBook(book, completion: handler) + } + + func thumbnailImages( + forBooks books: Set, + handler: @escaping (_ bookIdentifiersToImages: [String: UIImage]) -> Void + ) { + let group = DispatchGroup() + var result = [String: UIImage]() + + for book in books { + group.enter() TPPBookCoverRegistryBridge .shared - .thumbnailImageForBook(book, completion: handler) - } - - func thumbnailImages( - forBooks books: Set, - handler: @escaping (_ bookIdentifiersToImages: [String: UIImage]) -> Void - ) { - let group = DispatchGroup() - var result = [String: UIImage]() - - for book in books { - group.enter() - TPPBookCoverRegistryBridge - .shared - .thumbnailImageForBook(book) { image in - if let img = image { - result[book.identifier] = img - } - group.leave() + .thumbnailImageForBook(book) { image in + if let img = image { + result[book.identifier] = img } - } - - group.notify(queue: .main) { - handler(result) - } + group.leave() + } } - /// Single‐book cover (with thumbnail fallback inside bridge) - func coverImage( - for book: TPPBook, - handler: @escaping (_ image: UIImage?) -> Void - ) { - TPPBookCoverRegistryBridge - .shared - .coverImageForBook(book, completion: handler) + group.notify(queue: .main) { + handler(result) } + } + + /// Single‐book cover (with thumbnail fallback inside bridge) + func coverImage( + for book: TPPBook, + handler: @escaping (_ image: UIImage?) -> Void + ) { + TPPBookCoverRegistryBridge + .shared + .coverImageForBook(book, completion: handler) + } } +// MARK: TPPBookRegistryProvider + extension TPPBookRegistry: TPPBookRegistryProvider { func setLocation(_ location: TPPBookLocation?, forIdentifier bookIdentifier: String) { - guard !bookIdentifier.isEmpty else { return } + guard !bookIdentifier.isEmpty else { + return + } syncQueue.async(flags: .barrier) { [weak self] in - guard let self else { return } + guard let self else { + return + } - self.registry[bookIdentifier]?.location = location - self.save() + registry[bookIdentifier]?.location = location + save() } } func location(forIdentifier bookIdentifier: String) -> TPPBookLocation? { - return performSync { + performSync { self.registry[bookIdentifier]?.location } } func readiumBookmarks(forIdentifier bookIdentifier: String) -> [TPPReadiumBookmark] { - return performSync { - guard let record = self.registry[bookIdentifier] else { return [] } + performSync { + guard let record = self.registry[bookIdentifier] else { + return [] + } return record.readiumBookmarks?.sorted { $0.progressWithinBook < $1.progressWithinBook } ?? [] } } func add(_ bookmark: TPPReadiumBookmark, forIdentifier bookIdentifier: String) { syncQueue.async(flags: .barrier) { [weak self] in - guard let self else { return } + guard let self else { + return + } - guard self.registry[bookIdentifier] != nil else { return } - if self.registry[bookIdentifier]?.readiumBookmarks == nil { - self.registry[bookIdentifier]?.readiumBookmarks = [TPPReadiumBookmark]() + guard registry[bookIdentifier] != nil else { + return } - self.registry[bookIdentifier]?.readiumBookmarks?.append(bookmark) - self.save() + if registry[bookIdentifier]?.readiumBookmarks == nil { + registry[bookIdentifier]?.readiumBookmarks = [TPPReadiumBookmark]() + } + registry[bookIdentifier]?.readiumBookmarks?.append(bookmark) + save() } } func delete(_ bookmark: TPPReadiumBookmark, forIdentifier bookIdentifier: String) { syncQueue.async(flags: .barrier) { [weak self] in - guard let self else { return } + guard let self else { + return + } - self.registry[bookIdentifier]?.readiumBookmarks?.removeAll { $0 == bookmark } - self.save() + registry[bookIdentifier]?.readiumBookmarks?.removeAll { $0 == bookmark } + save() } } - func replace(_ oldBookmark: TPPReadiumBookmark, with newBookmark: TPPReadiumBookmark, forIdentifier bookIdentifier: String) { + func replace( + _ oldBookmark: TPPReadiumBookmark, + with newBookmark: TPPReadiumBookmark, + forIdentifier bookIdentifier: String + ) { syncQueue.async(flags: .barrier) { [weak self] in - guard let self else { return } + guard let self else { + return + } - self.registry[bookIdentifier]?.readiumBookmarks?.removeAll { $0 == oldBookmark } - self.registry[bookIdentifier]?.readiumBookmarks?.append(newBookmark) - self.save() + registry[bookIdentifier]?.readiumBookmarks?.removeAll { $0 == oldBookmark } + registry[bookIdentifier]?.readiumBookmarks?.append(newBookmark) + save() } } func genericBookmarksForIdentifier(_ bookIdentifier: String) -> [TPPBookLocation] { - return performSync { + performSync { self.registry[bookIdentifier]?.genericBookmarks ?? [] } } func addOrReplaceGenericBookmark(_ location: TPPBookLocation, forIdentifier bookIdentifier: String) { syncQueue.async(flags: .barrier) { [weak self] in - guard let self else { return } + guard let self else { + return + } - guard self.registry[bookIdentifier] != nil else { return } - if self.registry[bookIdentifier]?.genericBookmarks == nil { - self.registry[bookIdentifier]?.genericBookmarks = [TPPBookLocation]() + guard registry[bookIdentifier] != nil else { + return } - self.deleteGenericBookmark(location, forIdentifier: bookIdentifier) - self.addGenericBookmark(location, forIdentifier: bookIdentifier) - self.save() + if registry[bookIdentifier]?.genericBookmarks == nil { + registry[bookIdentifier]?.genericBookmarks = [TPPBookLocation]() + } + deleteGenericBookmark(location, forIdentifier: bookIdentifier) + addGenericBookmark(location, forIdentifier: bookIdentifier) + save() } } func addGenericBookmark(_ location: TPPBookLocation, forIdentifier bookIdentifier: String) { syncQueue.async(flags: .barrier) { [weak self] in - guard let self else { return } + guard let self else { + return + } - guard self.registry[bookIdentifier] != nil else { return } - if self.registry[bookIdentifier]?.genericBookmarks == nil { - self.registry[bookIdentifier]?.genericBookmarks = [TPPBookLocation]() + guard registry[bookIdentifier] != nil else { + return } - - self.registry[bookIdentifier]?.genericBookmarks?.append(location) - self.save() + if registry[bookIdentifier]?.genericBookmarks == nil { + registry[bookIdentifier]?.genericBookmarks = [TPPBookLocation]() + } + + registry[bookIdentifier]?.genericBookmarks?.append(location) + save() } } @@ -691,12 +792,18 @@ extension TPPBookRegistry: TPPBookRegistryProvider { } } - func replaceGenericBookmark(_ oldLocation: TPPBookLocation, with newLocation: TPPBookLocation, forIdentifier bookIdentifier: String) { + func replaceGenericBookmark( + _ oldLocation: TPPBookLocation, + with newLocation: TPPBookLocation, + forIdentifier bookIdentifier: String + ) { syncQueue.async(flags: .barrier) { [weak self] in - guard let self else { return } + guard let self else { + return + } - self.deleteGenericBookmark(oldLocation, forIdentifier: bookIdentifier) - self.addGenericBookmark(newLocation, forIdentifier: bookIdentifier) + deleteGenericBookmark(oldLocation, forIdentifier: bookIdentifier) + addGenericBookmark(newLocation, forIdentifier: bookIdentifier) } } } @@ -705,14 +812,17 @@ extension TPPBookLocation { func locationStringDictionary() -> [String: Any]? { guard let data = locationString.data(using: .utf8), let dictionary = try? JSONSerialization.jsonObject(with: data, options: .allowFragments) as? [String: Any] - else { return nil } + else { + return nil + } return dictionary } func isSimilarTo(_ location: TPPBookLocation) -> Bool { guard renderer == location.renderer, let locationDict = locationStringDictionary(), - let otherLocationDict = location.locationStringDictionary() else { + let otherLocationDict = location.locationStringDictionary() + else { return false } let excludedKeys = ["timeStamp", "annotationId"] diff --git a/Palace/Book/Models/TPPBookRegistryRecord.swift b/Palace/Book/Models/TPPBookRegistryRecord.swift index 0d639f2cc..dfd6f1478 100644 --- a/Palace/Book/Models/TPPBookRegistryRecord.swift +++ b/Palace/Book/Models/TPPBookRegistryRecord.swift @@ -8,7 +8,6 @@ import Foundation - /// An element of `TPPBookRegistry` @objcMembers class TPPBookRegistryRecord: NSObject { @@ -18,8 +17,15 @@ class TPPBookRegistryRecord: NSObject { var fulfillmentId: String? var readiumBookmarks: [TPPReadiumBookmark]? var genericBookmarks: [TPPBookLocation]? - - init(book: TPPBook, location: TPPBookLocation? = nil, state: TPPBookState, fulfillmentId: String? = nil, readiumBookmarks: [TPPReadiumBookmark]? = [], genericBookmarks: [TPPBookLocation]? = []) { + + init( + book: TPPBook, + location: TPPBookLocation? = nil, + state: TPPBookState, + fulfillmentId: String? = nil, + readiumBookmarks: [TPPReadiumBookmark]? = [], + genericBookmarks: [TPPBookLocation]? = [] + ) { self.book = book self.location = location self.state = state @@ -31,11 +37,8 @@ class TPPBookRegistryRecord: NSObject { if let defaultAcquisition = book.defaultAcquisition { defaultAcquisition.availability.matchUnavailable { _ in - } limited: { _ in - } unlimited: { _ in - } reserved: { [weak self] _ in self?.state = .holding } ready: { [weak self] _ in @@ -53,38 +56,41 @@ class TPPBookRegistryRecord: NSObject { self.state = .unsupported } } - + init?(record: TPPBookRegistryData) { guard let bookObject = record.object(for: .book), let book = TPPBook(dictionary: bookObject), let stateString = record.value(for: .state) as? String, let state = TPPBookState(stateString) - + else { return nil } self.book = book self.state = state - self.fulfillmentId = record.value(for: .fulfillmentId) as? String + fulfillmentId = record.value(for: .fulfillmentId) as? String if let location = record.object(for: .location) { - self.location = TPPBookLocation(dictionary: location) + self.location = TPPBookLocation(dictionary: location) } if let recordReadiumBookmarks = record.array(for: .readiumBookmarks) { - self.readiumBookmarks = recordReadiumBookmarks.compactMap { TPPReadiumBookmark(dictionary: $0 as NSDictionary) } + readiumBookmarks = recordReadiumBookmarks.compactMap { TPPReadiumBookmark(dictionary: $0 as NSDictionary) } } if let recordGenericBookmarks = record.array(for: .genericBookmarks) { - self.genericBookmarks = recordGenericBookmarks.compactMap { TPPBookLocation(dictionary: $0) } + genericBookmarks = recordGenericBookmarks.compactMap { TPPBookLocation(dictionary: $0) } } } - + var dictionaryRepresentation: [String: Any] { var dictionary = TPPBookRegistryData() dictionary.setValue(book.dictionaryRepresentation(), for: .book) dictionary.setValue(state.stringValue(), for: .state) dictionary.setValue(fulfillmentId, for: .fulfillmentId) - dictionary.setValue(self.location?.dictionaryRepresentation, for: .location) - dictionary.setValue(readiumBookmarks?.compactMap { $0.dictionaryRepresentation as? [String: Any] }, for: .readiumBookmarks) - dictionary.setValue(genericBookmarks?.map { $0.dictionaryRepresentation }, for: .genericBookmarks) + dictionary.setValue(location?.dictionaryRepresentation, for: .location) + dictionary.setValue( + readiumBookmarks?.compactMap { $0.dictionaryRepresentation as? [String: Any] }, + for: .readiumBookmarks + ) + dictionary.setValue(genericBookmarks?.map(\.dictionaryRepresentation), for: .genericBookmarks) return dictionary } } diff --git a/Palace/Book/Models/TPPBookState.swift b/Palace/Book/Models/TPPBookState.swift index 07c09fd02..9bc958bf4 100644 --- a/Palace/Book/Models/TPPBookState.swift +++ b/Palace/Book/Models/TPPBookState.swift @@ -11,7 +11,9 @@ let UnsupportedKey = "unsupported" let ReturningKey = "returning" let SAMLStartedKey = "saml-started" -@objc public enum TPPBookState : Int, CaseIterable { +// MARK: - TPPBookState + +@objc public enum TPPBookState: Int, CaseIterable { case unregistered = 0 case downloadNeeded = 1 case downloading @@ -26,63 +28,65 @@ let SAMLStartedKey = "saml-started" init?(_ stringValue: String) { switch stringValue { - case DownloadingKey: - self = .downloading - case DownloadFailedKey: - self = .downloadFailed - case DownloadNeededKey: - self = .downloadNeeded - case DownloadSuccessfulKey: - self = .downloadSuccessful - case UnregisteredKey: - self = .unregistered - case HoldingKey: - self = .holding - case UsedKey: - self = .used - case UnsupportedKey: - self = .unsupported - case SAMLStartedKey: - self = .SAMLStarted - default: - return nil + case DownloadingKey: + self = .downloading + case DownloadFailedKey: + self = .downloadFailed + case DownloadNeededKey: + self = .downloadNeeded + case DownloadSuccessfulKey: + self = .downloadSuccessful + case UnregisteredKey: + self = .unregistered + case HoldingKey: + self = .holding + case UsedKey: + self = .used + case UnsupportedKey: + self = .unsupported + case SAMLStartedKey: + self = .SAMLStarted + default: + return nil } } - + func stringValue() -> String { switch self { - case .downloading: - return DownloadingKey; - case .downloadFailed: - return DownloadFailedKey; - case .downloadNeeded: - return DownloadNeededKey; - case .downloadSuccessful: - return DownloadSuccessfulKey; - case .unregistered: - return UnregisteredKey; - case .holding: - return HoldingKey; - case .used: - return UsedKey; - case .unsupported: - return UnsupportedKey; - case .returning: - return ReturningKey + case .downloading: + DownloadingKey + case .downloadFailed: + DownloadFailedKey + case .downloadNeeded: + DownloadNeededKey + case .downloadSuccessful: + DownloadSuccessfulKey + case .unregistered: + UnregisteredKey + case .holding: + HoldingKey + case .used: + UsedKey + case .unsupported: + UnsupportedKey + case .returning: + ReturningKey case .SAMLStarted: - return SAMLStartedKey; + SAMLStartedKey } } } +// MARK: - TPPBookStateHelper + // For Objective-C, since Obj-C enum is not allowed to have methods // TODO: Remove when migration to Swift completed -class TPPBookStateHelper : NSObject { +class TPPBookStateHelper: NSObject { @objc(stringValueFromBookState:) static func stringValue(from state: TPPBookState) -> String { - return state.stringValue() + state.stringValue() } - + @objc(bookStateFromString:) static func bookState(fromString string: String) -> NSNumber? { guard let state = TPPBookState(string) else { @@ -91,9 +95,8 @@ class TPPBookStateHelper : NSObject { return NSNumber(integerLiteral: state.rawValue) } - + @objc static func allBookStates() -> [TPPBookState.RawValue] { - return TPPBookState.allCases.map{ $0.rawValue } + TPPBookState.allCases.map(\.rawValue) } } - diff --git a/Palace/Book/Models/TPPContentType.swift b/Palace/Book/Models/TPPContentType.swift index 734e38051..c5a488d1d 100644 --- a/Palace/Book/Models/TPPContentType.swift +++ b/Palace/Book/Models/TPPContentType.swift @@ -11,12 +11,12 @@ case audiobook case pdf case unsupported - + static func from(mimeType: String?) -> TPPBookContentType { guard let mimeType = mimeType else { return .unsupported } - + if TPPOPDSAcquisitionPath.audiobookTypes().contains(mimeType) { return .audiobook } else if mimeType == ContentTypeEpubZip || mimeType == ContentTypeOctetStream { diff --git a/Palace/Book/UI/AudiobookSampleToolbar.swift b/Palace/Book/UI/AudiobookSampleToolbar.swift index e0bdee709..c46960aed 100644 --- a/Palace/Book/UI/AudiobookSampleToolbar.swift +++ b/Palace/Book/UI/AudiobookSampleToolbar.swift @@ -6,8 +6,10 @@ // Copyright © 2022 The Palace Project. All rights reserved. // -import SwiftUI import PalaceUIKit +import SwiftUI + +// MARK: - AudiobookSampleToolbar struct AudiobookSampleToolbar: View { typealias Images = ImageProviders.AudiobookSampleToolbar @@ -23,7 +25,9 @@ struct AudiobookSampleToolbar: View { init?(book: TPPBook) { self.book = book - guard let sample = book.sample as? AudiobookSample else { return nil } + guard let sample = book.sample as? AudiobookSample else { + return nil + } player = AudiobookSamplePlayer(sample: sample) if let imageURL = book.imageThumbnailURL ?? book.imageURL { imageLoader.loadImage(url: imageURL) @@ -39,7 +43,7 @@ struct AudiobookSampleToolbar: View { } .frame(height: toolbarHeight) .padding(toolbarPadding) - .background(Color.init(.lightGray)) + .background(Color(.lightGray)) .onDisappear { player.pauseAudiobook() } @@ -127,8 +131,9 @@ struct AudiobookSampleToolbar: View { } } -@objc class AudiobookSampleToolbarWrapper: NSObject { +// MARK: - AudiobookSampleToolbarWrapper +@objc class AudiobookSampleToolbarWrapper: NSObject { @objc static func create(book: TPPBook) -> UIViewController { let toolbar = AudiobookSampleToolbar(book: book) let hostingController = UIHostingController(rootView: toolbar) @@ -141,7 +146,7 @@ private extension TimeInterval { let ti = NSInteger(self) let seconds = ti % 60 let minutes = (ti / 60) % 60 - + return "\(minutes)m \(seconds)s left" } } diff --git a/Palace/Book/UI/BookDetail/BookButtonMapper.swift b/Palace/Book/UI/BookDetail/BookButtonMapper.swift index d27fe08f9..513c63094 100644 --- a/Palace/Book/UI/BookDetail/BookButtonMapper.swift +++ b/Palace/Book/UI/BookDetail/BookButtonMapper.swift @@ -9,8 +9,7 @@ import Foundation -struct BookButtonMapper { - +enum BookButtonMapper { /// First look at registryState. If that alone dictates a clear UI state, /// return it. Otherwise fall back to OPDS availability via `stateForAvailability(_)`. static func map( diff --git a/Palace/Book/UI/BookDetail/BookDetailView.swift b/Palace/Book/UI/BookDetail/BookDetailView.swift index 2272583c5..a0c61b580 100644 --- a/Palace/Book/UI/BookDetail/BookDetailView.swift +++ b/Palace/Book/UI/BookDetail/BookDetailView.swift @@ -1,10 +1,12 @@ import SwiftUI import UIKit +// MARK: - BookDetailView + struct BookDetailView: View { @Environment(\.presentationMode) var presentationMode @Environment(\.colorScheme) private var colorScheme - + private var coordinator: NavigationCoordinator? { NavigationCoordinatorHub.shared.coordinator } @@ -24,8 +26,8 @@ struct BookDetailView: View { @State private var dragOffset: CGFloat = 0 @State private var imageBottomPosition: CGFloat = 400 @State private var pulseSkeleton: Bool = false - @State private var lastBookIdentifier: String? = nil - + @State private var lastBookIdentifier: String? + private let scaleAnimation = Animation.linear(duration: 0.35) @State private var headerColor: Color = .white @@ -34,11 +36,11 @@ struct BookDetailView: View { private let minHeaderHeight: CGFloat = 80 private let imageTopPadding: CGFloat = 80 private let dampingFactor: CGFloat = 0.95 - + init(book: TPPBook) { - self.viewModel = BookDetailViewModel(book: book) + viewModel = BookDetailViewModel(book: book) } - + var body: some View { ZStack(alignment: .top) { ScrollViewReader { proxy in @@ -51,7 +53,7 @@ struct BookDetailView: View { Spacer() } } - + mainView .padding(.bottom, 100) .background(GeometryReader { proxy in @@ -68,11 +70,15 @@ struct BookDetailView: View { lastBookIdentifier = newIdentifier resetSampleToolbar() let newSummary = viewModel.book.summary ?? "" - if self.descriptionText != newSummary { self.descriptionText = newSummary } + if descriptionText != newSummary { + descriptionText = newSummary + } proxy.scrollTo(0, anchor: .top) } else { - let newSummary = viewModel.book.summary ?? self.descriptionText - if self.descriptionText != newSummary { self.descriptionText = newSummary } + let newSummary = viewModel.book.summary ?? descriptionText + if descriptionText != newSummary { + descriptionText = newSummary + } } } } @@ -82,7 +88,7 @@ struct BookDetailView: View { headerHeight = viewModel.isFullSize ? 300 : 225 viewModel.fetchRelatedBooks() - self.descriptionText = viewModel.book.summary ?? "" + descriptionText = viewModel.book.summary ?? "" withAnimation(.easeInOut(duration: 0.9).repeatForever(autoreverses: true)) { pulseSkeleton = true } @@ -93,14 +99,18 @@ struct BookDetailView: View { .onReceive(viewModel.book.$dominantUIColor) { newColor in headerColor = Color(newColor) } - .onReceive(NotificationCenter.default.publisher(for: .TPPBookRegistryStateDidChange).receive(on: RunLoop.main)) { note in + .onReceive(NotificationCenter.default.publisher(for: .TPPBookRegistryStateDidChange) + .receive(on: RunLoop.main) + ) { note in guard let info = note.userInfo as? [String: Any], let identifier = info["bookIdentifier"] as? String, identifier == viewModel.book.identifier, let raw = info["state"] as? Int, let newState = TPPBookState(rawValue: raw) - else { return } + else { + return + } if newState == .unregistered { if let coordinator = coordinator { @@ -122,36 +132,45 @@ struct BookDetailView: View { } .presentationDetents([.height(0), .height(300)]) .navigationBarHidden(true) - + if !viewModel.isFullSize { backgroundView .frame(height: headerHeight) .animation(scaleAnimation, value: headerHeight) - + imageView .padding(.top, 50) } - + compactHeaderContent .opacity(showCompactHeader ? 1 : 0) .animation(scaleAnimation, value: -headerHeight) SamplePreviewBarView() - + customBackButton } .offset(x: dragOffset) .animation(.interactiveSpring(), value: dragOffset) - + .modifier(BookStateModifier(viewModel: viewModel, showHalfSheet: $viewModel.showHalfSheet)) - .onReceive(NotificationCenter.default.publisher(for: Notification.Name("ToggleSampleNotification")).receive(on: RunLoop.main)) { note in - guard let info = note.userInfo as? [String: Any], let identifier = info["bookIdentifier"] as? String else { return } + .onReceive(NotificationCenter.default.publisher(for: Notification.Name("ToggleSampleNotification")) + .receive(on: RunLoop.main) + ) { note in + guard let info = note.userInfo as? [String: Any], + let identifier = info["bookIdentifier"] as? String + else { + return + } let action = (info["action"] as? String) ?? "toggle" if action == "close" { SamplePreviewManager.shared.close() return } - if let book = TPPBookRegistry.shared.book(forIdentifier: identifier) ?? (viewModel.relatedBooksByLane.values.flatMap { $0.books }).first(where: { $0.identifier == identifier }) { + if let book = TPPBookRegistry.shared + .book(forIdentifier: identifier) ?? (viewModel.relatedBooksByLane.values.flatMap(\.books)) + .first(where: { $0.identifier == identifier }) + { SamplePreviewManager.shared.toggle(for: book) } else if viewModel.book.identifier == identifier { SamplePreviewManager.shared.toggle(for: viewModel.book) @@ -159,28 +178,28 @@ struct BookDetailView: View { } .onDisappear { SamplePreviewManager.shared.close() } } - + // MARK: - View Components - + private func dynamicTopPadding() -> CGFloat { let basePadding: CGFloat = 20 let iPadPadding: CGFloat = 40 let notchPadding: CGFloat = 60 - + if UIDevice.current.userInterfaceIdiom == .pad { return iPadPadding } else { - let topInset: CGFloat - if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene, - let win = windowScene.windows.first { - topInset = win.safeAreaInsets.top + let topInset: CGFloat = if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene, + let win = windowScene.windows.first + { + win.safeAreaInsets.top } else { - topInset = 0 + 0 } return topInset > 20 ? notchPadding : basePadding } } - + @ViewBuilder private var mainView: some View { if viewModel.isFullSize { fullView @@ -188,7 +207,7 @@ struct BookDetailView: View { compactView } } - + private var fullView: some View { VStack(alignment: .leading, spacing: 20) { VStack(alignment: .leading, spacing: 30) { @@ -198,17 +217,17 @@ struct BookDetailView: View { .padding(.top, 20) } .padding(.top, 110) - + descriptionView informationView Spacer() } .padding(30) - + relatedBooksView } } - + private var compactView: some View { VStack(spacing: 10) { VStack { @@ -217,23 +236,23 @@ struct BookDetailView: View { .scaleEffect(max(0.8, titleOpacity)) .offset(y: (1 - titleOpacity) * -10) .animation(scaleAnimation, value: titleOpacity) - + VStack(spacing: 20) { descriptionView informationView } } .padding(.horizontal, 30) - + relatedBooksView .padding(.top) - + Spacer(minLength: 50) } .padding(.top, imageBottomPosition + 10) .animation(scaleAnimation, value: imageBottomPosition) } - + private var imageView: some View { BookImageView(book: viewModel.book, height: 280 * imageScale) .opacity(imageOpacity) @@ -246,7 +265,7 @@ struct BookDetailView: View { .onChange(of: imageScale) { _ in updateImageBottomPosition() } }) } - + private var titleView: some View { VStack(alignment: viewModel.isFullSize ? .leading : .center, spacing: 8) { Text(viewModel.book.title) @@ -254,19 +273,19 @@ struct BookDetailView: View { .lineLimit(nil) .multilineTextAlignment(.center) .frame(maxWidth: .infinity, alignment: viewModel.isFullSize ? .leading : .center) - + if let authors = viewModel.book.authors, !authors.isEmpty { Text(authors) .font(.footnote) } - + BookButtonsView( provider: viewModel, backgroundColor: viewModel.isFullSize ? headerColor : (colorScheme == .dark ? .black : .white) ) { type in handleButtonAction(type) } - + if !viewModel.book.isAudiobook && viewModel.book.hasAudiobookSample { audiobookAvailable .padding(.top) @@ -275,12 +294,12 @@ struct BookDetailView: View { .foregroundColor(viewModel.isFullSize ? (headerColor.isDark ? .white : .black) : Color(UIColor.label)) .animation(scaleAnimation, value: imageScale) } - + private var backgroundView: some View { ZStack(alignment: .top) { Color.primary .edgesIgnoringSafeArea(.all) - + LinearGradient( gradient: Gradient(colors: [ headerColor.opacity(1.0), @@ -292,7 +311,7 @@ struct BookDetailView: View { } .edgesIgnoringSafeArea(.top) } - + private var compactHeaderContent: some View { HStack(alignment: .top) { VStack(alignment: .leading) { @@ -302,7 +321,7 @@ struct BookDetailView: View { .multilineTextAlignment(.center) .font(.subheadline) .foregroundColor(headerColor.isDark ? .white : .black) - + if let authors = viewModel.book.authors, !authors.isEmpty { Text(authors) .font(.caption) @@ -319,19 +338,19 @@ struct BookDetailView: View { .padding(.top, 20) .padding(.bottom, 10) } - + @ViewBuilder private var descriptionView: some View { - if !self.descriptionText.isEmpty { + if !descriptionText.isEmpty { ZStack(alignment: .bottom) { VStack(alignment: .leading, spacing: 10) { Text(DisplayStrings.description.uppercased()) .font(.headline) - + Divider() .padding(.vertical) - + VStack { - HTMLTextView(htmlContent: self.descriptionText) + HTMLTextView(htmlContent: descriptionText) .lineLimit(nil) .frame(maxWidth: .infinity) .fixedSize(horizontal: false, vertical: true) @@ -340,7 +359,7 @@ struct BookDetailView: View { .frame(maxHeight: isExpanded ? .infinity : 100, alignment: .top) .clipped() } - + if !isExpanded { LinearGradient( gradient: Gradient(stops: [ @@ -353,7 +372,7 @@ struct BookDetailView: View { ) .frame(height: 60) } - + Button(isExpanded ? DisplayStrings.less.capitalized : DisplayStrings.more.capitalized) { withAnimation { isExpanded.toggle() @@ -365,24 +384,24 @@ struct BookDetailView: View { .padding(.bottom) } } - + @ViewBuilder private var relatedBooksView: some View { if viewModel.relatedBooksByLane.count > 0 { VStack(alignment: .leading, spacing: 20) { VStack(alignment: .leading, spacing: 0) { Text(DisplayStrings.otherBooks.uppercased()) .font(.headline) - + Divider() .padding(.vertical, 20) } .padding(.horizontal, 30) - + ForEach(viewModel.relatedBooksByLane.keys.sorted(), id: \.self) { laneTitle in if laneTitle != viewModel.relatedBooksByLane.keys.sorted().first { Divider() } - + if let lane = viewModel.relatedBooksByLane[laneTitle] { VStack(alignment: .leading, spacing: 20) { HStack { @@ -397,7 +416,7 @@ struct BookDetailView: View { } } .padding(.horizontal, 30) - + ScrollView(.horizontal, showsIndicators: false) { LazyHStack(spacing: 12) { ForEach(lane.books.indices, id: \.self) { index in @@ -419,7 +438,6 @@ struct BookDetailView: View { } } .padding(.horizontal, 30) - } } } @@ -427,7 +445,7 @@ struct BookDetailView: View { } } } - + @ViewBuilder private var audiobookAvailable: some View { VStack(alignment: .leading, spacing: 10) { Divider() @@ -439,9 +457,9 @@ struct BookDetailView: View { Divider() } } - - @State private var currentBookID: String? = nil - + + @State private var currentBookID: String? + @ViewBuilder private var audiobookIndicator: some View { ImageProviders.MyBooksView.audiobookBadge .resizable() @@ -450,39 +468,40 @@ struct BookDetailView: View { .background(Circle().fill(Color.colorAudiobookBackground)) .clipped() } - + @ViewBuilder private var informationView: some View { VStack(alignment: .leading, spacing: 5) { Text(DisplayStrings.information.uppercased()) .font(.headline) Divider() .padding(.vertical) - - infoRow(label: DisplayStrings.format.uppercased(), value: self.viewModel.book.format) - infoRow(label: DisplayStrings.published.uppercased(), value: self.viewModel.book.published?.monthDayYearString ?? "") - infoRow(label: DisplayStrings.publisher.uppercased(), value: self.viewModel.book.publisher ?? "") - - let categoryLabel = self.viewModel.book.categoryStrings?.count == 1 ? DisplayStrings.categories.uppercased() : DisplayStrings.category.uppercased() - infoRow(label: categoryLabel, value: self.viewModel.book.categories ?? "") - - infoRow(label: DisplayStrings.distributor.uppercased(), value: self.viewModel.book.distributor ?? "") - + + infoRow(label: DisplayStrings.format.uppercased(), value: viewModel.book.format) + infoRow(label: DisplayStrings.published.uppercased(), value: viewModel.book.published?.monthDayYearString ?? "") + infoRow(label: DisplayStrings.publisher.uppercased(), value: viewModel.book.publisher ?? "") + + let categoryLabel = viewModel.book.categoryStrings?.count == 1 ? DisplayStrings.categories + .uppercased() : DisplayStrings.category.uppercased() + infoRow(label: categoryLabel, value: viewModel.book.categories ?? "") + + infoRow(label: DisplayStrings.distributor.uppercased(), value: viewModel.book.distributor ?? "") + if viewModel.book.isAudiobook { - if let narrators = self.viewModel.book.narrators { + if let narrators = viewModel.book.narrators { infoRow(label: DisplayStrings.narrators.uppercased(), value: narrators) } - - if let duration = self.viewModel.book.bookDuration { + + if let duration = viewModel.book.bookDuration { infoRow(label: DisplayStrings.duration.uppercased(), value: formatDuration(duration)) } } - + Spacer() } } - + // MARK: - Helper Functions - + private func infoRow(label: String, value: String) -> some View { HStack(alignment: .bottom, spacing: 10) { infoLabel(label: label) @@ -490,7 +509,7 @@ struct BookDetailView: View { infoValue(value: value) } } - + @ViewBuilder private func infoLabel(label: String) -> some View { Text(label) .font(Font.boldPalaceFont(size: 12)) @@ -498,7 +517,7 @@ struct BookDetailView: View { .multilineTextAlignment(.leading) .fixedSize(horizontal: false, vertical: true) } - + @ViewBuilder private func infoValue(value: String) -> some View { if let url = URL(string: value), UIApplication.shared.canOpenURL(url) { Link(value, destination: url) @@ -515,36 +534,36 @@ struct BookDetailView: View { .fixedSize(horizontal: false, vertical: true) } } - + private func formatDuration(_ durationInSeconds: String) -> String { guard let totalSeconds = Double(durationInSeconds) else { return "Invalid input" } - + let hours = Int(totalSeconds / 3600) let minutes = Int((totalSeconds - Double(hours * 3600)) / 60) - + return String(format: "%d hours, %d minutes", hours, minutes) } - + private func updateImageBottomPosition() { let imageHeight = max(280 * imageScale, 80) imageBottomPosition = imageTopPadding + imageHeight + 70 } - + private func resetSampleToolbar() { viewModel.showSampleToolbar = false SamplePreviewManager.shared.close() } - + private func setupSampleToolbarIfNeeded() { let bookID = viewModel.book.identifier - + if !SamplePreviewManager.shared.isShowingPreview(for: viewModel.book) || bookID != currentBookID { currentBookID = bookID } } - + private func handleButtonAction(_ buttonType: BookButtonType) { switch buttonType { case .sample, .audiobookSample: @@ -574,23 +593,25 @@ struct BookDetailView: View { } } } - + private func updateHeaderHeight(for offset: CGFloat) { - guard !viewModel.isFullSize else { return } - + guard !viewModel.isFullSize else { + return + } + let dampedOffset = offset * dampingFactor let newHeight = headerHeight + dampedOffset let adjustedHeight = max(minHeaderHeight, min(newHeight, maxHeaderHeight)) let progress = (adjustedHeight - minHeaderHeight) / (maxHeaderHeight - minHeaderHeight) - + headerHeight = adjustedHeight imageScale = progress imageOpacity = progress titleOpacity = showCompactHeader ? 0 : progress - + let compactThreshold = minHeaderHeight + (maxHeaderHeight - minHeaderHeight) * 0.3 let expandThreshold = minHeaderHeight + (maxHeaderHeight - minHeaderHeight) * 0.6 - + if offset < lastOffset { if adjustedHeight <= compactThreshold && !showCompactHeader { showCompactHeader = true @@ -600,10 +621,10 @@ struct BookDetailView: View { showCompactHeader = false } } - + lastOffset = offset } - + private var customBackButton: some View { VStack { HStack { @@ -623,24 +644,26 @@ struct BookDetailView: View { .foregroundColor(headerColor.isDark ? .white : .black) } .padding(.leading, 16) - + Spacer() } - + Spacer() } } } +// MARK: - BookStateModifier + private struct BookStateModifier: ViewModifier { @ObservedObject var viewModel: BookDetailViewModel @Binding var showHalfSheet: Bool @Environment(\.presentationMode) var presentationMode - + private var coordinator: NavigationCoordinator? { NavigationCoordinatorHub.shared.coordinator } - + func body(content: Content) -> some View { content .onChange(of: viewModel.bookState) { newState in diff --git a/Palace/Book/UI/BookDetail/BookDetailViewModel.swift b/Palace/Book/UI/BookDetail/BookDetailViewModel.swift index 074842402..f1b031b80 100644 --- a/Palace/Book/UI/BookDetail/BookDetailViewModel.swift +++ b/Palace/Book/UI/BookDetail/BookDetailViewModel.swift @@ -1,25 +1,30 @@ import Combine -import SwiftUI import PalaceAudiobookToolkit +import SwiftUI #if LCP import ReadiumShared import ReadiumStreamer #endif +// MARK: - BookLane + struct BookLane { let title: String let books: [TPPBook] let subsectionURL: URL? } +// MARK: - BookDetailViewModel + @MainActor final class BookDetailViewModel: ObservableObject { // MARK: - Constants + private let kTimerInterval: TimeInterval = 3.0 - + @Published var book: TPPBook - + /// The registry state, e.g. `unregistered`, `downloading`, `downloadSuccessful`, etc. @Published var bookState: TPPBookState { didSet { @@ -30,37 +35,38 @@ final class BookDetailViewModel: ObservableObject { } } } - + @Published var bookmarks: [TPPReadiumBookmark] = [] @Published var showSampleToolbar = false @Published var downloadProgress: Double = 0.0 - + @Published var relatedBooksByLane: [String: BookLane] = [:] @Published var isLoadingRelatedBooks = false @Published var isLoadingDescription = false - @Published var selectedBookURL: URL? = nil + @Published var selectedBookURL: URL? @Published var isManagingHold: Bool = false @Published var showHalfSheet = false @Published private(set) var stableButtonState: BookButtonState = .unsupported - + var isFullSize: Bool { UIDevice.current.isIpad } - + @Published var processingButtons: Set = [] { didSet { isProcessing = processingButtons.count > 0 } } + @Published var isProcessing: Bool = false - + var isShowingSample = false var isProcessingSample = false - + // MARK: - Dependencies - + let registry: TPPBookRegistry let downloadCenter = MyBooksDownloadCenter.shared private var cancellables = Set() - + // Note: audiobook management moved to BookService // private var audiobookViewController: UIViewController? // No longer used // private var audiobookManager: DefaultAudiobookManager? // No longer used @@ -70,81 +76,88 @@ final class BookDetailViewModel: ObservableObject { private var previousPlayheadOffset: TimeInterval = 0 private var didPrefetchLCPStreaming = false private var isReconcilingLocation: Bool = false - private var recentMoveAt: Date? = nil + private var recentMoveAt: Date? private var isSyncingLocation: Bool = false private let bookIdentifier: String - private var localBookStateOverride: TPPBookState? = nil - + private var localBookStateOverride: TPPBookState? + // MARK: – Computed Button State - + var buttonState: BookButtonState { stableButtonState } - + // MARK: - Initializer - + @objc init(book: TPPBook) { self.book = book - self.registry = TPPBookRegistry.shared - self.bookState = registry.state(for: book.identifier) - self.bookIdentifier = book.identifier - self.stableButtonState = self.computeButtonState(book: book, state: self.bookState, isManagingHold: self.isManagingHold, isProcessing: self.isProcessing) - + registry = TPPBookRegistry.shared + bookState = registry.state(for: book.identifier) + bookIdentifier = book.identifier + stableButtonState = computeButtonState( + book: book, + state: bookState, + isManagingHold: isManagingHold, + isProcessing: isProcessing + ) + bindRegistryState() setupStableButtonState() setupObservers() - self.downloadProgress = downloadCenter.downloadProgress(for: book.identifier) -#if LCP - self.prefetchLCPStreamingIfPossible() -#endif + downloadProgress = downloadCenter.downloadProgress(for: book.identifier) + #if LCP + prefetchLCPStreamingIfPossible() + #endif } - + deinit { timer?.cancel() timer = nil NotificationCenter.default.removeObserver(self) -#if LCP + #if LCP if let licenseUrl = Self.lcpLicenseURL(forBookIdentifier: bookIdentifier) { - var lcpAudiobooks: LCPAudiobooks? - if let localURL = MyBooksDownloadCenter.shared.fileUrl(for: bookIdentifier), - FileManager.default.fileExists(atPath: localURL.path) { - lcpAudiobooks = LCPAudiobooks(for: localURL) + var lcpAudiobooks: LCPAudiobooks? = if let localURL = MyBooksDownloadCenter.shared.fileUrl(for: bookIdentifier), + FileManager.default.fileExists(atPath: localURL.path) + { + LCPAudiobooks(for: localURL) } else { - lcpAudiobooks = LCPAudiobooks(for: licenseUrl) + LCPAudiobooks(for: licenseUrl) } lcpAudiobooks?.cancelPrefetch() } -#endif + #endif } - + // MARK: - Book State Binding - + private func bindRegistryState() { registry .bookStatePublisher .filter { $0.0 == self.book.identifier } - .map { $0.1 } + .map(\.1) .receive(on: DispatchQueue.main) - .sink { [weak self] newState in - guard let self else { return } + .sink { [weak self] _ in + guard let self else { + return + } let updatedBook = registry.book(forIdentifier: book.identifier) ?? book let registryState = registry.state(for: book.identifier) - self.book = updatedBook + book = updatedBook // If we are in a local returning override, hold it until unregistered - if let override = self.localBookStateOverride, override == .returning, registryState != .unregistered { + if let override = localBookStateOverride, override == .returning, registryState != .unregistered { return } - self.bookState = registryState + bookState = registryState if registryState == .unregistered { // Ensure UI is not left in a managing/processing state after returning - self.isManagingHold = false - self.showHalfSheet = false - self.processingButtons.remove(.returning) - self.processingButtons.remove(.cancelHold) + isManagingHold = false + showHalfSheet = false + processingButtons.remove(.returning) + processingButtons.remove(.cancelHold) } } .store(in: &cancellables) } - + private func setupObservers() { NotificationCenter.default.addObserver( self, @@ -152,20 +165,27 @@ final class BookDetailViewModel: ObservableObject { name: .TPPBookRegistryDidChange, object: nil ) - + // Avoid general download center change notifications; we already subscribe to fine-grained progress and registry state publishers - + downloadCenter.downloadProgressPublisher .filter { $0.0 == self.book.identifier } - .map { $0.1 } + .map(\.1) .receive(on: DispatchQueue.main) .assign(to: &$downloadProgress) } - private func computeButtonState(book: TPPBook, state: TPPBookState, isManagingHold: Bool, isProcessing: Bool) -> BookButtonState { + private func computeButtonState( + book: TPPBook, + state: TPPBookState, + isManagingHold: Bool, + isProcessing: Bool + ) -> BookButtonState { let availability = book.defaultAcquisition?.availability let isProcessingDownload = isProcessing || state == .downloading - if case .holding = state, isManagingHold { return .managingHold } + if case .holding = state, isManagingHold { + return .managingHold + } return BookButtonMapper.map( registryState: state, availability: availability, @@ -176,69 +196,89 @@ final class BookDetailViewModel: ObservableObject { private func setupStableButtonState() { Publishers.CombineLatest4($book, $bookState, $isManagingHold, $isProcessing) .map { [weak self] book, state, isManaging, isProcessing in - self?.computeButtonState(book: book, state: state, isManagingHold: isManaging, isProcessing: isProcessing) ?? .unsupported + self? + .computeButtonState(book: book, state: state, isManagingHold: isManaging, isProcessing: isProcessing) ?? + .unsupported } .removeDuplicates() .debounce(for: .milliseconds(180), scheduler: DispatchQueue.main) .receive(on: DispatchQueue.main) - .assign(to: &self.$stableButtonState) + .assign(to: &$stableButtonState) } - - @objc func handleBookRegistryChange(_ notification: Notification) { + + @objc func handleBookRegistryChange(_: Notification) { DispatchQueue.main.async { [weak self] in - guard let self else { return } + guard let self else { + return + } let updatedBook = registry.book(forIdentifier: book.identifier) ?? book - self.book = updatedBook + book = updatedBook } } - + func selectRelatedBook(_ newBook: TPPBook) { - guard newBook.identifier != book.identifier else { return } + guard newBook.identifier != book.identifier else { + return + } book = newBook bookState = registry.state(for: newBook.identifier) fetchRelatedBooks() } - + // MARK: - Notifications - - @objc func handleDownloadStateDidChange(_ notification: Notification) { + + @objc func handleDownloadStateDidChange(_: Notification) { DispatchQueue.main.async { [weak self] in - guard let self else { return } - self.downloadProgress = downloadCenter.downloadProgress(for: book.identifier) + guard let self else { + return + } + downloadProgress = downloadCenter.downloadProgress(for: book.identifier) let info = downloadCenter.downloadInfo(forBookIdentifier: book.identifier) if let rights = info?.rightsManagement, rights != .unknown { if bookState != .downloading && bookState != .downloadSuccessful { - self.bookState = registry.state(for: book.identifier) + bookState = registry.state(for: book.identifier) } #if LCP - self.prefetchLCPStreamingIfPossible() + prefetchLCPStreamingIfPossible() #endif } } } - + // MARK: - Related Books - + func fetchRelatedBooks() { - guard let url = book.relatedWorksURL else { return } - + guard let url = book.relatedWorksURL else { + return + } + isLoadingRelatedBooks = true relatedBooksByLane = [:] - - TPPOPDSFeed.withURL(url, shouldResetCache: false, useTokenIfAvailable: TPPUserAccount.sharedAccount().hasAdobeToken()) { [weak self] feed, _ in - guard let self else { return } - + + TPPOPDSFeed.withURL( + url, + shouldResetCache: false, + useTokenIfAvailable: TPPUserAccount.sharedAccount().hasAdobeToken() + ) { [weak self] feed, _ in + guard let self else { + return + } + DispatchQueue.main.async { if feed?.type == .acquisitionGrouped { var groupTitleToBooks: [String: [TPPBook]] = [:] var groupTitleToMoreURL: [String: URL?] = [:] if let entries = feed?.entries as? [TPPOPDSEntry] { for entry in entries { - guard let group = entry.groupAttributes else { continue } + guard let group = entry.groupAttributes else { + continue + } let groupTitle = group.title ?? "" if let b = CatalogViewModel.makeBook(from: entry) { groupTitleToBooks[groupTitle, default: []].append(b) - if groupTitleToMoreURL[groupTitle] == nil { groupTitleToMoreURL[groupTitle] = group.href } + if groupTitleToMoreURL[groupTitle] == nil { + groupTitleToMoreURL[groupTitle] = group.href + } } } } @@ -249,16 +289,18 @@ final class BookDetailViewModel: ObservableObject { } } } - + private func createRelatedBooksCells(groupedBooks: [String: [TPPBook]], moreURLs: [String: URL?]) { var lanesMap = [String: BookLane]() for (title, books) in groupedBooks { - let lane = BookLane(title: title, books: books, subsectionURL: moreURLs[title] ?? nil) + let lane = BookLane(title: title, books: books, subsectionURL: moreURLs[title]) lanesMap[title] = lane } - + if let author = book.authors, !author.isEmpty { - if let authorLane = lanesMap.first(where: { $0.value.books.contains(where: { $0.authors?.contains(author) ?? false }) }) { + if let authorLane = lanesMap + .first(where: { $0.value.books.contains(where: { $0.authors?.contains(author) ?? false }) }) + { lanesMap.removeValue(forKey: authorLane.key) var reorderedBooks = [String: BookLane]() reorderedBooks[authorLane.key] = authorLane.value @@ -266,29 +308,31 @@ final class BookDetailViewModel: ObservableObject { lanesMap = reorderedBooks } } - + DispatchQueue.main.async { self.relatedBooksByLane = lanesMap self.isLoadingRelatedBooks = false } } - + func showMoreBooksForLane(laneTitle: String) { - guard let lane = relatedBooksByLane[laneTitle] else { return } + guard let lane = relatedBooksByLane[laneTitle] else { + return + } if let subsectionURL = lane.subsectionURL { - self.selectedBookURL = subsectionURL + selectedBookURL = subsectionURL } } - + // MARK: - Button Actions - + func handleAction(for button: BookButtonType) { - guard !isProcessing(for: button) else { + guard !isProcessing(for: button) else { Log.debug(#file, "Button \(button) is already processing, ignoring tap") - return + return } processingButtons.insert(button) - + switch button { case .reserve: didSelectReserve(for: book) @@ -297,7 +341,7 @@ final class BookDetailViewModel: ObservableObject { case .return, .remove: bookState = .returning removeProcessingButton(button) - + case .returning, .cancelHold: // didSelectReturn will guard against duplicate requests using processingButtons didSelectReturn(for: book) { @@ -305,54 +349,55 @@ final class BookDetailViewModel: ObservableObject { self.showHalfSheet = false self.isManagingHold = false } - + case .download, .get, .retry: - self.downloadProgress = 0 + downloadProgress = 0 didSelectDownload(for: book) removeProcessingButton(button) - + case .read, .listen: didSelectRead(for: book) { self.removeProcessingButton(button) } - + case .cancel: didSelectCancel() removeProcessingButton(button) - + case .sample, .audiobookSample: didSelectPlaySample(for: book) { self.removeProcessingButton(button) } - + case .close: break - + case .manageHold: isManagingHold = true bookState = .holding - break } } - + private func removeProcessingButton(_ button: BookButtonType) { - self.processingButtons.remove(button) + processingButtons.remove(button) } - + func isProcessing(for button: BookButtonType) -> Bool { processingButtons.contains(button) } - + // MARK: - Download/Return/Cancel - + func didSelectDownload(for book: TPPBook) { - self.downloadProgress = 0 + downloadProgress = 0 let account = TPPUserAccount.sharedAccount() if account.needsAuth && !account.hasCredentials() { showHalfSheet = false TPPAccountSignInViewController.requestCredentials { [weak self] in - guard let self else { return } - self.startDownloadAfterAuth(book: book) + guard let self else { + return + } + startDownloadAfterAuth(book: book) } return } @@ -370,32 +415,36 @@ final class BookDetailViewModel: ObservableObject { if account.needsAuth && !account.hasCredentials() { showHalfSheet = false TPPAccountSignInViewController.requestCredentials { [weak self] in - guard let self else { return } - self.downloadCenter.startBorrow(for: book, attemptDownload: false) + guard let self else { + return + } + downloadCenter.startBorrow(for: book, attemptDownload: false) } return } downloadCenter.startBorrow(for: book, attemptDownload: false) } - + func didSelectCancel() { downloadCenter.cancelDownload(for: book.identifier) - self.downloadProgress = 0 + downloadProgress = 0 } - + func didSelectReturn(for book: TPPBook, completion: (() -> Void)?) { // Prevent multiple return requests and UI loops processingButtons.insert(.returning) downloadCenter.returnBook(withIdentifier: book.identifier) { [weak self] in - guard let self else { return } - self.bookState = .unregistered - self.processingButtons.remove(.returning) + guard let self else { + return + } + bookState = .unregistered + processingButtons.remove(.returning) completion?() } } - + // MARK: - Reading - + @MainActor func didSelectRead(for book: TPPBook, completion: (() -> Void)?) { let account = TPPUserAccount.sharedAccount() @@ -407,15 +456,16 @@ final class BookDetailViewModel: ObservableObject { } return } -#if FEATURE_DRM_CONNECTOR + #if FEATURE_DRM_CONNECTOR let user = TPPUserAccount.sharedAccount() - + if user.hasCredentials() { if user.hasAuthToken() { openBook(book, completion: completion) return } else if !(AdobeCertificate.defaultCertificate?.hasExpired ?? false) && - !NYPLADEPT.sharedInstance().isUserAuthorized(user.userID, withDevice: user.deviceID) { + !NYPLADEPT.sharedInstance().isUserAuthorized(user.userID, withDevice: user.deviceID) + { let reauthenticator = TPPReauthenticator() reauthenticator.authenticateIfNeeded(user, usingExistingCredentials: true) { Task { @MainActor in @@ -425,14 +475,14 @@ final class BookDetailViewModel: ObservableObject { return } } -#endif + #endif openBook(book, completion: completion) } - + @MainActor func openBook(_ book: TPPBook, completion: (() -> Void)?) { TPPCirculationAnalytics.postEvent("open_book", withBook: book) - + let resolvedBook = registry.book(forIdentifier: book.identifier) ?? book switch resolvedBook.defaultBookContentType { @@ -454,41 +504,40 @@ final class BookDetailViewModel: ObservableObject { presentUnsupportedItemError() } } - + @MainActor private func presentEPUB(_ book: TPPBook) { BookService.open(book) } - + @MainActor private func presentPDF(_ book: TPPBook) { BookService.open(book) } - + // MARK: - Audiobook Opening - + func openAudiobook(_ book: TPPBook, completion: (() -> Void)? = nil) { BookService.open(book, onFinish: completion) } - - + private func getLCPLicenseURL(for book: TPPBook) -> URL? { -#if LCP + #if LCP guard let bookFileURL = downloadCenter.fileUrl(for: book.identifier) else { return nil } - + let licenseURL = bookFileURL.deletingPathExtension().appendingPathExtension("lcpl") - + if FileManager.default.fileExists(atPath: licenseURL.path) { return licenseURL } - + return nil -#else + #else return nil -#endif + #endif } -#if LCP + #if LCP nonisolated static func lcpLicenseURL(forBookIdentifier identifier: String) -> URL? { guard let bookFileURL = MyBooksDownloadCenter.shared.fileUrl(for: identifier) else { return nil @@ -496,41 +545,44 @@ final class BookDetailViewModel: ObservableObject { let licenseURL = bookFileURL.deletingPathExtension().appendingPathExtension("lcpl") return FileManager.default.fileExists(atPath: licenseURL.path) ? licenseURL : nil } -#endif - - - + #endif - - -#if LCP + #if LCP private func prefetchLCPStreamingIfPossible() { - guard !didPrefetchLCPStreaming, LCPAudiobooks.canOpenBook(book), let licenseUrl = Self.lcpLicenseURL(forBookIdentifier: bookIdentifier) else { return } - if let localURL = downloadCenter.fileUrl(for: bookIdentifier), FileManager.default.fileExists(atPath: localURL.path) { + guard !didPrefetchLCPStreaming, LCPAudiobooks.canOpenBook(book), + let licenseUrl = Self.lcpLicenseURL(forBookIdentifier: bookIdentifier) + else { + return + } + if let localURL = downloadCenter.fileUrl(for: bookIdentifier), + FileManager.default.fileExists(atPath: localURL.path) + { + return + } + + guard let lcpAudiobooks = LCPAudiobooks(for: licenseUrl) else { return } - - guard let lcpAudiobooks = LCPAudiobooks(for: licenseUrl) else { return } didPrefetchLCPStreaming = true lcpAudiobooks.startPrefetch() } -#endif - - + #endif + // MARK: - Samples - + func didSelectPlaySample(for book: TPPBook, completion: (() -> Void)?) { - guard !isProcessingSample else { return } + guard !isProcessingSample else { + return + } isProcessingSample = true - + if book.defaultBookContentType == .audiobook { if book.sampleAcquisition?.type == "text/html" { presentWebView(book.sampleAcquisition?.hrefURL) isProcessingSample = false completion?() } else { - SamplePreviewManager.shared.toggle(for: book) isProcessingSample = false completion?() @@ -546,7 +598,7 @@ final class BookDetailViewModel: ObservableObject { // Check if this is a Palace Marketplace EPUB sample let isPalaceMarketplace = book.distributor == "Palace Marketplace" let isEpubSample = book.sample?.type == .contentTypeEpubZip - + if isPalaceMarketplace && isEpubSample { // Use Readium EPUB reader for Palace Marketplace EPUB samples ReaderService.shared.openSample(book, url: sampleURL) @@ -564,22 +616,23 @@ final class BookDetailViewModel: ObservableObject { } } } - + private func presentWebView(_ url: URL?) { - guard let url = url else { return } + guard let url = url else { + return + } let webController = BundledHTMLViewController( fileURL: url, title: AccountsManager.shared.currentAccount?.name ?? "" ) - + if let top = (UIApplication.shared.delegate as? TPPAppDelegate)?.topViewController() { top.present(webController, animated: true) } } - - + // MARK: - Error Alerts - + private func presentCorruptedItemError() { let alert = UIAlertController( title: Strings.Error.epubNotValidError, @@ -587,9 +640,14 @@ final class BookDetailViewModel: ObservableObject { preferredStyle: .alert ) alert.addAction(UIAlertAction(title: "OK", style: .default)) - TPPAlertUtils.presentFromViewControllerOrNil(alertController: alert, viewController: nil, animated: true, completion: nil) + TPPAlertUtils.presentFromViewControllerOrNil( + alertController: alert, + viewController: nil, + animated: true, + completion: nil + ) } - + private func presentUnsupportedItemError() { let alert = UIAlertController( title: Strings.Error.formatNotSupportedError, @@ -597,13 +655,23 @@ final class BookDetailViewModel: ObservableObject { preferredStyle: .alert ) alert.addAction(UIAlertAction(title: "OK", style: .default)) - TPPAlertUtils.presentFromViewControllerOrNil(alertController: alert, viewController: nil, animated: true, completion: nil) + TPPAlertUtils.presentFromViewControllerOrNil( + alertController: alert, + viewController: nil, + animated: true, + completion: nil + ) } - + private func presentDRMKeyError(_ error: Error) { let alert = UIAlertController(title: "DRM Error", message: error.localizedDescription, preferredStyle: .alert) alert.addAction(UIAlertAction(title: "OK", style: .default)) - TPPAlertUtils.presentFromViewControllerOrNil(alertController: alert, viewController: nil, animated: true, completion: nil) + TPPAlertUtils.presentFromViewControllerOrNil( + alertController: alert, + viewController: nil, + animated: true, + completion: nil + ) } } @@ -611,18 +679,19 @@ extension BookDetailViewModel { public func scheduleTimer() { timer?.cancel() timer = nil - + let queue = DispatchQueue(label: "com.palace.pollAudiobookLocation", qos: .background, attributes: .concurrent) timer = DispatchSource.makeTimerSource(queue: queue) - + timer?.schedule(deadline: .now() + kTimerInterval, repeating: kTimerInterval) - + timer?.setEventHandler { [weak self] in self?.pollAudiobookReadingLocation() } - + timer?.resume() } + @objc public func pollAudiobookReadingLocation() { // Position polling is now handled by AudiobookPlaybackModel in BookService // This legacy polling can interfere with the new system, so disable it @@ -632,18 +701,26 @@ extension BookDetailViewModel { } extension BookDetailViewModel { - func chooseLocalLocation(localPosition: TrackPosition?, remotePosition: TrackPosition?, serverUpdateDelay: TimeInterval, operation: @escaping (TrackPosition) -> Void) { - let remoteLocationIsNewer: Bool - - if let localPosition = localPosition, let remotePosition = remotePosition { - remoteLocationIsNewer = String.isDate(remotePosition.lastSavedTimeStamp, moreRecentThan: localPosition.lastSavedTimeStamp, with: serverUpdateDelay) + func chooseLocalLocation( + localPosition: TrackPosition?, + remotePosition: TrackPosition?, + serverUpdateDelay: TimeInterval, + operation: @escaping (TrackPosition) -> Void + ) { + let remoteLocationIsNewer: Bool = if let localPosition = localPosition, let remotePosition = remotePosition { + String.isDate( + remotePosition.lastSavedTimeStamp, + moreRecentThan: localPosition.lastSavedTimeStamp, + with: serverUpdateDelay + ) } else { - remoteLocationIsNewer = localPosition == nil && remotePosition != nil + localPosition == nil && remotePosition != nil } - + if let remotePosition = remotePosition, remotePosition.description != localPosition?.description, - remoteLocationIsNewer { + remoteLocationIsNewer + { requestSyncWithCompletion { shouldSync in let location = shouldSync ? remotePosition : (localPosition ?? remotePosition) operation(location) @@ -654,56 +731,69 @@ extension BookDetailViewModel { operation(remotePosition) } } - + func requestSyncWithCompletion(completion: @escaping (Bool) -> Void) { DispatchQueue.main.async { let title = LocalizedStrings.syncListeningPositionAlertTitle let message = LocalizedStrings.syncListeningPositionAlertBody let moveTitle = LocalizedStrings.move let stayTitle = LocalizedStrings.stay - + let alertController = UIAlertController(title: title, message: message, preferredStyle: .alert) - + let moveAction = UIAlertAction(title: moveTitle, style: .default) { _ in completion(true) } - + let stayAction = UIAlertAction(title: stayTitle, style: .cancel) { _ in completion(false) } - + alertController.addAction(moveAction) alertController.addAction(stayAction) - - TPPAlertUtils.presentFromViewControllerOrNil(alertController: alertController, viewController: nil, animated: true, completion: nil) + + TPPAlertUtils.presentFromViewControllerOrNil( + alertController: alertController, + viewController: nil, + animated: true, + completion: nil + ) } } - + private func presentEndOfBookAlert() { let paths = TPPOPDSAcquisitionPath.supportedAcquisitionPaths( forAllowedTypes: TPPOPDSAcquisitionPath.supportedTypes(), allowedRelations: [.borrow, .generic], acquisitions: book.acquisitions ) - + if paths.count > 0 { let alert = TPPReturnPromptHelper.audiobookPrompt { [weak self] returnWasChosen in - guard let self else { return } - + guard let self else { + return + } + if returnWasChosen { NavigationCoordinatorHub.shared.coordinator?.pop() - self.didSelectReturn(for: self.book, completion: nil) + didSelectReturn(for: book, completion: nil) } TPPAppStoreReviewPrompt.presentIfAvailable() } - TPPAlertUtils.presentFromViewControllerOrNil(alertController: alert, viewController: nil, animated: true, completion: nil) + TPPAlertUtils.presentFromViewControllerOrNil( + alertController: alert, + viewController: nil, + animated: true, + completion: nil + ) } else { TPPAppStoreReviewPrompt.presentIfAvailable() } } } -// MARK: – BookButtonProvider +// MARK: BookButtonProvider + extension BookDetailViewModel: BookButtonProvider { var buttonTypes: [BookButtonType] { buttonState.buttonTypes(book: book) @@ -711,24 +801,26 @@ extension BookDetailViewModel: BookButtonProvider { } // MARK: - LCP Streaming Enhancement + #if LCP private extension BookDetailViewModel { /// Extract publication URL from LCPAudiobooks instance - func getPublicationUrl(from lcpAudiobooks: LCPAudiobooks) -> URL? { - + func getPublicationUrl(from _: LCPAudiobooks) -> URL? { guard let licenseUrl = getLCPLicenseURL(for: book), let license = TPPLCPLicense(url: licenseUrl), let publicationLink = license.firstLink(withRel: .publication), let href = publicationLink.href, - let publicationUrl = URL(string: href) else { + let publicationUrl = URL(string: href) + else { return nil } - + return publicationUrl } } #endif +// MARK: HalfSheetProvider extension BookDetailViewModel: HalfSheetProvider {} diff --git a/Palace/Book/UI/BookDetail/BookImageView.swift b/Palace/Book/UI/BookDetail/BookImageView.swift index 1caf7faab..83e5660f5 100644 --- a/Palace/Book/UI/BookDetail/BookImageView.swift +++ b/Palace/Book/UI/BookDetail/BookImageView.swift @@ -2,7 +2,7 @@ import SwiftUI struct BookImageView: View { @ObservedObject var book: TPPBook - var width: CGFloat? = nil + var width: CGFloat? var height: CGFloat = 280 var usePulseSkeleton: Bool = false diff --git a/Palace/Book/UI/BookDetail/BookService.swift b/Palace/Book/UI/BookDetail/BookService.swift index 41b4672e3..5b03850b4 100644 --- a/Palace/Book/UI/BookDetail/BookService.swift +++ b/Palace/Book/UI/BookDetail/BookService.swift @@ -1,11 +1,11 @@ -import Foundation -import SwiftUI import Combine +import Foundation import PalaceAudiobookToolkit +import SwiftUI enum BookService { private static var openingBooks = Set() - + static func open(_ book: TPPBook, onFinish: (() -> Void)? = nil) { // Prevent multiple simultaneous opens of the same book guard !openingBooks.contains(book.identifier) else { @@ -13,13 +13,13 @@ enum BookService { onFinish?() return } - + openingBooks.insert(book.identifier) let resolvedBook = TPPBookRegistry.shared.book(forIdentifier: book.identifier) ?? book openAfterTokenRefresh(resolvedBook, onFinish: onFinish) } - + private static func openAfterTokenRefresh(_ book: TPPBook, onFinish: (() -> Void)?) { switch book.defaultBookContentType { case .epub: @@ -30,9 +30,9 @@ enum BookService { } case .pdf: Task { @MainActor in - presentPDF(book) { + presentPDF(book) { openingBooks.remove(book.identifier) - onFinish?() + onFinish?() } } case .audiobook: @@ -45,9 +45,11 @@ enum BookService { onFinish?() } } - + @MainActor private static func presentPDF(_ book: TPPBook, completion: (() -> Void)? = nil) { - guard let url = MyBooksDownloadCenter.shared.fileUrl(for: book.identifier) else { completion?(); return } + guard let url = MyBooksDownloadCenter.shared.fileUrl(for: book.identifier) else { + completion?(); return + } let data = try? Data(contentsOf: url) let metadata = TPPPDFDocumentMetadata(with: book) let document = TPPPDFDocument(data: data ?? Data()) @@ -59,9 +61,11 @@ enum BookService { } private static func presentAudiobook(_ book: TPPBook, onFinish: (() -> Void)? = nil) { -#if LCP + #if LCP if LCPAudiobooks.canOpenBook(book) { - if let localURL = MyBooksDownloadCenter.shared.fileUrl(for: book.identifier), FileManager.default.fileExists(atPath: localURL.path) { + if let localURL = MyBooksDownloadCenter.shared.fileUrl(for: book.identifier), + FileManager.default.fileExists(atPath: localURL.path) + { buildAndPresentAudiobook(book: book, lcpSourceURL: localURL, onFinish: onFinish) return } @@ -70,11 +74,12 @@ enum BookService { return } } -#endif + #endif if let url = MyBooksDownloadCenter.shared.fileUrl(for: book.identifier), FileManager.default.fileExists(atPath: url.path), let data = try? Data(contentsOf: url), - let json = try? JSONSerialization.jsonObject(with: data, options: []) as? [String: Any] { + let json = try? JSONSerialization.jsonObject(with: data, options: []) as? [String: Any] + { presentAudiobookFrom(book: book, json: json, decryptor: nil, onFinish: onFinish) return } @@ -90,7 +95,7 @@ enum BookService { } } -#if LCP + #if LCP private static func buildAndPresentAudiobook(book: TPPBook, lcpSourceURL: URL, onFinish: (() -> Void)?) { guard let lcpAudiobooks = LCPAudiobooks(for: lcpSourceURL) else { showAudiobookTryAgainError() @@ -114,7 +119,7 @@ enum BookService { } } } -#endif + #endif private static func presentAudiobookFrom( book: TPPBook, @@ -135,7 +140,7 @@ enum BookService { } ATLog(.info, "Creating audiobook with bearerToken: '\(book.bearerToken ?? "nil")' for \(book.title)") - + guard let jsonData = try? JSONSerialization.data(withJSONObject: jsonDict, options: []), let manifest = try? Manifest.customDecoder().decode(Manifest.self, from: jsonData), @@ -152,7 +157,7 @@ enum BookService { return } - let metadata = AudiobookMetadata(title: book.title, authors: [book.authors ?? ""]) + let metadata = AudiobookMetadata(title: book.title, authors: [book.authors ?? ""]) var timeTracker: AudiobookTimeTracker? if let libraryId = AccountsManager.shared.currentAccount?.uuid, @@ -192,9 +197,9 @@ enum BookService { let route = BookRoute(id: book.identifier) coordinator.storeAudioModel(playbackModel, forBookId: route.id) coordinator.push(.audio(route)) - + var hasStartedPlayback = false - + let shouldRestorePosition = shouldRestoreBookmarkPosition(for: book) if shouldRestorePosition, let localPosition = getValidLocalPosition(book: book, audiobook: audiobook) { ATLog(.info, "Starting with immediate local position restore") @@ -212,7 +217,9 @@ enum BookService { toc: audiobook.tableOfContents.toc, tracks: audiobook.tableOfContents.tracks ) - else { return } + else { + return + } let localDict = TPPBookRegistry.shared.location(forIdentifier: book.identifier)?.locationStringDictionary() @@ -238,12 +245,16 @@ enum BookService { } } } - + // Fallback: Start from beginning if no valid position was found if !hasStartedPlayback { DispatchQueue.main.async { if let firstTrack = audiobook.tableOfContents.allTracks.first { - let startPosition = TrackPosition(track: firstTrack, timestamp: 0.0, tracks: audiobook.tableOfContents.tracks) + let startPosition = TrackPosition( + track: firstTrack, + timestamp: 0.0, + tracks: audiobook.tableOfContents.tracks + ) ATLog(.info, "Starting \(book.title) from beginning - no saved position") playbackModel.jumpToInitialLocation(startPosition) playbackModel.beginSaveSuppression(for: 2.0) @@ -270,11 +281,14 @@ enum BookService { } private static func fetchOpenAccessManifest(for book: TPPBook, completion: @escaping ([String: Any]?) -> Void) { - guard let url = book.defaultAcquisition?.hrefURL else { completion(nil); return } - let task = TPPNetworkExecutor.shared.download(url) { data, response, error in + guard let url = book.defaultAcquisition?.hrefURL else { + completion(nil); return + } + let task = TPPNetworkExecutor.shared.download(url) { data, _, error in guard error == nil, let data = data, - let json = (try? JSONSerialization.jsonObject(with: data, options: [])) as? [String: Any] else { + let json = (try? JSONSerialization.jsonObject(with: data, options: [])) as? [String: Any] + else { completion(nil) return } @@ -284,28 +298,30 @@ enum BookService { } private static func licenseURL(forBookIdentifier identifier: String) -> URL? { -#if LCP - guard let contentURL = MyBooksDownloadCenter.shared.fileUrl(for: identifier) else { return nil } + #if LCP + guard let contentURL = MyBooksDownloadCenter.shared.fileUrl(for: identifier) else { + return nil + } let license = contentURL.deletingPathExtension().appendingPathExtension("lcpl") return FileManager.default.fileExists(atPath: license.path) ? license : nil -#else + #else return nil -#endif + #endif } /// Determines if bookmark position should be restored for this book private static func shouldRestoreBookmarkPosition(for book: TPPBook) -> Bool { let bookState = TPPBookRegistry.shared.state(for: book.identifier) let hasLocation = TPPBookRegistry.shared.location(forIdentifier: book.identifier) != nil - + ATLog(.info, "Position check for \(book.title): state=\(bookState), hasLocation=\(hasLocation)") - + // If there's no saved location, always start from beginning guard hasLocation else { ATLog(.info, "No saved location found - starting from beginning") return false } - + // Always restore saved positions unless explicitly a new download if bookState == .downloadSuccessful { // Even for newly downloaded books, restore position if one exists @@ -313,11 +329,11 @@ enum BookService { ATLog(.info, "Book is downloadSuccessful but has saved location - restoring position") return true } - + ATLog(.info, "Restoring saved position for book") return true } - + /// Gets valid local position if available private static func getValidLocalPosition(book: TPPBook, audiobook: Audiobook) -> TrackPosition? { guard let dict = TPPBookRegistry.shared.location(forIdentifier: book.identifier)?.locationStringDictionary(), @@ -327,65 +343,67 @@ enum BookService { toc: audiobook.tableOfContents.toc, tracks: audiobook.tableOfContents.tracks ), - isValidPosition(localPosition, in: audiobook.tableOfContents) else { + isValidPosition(localPosition, in: audiobook.tableOfContents) + else { return nil } return localPosition } - + /// Validates that a position is reasonable and not corrupted private static func isValidPosition(_ position: TrackPosition, in tableOfContents: AudiobookTableOfContents) -> Bool { ATLog(.info, "Validating position: track=\(position.track.index), timestamp=\(position.timestamp)") - + // Check if position is within reasonable bounds guard position.timestamp >= 0 && position.timestamp.isFinite else { ATLog(.warn, "Invalid position timestamp: \(position.timestamp)") return false } - + // Check if track exists in table of contents guard tableOfContents.tracks.track(forKey: position.track.key) != nil else { ATLog(.warn, "Position references non-existent track: \(position.track.key)") return false } - + // Check if position is within reasonable bounds (basic validation) let totalDuration = tableOfContents.tracks.totalDuration let positionDuration = position.durationToSelf() - + // FIXED: If durations aren't available yet (common for Overdrive), skip validation if totalDuration <= 0 { ATLog(.info, "Position validation: Total duration not available yet, accepting position") return true } - + let percentageThrough = positionDuration / totalDuration - + ATLog(.info, "Position validation: \(Int(percentageThrough * 100))% through book") - + // More lenient validation - only reject if position is clearly invalid if positionDuration > totalDuration * 1.1 { // Allow 10% overflow for timing variations ATLog(.warn, "Position is beyond book duration (\(Int(percentageThrough * 100))%), starting from beginning") return false } - + ATLog(.info, "Position validation passed") return true } - + /// Gets download date for a book (placeholder - would integrate with download tracking) - private static func getDownloadDate(for bookId: String) -> Date? { + private static func getDownloadDate(for _: String) -> Date? { // This would integrate with MyBooksDownloadCenter to get actual download date // For now, return nil to be conservative - return nil + nil } - - private static func showAudiobookTryAgainError() { let alert = TPPAlertUtils.alert(title: Strings.Error.openFailedError, message: Strings.Error.tryAgain) - TPPAlertUtils.presentFromViewControllerOrNil(alertController: alert, viewController: nil, animated: true, completion: nil) + TPPAlertUtils.presentFromViewControllerOrNil( + alertController: alert, + viewController: nil, + animated: true, + completion: nil + ) } } - - diff --git a/Palace/Book/UI/BookDetail/HTMLTextView.swift b/Palace/Book/UI/BookDetail/HTMLTextView.swift index 8e301ebc2..611755b95 100644 --- a/Palace/Book/UI/BookDetail/HTMLTextView.swift +++ b/Palace/Book/UI/BookDetail/HTMLTextView.swift @@ -3,9 +3,9 @@ import UIKit struct HTMLTextView: View { let htmlContent: String - + @State private var attributedString = AttributedString("") - + var body: some View { Text(attributedString) .frame(maxWidth: .infinity, alignment: .leading) @@ -13,31 +13,31 @@ struct HTMLTextView: View { attributedString = makeAttributedString(from: htmlContent) } } - + private func makeAttributedString(from html: String) -> AttributedString { - guard html.contains("<"), html.count < 10_000 else { + guard html.contains("<"), html.count < 10000 else { return AttributedString(html) } - + guard let data = html.data(using: .utf8) else { return AttributedString(html) } - + let options: [NSAttributedString.DocumentReadingOptionKey: Any] = [ .documentType: NSAttributedString.DocumentType.html, .characterEncoding: String.Encoding.utf8.rawValue ] - + do { let nsAttr = try NSAttributedString(data: data, options: options, documentAttributes: nil) let mutable = NSMutableAttributedString(attributedString: nsAttr) - - mutable.enumerateAttribute(.font, in: NSRange(location: 0, length: mutable.length)) { value, range, _ in + + mutable.enumerateAttribute(.font, in: NSRange(location: 0, length: mutable.length)) { _, range, _ in mutable.addAttribute(.font, value: UIFont.palaceFont(ofSize: 17), range: range) } - + mutable.addAttribute(.foregroundColor, value: UIColor.label, range: NSRange(location: 0, length: mutable.length)) - + return AttributedString(mutable) } catch { return AttributedString(html) diff --git a/Palace/Book/UI/BookDetail/HalfSheetview.swift b/Palace/Book/UI/BookDetail/HalfSheetview.swift index f0ab03405..17d476d2e 100644 --- a/Palace/Book/UI/BookDetail/HalfSheetview.swift +++ b/Palace/Book/UI/BookDetail/HalfSheetview.swift @@ -1,5 +1,7 @@ import SwiftUI +// MARK: - HalfSheetProvider + @MainActor protocol HalfSheetProvider: ObservableObject, BookButtonProvider { var isFullSize: Bool { get } @@ -15,7 +17,7 @@ extension HalfSheetProvider { var isReturning: Bool { bookState == .returning } - + var isManagingHold: Bool { switch buttonState { case .managingHold, .holding, .holdingFrontOfQueue: @@ -26,6 +28,8 @@ extension HalfSheetProvider { } } +// MARK: - HalfSheetView + struct HalfSheetView: View { typealias DisplayStrings = Strings.BookDetailView @Environment(\.colorScheme) var colorScheme @@ -39,7 +43,6 @@ struct HalfSheetView: View { var body: some View { VStack(alignment: .leading, spacing: viewModel.isFullSize ? 20 : 10) { - headerView Text(AccountsManager.shared.currentAccount?.name ?? "") @@ -75,7 +78,7 @@ struct HalfSheetView: View { viewModel.handleAction(for: type) } }) - .horizontallyCentered() + .horizontallyCentered() } else { BookButtonsView(provider: viewModel, previewEnabled: false, onButtonTapped: { type in switch type { @@ -112,14 +115,18 @@ struct HalfSheetView: View { cellModel.isManagingHold = false } } - .onReceive(NotificationCenter.default.publisher(for: .TPPBookRegistryStateDidChange).receive(on: RunLoop.main)) { note in + .onReceive(NotificationCenter.default.publisher(for: .TPPBookRegistryStateDidChange) + .receive(on: RunLoop.main) + ) { note in guard let info = note.userInfo as? [String: Any], let identifier = info["bookIdentifier"] as? String, identifier == viewModel.book.identifier, let raw = info["state"] as? Int, let newState = TPPBookState(rawValue: raw) - else { return } + else { + return + } // Dismiss only when a return/remove fully completed to unregistered if viewModel.isReturning && newState == .unregistered { @@ -151,6 +158,7 @@ struct HalfSheetView: View { } // MARK: - Subviews + private extension HalfSheetView { @ViewBuilder var bookInfoView: some View { diff --git a/Palace/Book/UI/TPPBookDetailsProblemDocumentViewController.swift b/Palace/Book/UI/TPPBookDetailsProblemDocumentViewController.swift index 1f176a474..c8bbdad6e 100644 --- a/Palace/Book/UI/TPPBookDetailsProblemDocumentViewController.swift +++ b/Palace/Book/UI/TPPBookDetailsProblemDocumentViewController.swift @@ -1,109 +1,113 @@ -@objcMembers class TPPBookDetailsProblemDocumentViewController : UIViewController { +@objcMembers class TPPBookDetailsProblemDocumentViewController: UIViewController { let doc: TPPProblemDocument let book: TPPBook? - + let elementSpacing = CGFloat(12) weak var scrollView: UIScrollView? weak var backButton: UIButton? weak var closeButton: UIButton? weak var label: UILabel? weak var submitButton: UIButton? - + @available(*, unavailable) - required init?(coder: NSCoder) { + required init?(coder _: NSCoder) { fatalError("init(coder:) has not been implemented") } - + init(problemDocument: TPPProblemDocument, book: TPPBook?) { - self.doc = problemDocument + doc = problemDocument self.book = book super.init(nibName: nil, bundle: nil) } - + override func viewDidLoad() { super.viewDidLoad() - - let margins = self.view.layoutMarginsGuide - + + let margins = view.layoutMarginsGuide + if #available(iOS 13, *) { self.view.backgroundColor = .systemBackground } else { - self.view.backgroundColor = .white + view.backgroundColor = .white } - + // ScrollView Setup let scrollView = UIScrollView() scrollView.translatesAutoresizingMaskIntoConstraints = false self.scrollView = scrollView scrollView.alwaysBounceHorizontal = false scrollView.alwaysBounceVertical = true - self.view.addSubview(scrollView) - + view.addSubview(scrollView) + // NavBar var navBar: UIView? if UIDevice.current.userInterfaceIdiom == .pad, let scene = UIApplication.shared.connectedScenes.compactMap({ $0 as? UIWindowScene }).first, let win = scene.windows.first(where: { $0.isKeyWindow }), - win.traitCollection.horizontalSizeClass != .compact { - navBar = UIView.init() + win.traitCollection.horizontalSizeClass != .compact + { + navBar = UIView() navBar!.translatesAutoresizingMaskIntoConstraints = false - + // Back Button - let backButton = UIButton.init(type: .system) + let backButton = UIButton(type: .system) self.backButton = backButton backButton.translatesAutoresizingMaskIntoConstraints = false backButton.setTitle(Strings.Generic.back, for: .normal) backButton.setTitleColor(TPPConfiguration.mainColor(), for: .normal) backButton.contentHorizontalAlignment = .left - backButton.contentEdgeInsets = UIEdgeInsets.init(top: 0, left: 0, bottom: 0, right: 2) + backButton.contentEdgeInsets = UIEdgeInsets(top: 0, left: 0, bottom: 0, right: 2) backButton.addTarget(self, action: #selector(backButtonWasPressed), for: .touchDown) navBar!.addSubview(backButton) - + // Close Button - let closeButton = UIButton.init(type: .system) + let closeButton = UIButton(type: .system) self.closeButton = closeButton closeButton.translatesAutoresizingMaskIntoConstraints = false closeButton.setTitle(Strings.Generic.close, for: .normal) closeButton.setTitleColor(TPPConfiguration.mainColor(), for: .normal) closeButton.contentHorizontalAlignment = .right - closeButton.contentEdgeInsets = UIEdgeInsets.init(top: 0, left: 2, bottom: 0, right: 0) + closeButton.contentEdgeInsets = UIEdgeInsets(top: 0, left: 2, bottom: 0, right: 0) closeButton.addTarget(self, action: #selector(closeButtonWasPressed), for: .touchDown) navBar!.addSubview(closeButton) - + scrollView.addSubview(navBar!) - + // Layout navbar let buttonHeight = max(backButton.intrinsicContentSize.height, closeButton.intrinsicContentSize.height) navBar!.topAnchor.constraint(equalTo: scrollView.topAnchor, constant: elementSpacing).isActive = true navBar!.leadingAnchor.constraint(equalTo: margins.leadingAnchor).isActive = true navBar!.trailingAnchor.constraint(equalTo: margins.trailingAnchor).isActive = true - navBar!.widthAnchor.constraint(greaterThanOrEqualToConstant: backButton.intrinsicContentSize.width + closeButton.intrinsicContentSize.width).isActive = true + navBar!.widthAnchor + .constraint(greaterThanOrEqualToConstant: backButton.intrinsicContentSize.width + closeButton + .intrinsicContentSize.width + ).isActive = true navBar!.heightAnchor.constraint(equalToConstant: buttonHeight).isActive = true - + backButton.leadingAnchor.constraint(equalTo: navBar!.leadingAnchor).isActive = true backButton.topAnchor.constraint(equalTo: navBar!.topAnchor).isActive = true - + closeButton.trailingAnchor.constraint(equalTo: navBar!.trailingAnchor).isActive = true closeButton.topAnchor.constraint(equalTo: navBar!.topAnchor).isActive = true } // Info Label - let label = UILabel.init() + let label = UILabel() self.label = label label.translatesAutoresizingMaskIntoConstraints = false label.numberOfLines = 0 - label.attributedText = generateAttributedText(problemDocument: self.doc) - + label.attributedText = generateAttributedText(problemDocument: doc) + // Submit button - let submitButton = UIButton.init(type: .roundedRect) + let submitButton = UIButton(type: .roundedRect) self.submitButton = submitButton submitButton.translatesAutoresizingMaskIntoConstraints = false submitButton.setTitle("Send to Support", for: .normal) submitButton.addTarget(self, action: #selector(submitButtonWasPressed), for: .touchDown) - + scrollView.addSubview(label) scrollView.addSubview(submitButton) - + // Layout if navBar != nil { label.topAnchor.constraint(equalTo: navBar!.bottomAnchor, constant: elementSpacing).isActive = true @@ -112,35 +116,35 @@ } label.leadingAnchor.constraint(equalTo: margins.leadingAnchor).isActive = true label.trailingAnchor.constraint(equalTo: margins.trailingAnchor).isActive = true - + submitButton.topAnchor.constraint(equalTo: label.bottomAnchor, constant: elementSpacing).isActive = true - submitButton.centerXAnchor.constraint(equalTo: self.view.centerXAnchor).isActive = true - - scrollView.leadingAnchor.constraint(equalTo: self.view.leadingAnchor).isActive = true - scrollView.topAnchor.constraint(equalTo: self.view.topAnchor).isActive = true - scrollView.bottomAnchor.constraint(equalTo: self.view.bottomAnchor).isActive = true - scrollView.trailingAnchor.constraint(equalTo: self.view.trailingAnchor).isActive = true - + submitButton.centerXAnchor.constraint(equalTo: view.centerXAnchor).isActive = true + + scrollView.leadingAnchor.constraint(equalTo: view.leadingAnchor).isActive = true + scrollView.topAnchor.constraint(equalTo: view.topAnchor).isActive = true + scrollView.bottomAnchor.constraint(equalTo: view.bottomAnchor).isActive = true + scrollView.trailingAnchor.constraint(equalTo: view.trailingAnchor).isActive = true + // ScrollContentSize scrollView.contentSize = calculateContentSize() } - + override func viewDidLayoutSubviews() { super.viewDidLayoutSubviews() - - self.scrollView?.contentSize = calculateContentSize() + + scrollView?.contentSize = calculateContentSize() } - + func calculateContentSize() -> CGSize { var height = CGFloat.zero - height += max(self.backButton?.bounds.height ?? 0, self.closeButton?.bounds.height ?? 0) - height += self.label?.bounds.height ?? 0 - height += self.submitButton?.bounds.height ?? 0 - height += self.elementSpacing * (self.backButton == nil ? 3 : 4) - let width = max(self.submitButton?.bounds.width ?? 0, (self.backButton?.bounds.width ?? 0) + (self.closeButton?.bounds.width ?? 0)) + height += max(backButton?.bounds.height ?? 0, closeButton?.bounds.height ?? 0) + height += label?.bounds.height ?? 0 + height += submitButton?.bounds.height ?? 0 + height += elementSpacing * (backButton == nil ? 3 : 4) + let width = max(submitButton?.bounds.width ?? 0, (backButton?.bounds.width ?? 0) + (closeButton?.bounds.width ?? 0)) return CGSize(width: width, height: height) } - + func generateAttributedText(problemDocument: TPPProblemDocument) -> NSAttributedString { let normalFont = UIFont.palaceFont(ofSize: 12) let boldFont = UIFont.boldPalaceFont(ofSize: 12) @@ -149,34 +153,38 @@ let status = problemDocument.status == nil ? "n/a" : "\(problemDocument.status!)" let detail = problemDocument.detail ?? "n/a" let instance = problemDocument.instance ?? "n/a" - let result = NSMutableAttributedString.init() - result.append(NSAttributedString(string: "Type:\n", attributes: [.font : boldFont])) - result.append(NSAttributedString(string: "\(type)\n\n", attributes: [.font : normalFont])) - result.append(NSAttributedString(string: "Title:\n", attributes: [.font : boldFont])) - result.append(NSAttributedString(string: "\(title)\n\n", attributes: [.font : normalFont])) - result.append(NSAttributedString(string: "Status:\n", attributes: [.font : boldFont])) - result.append(NSAttributedString(string: "\(status)\n\n", attributes: [.font : normalFont])) - result.append(NSAttributedString(string: "Detail:\n", attributes: [.font : boldFont])) - result.append(NSAttributedString(string: "\(detail)\n\n", attributes: [.font : normalFont])) - result.append(NSAttributedString(string: "Instance:\n", attributes: [.font : boldFont])) - result.append(NSAttributedString(string: "\(instance)\n", attributes: [.font : normalFont])) + let result = NSMutableAttributedString() + result.append(NSAttributedString(string: "Type:\n", attributes: [.font: boldFont])) + result.append(NSAttributedString(string: "\(type)\n\n", attributes: [.font: normalFont])) + result.append(NSAttributedString(string: "Title:\n", attributes: [.font: boldFont])) + result.append(NSAttributedString(string: "\(title)\n\n", attributes: [.font: normalFont])) + result.append(NSAttributedString(string: "Status:\n", attributes: [.font: boldFont])) + result.append(NSAttributedString(string: "\(status)\n\n", attributes: [.font: normalFont])) + result.append(NSAttributedString(string: "Detail:\n", attributes: [.font: boldFont])) + result.append(NSAttributedString(string: "\(detail)\n\n", attributes: [.font: normalFont])) + result.append(NSAttributedString(string: "Instance:\n", attributes: [.font: boldFont])) + result.append(NSAttributedString(string: "\(instance)\n", attributes: [.font: normalFont])) return result } - + // Selectors - + func backButtonWasPressed() { - self.navigationController?.popViewController(animated: true) + navigationController?.popViewController(animated: true) } - + func closeButtonWasPressed() { - self.dismiss(animated: true, completion: nil) + dismiss(animated: true, completion: nil) } - + func submitButtonWasPressed() { - let alert = UIAlertController.init(title: "Report a Problem", message: "Are you sure you want to email this error log to The Palace Project support?", preferredStyle: .alert) - alert.addAction(UIAlertAction.init(title: "Cancel", style: .cancel, handler: nil)) - alert.addAction(UIAlertAction.init(title: "Send email", style: .default, handler: { (action) in + let alert = UIAlertController( + title: "Report a Problem", + message: "Are you sure you want to email this error log to The Palace Project support?", + preferredStyle: .alert + ) + alert.addAction(UIAlertAction(title: "Cancel", style: .cancel, handler: nil)) + alert.addAction(UIAlertAction(title: "Send email", style: .default, handler: { _ in let labelText = self.label?.attributedText?.string ?? "" let body = """ \(labelText)\n\ @@ -193,6 +201,6 @@ body: body ) })) - self.present(alert, animated: true, completion: nil) + present(alert, animated: true, completion: nil) } } diff --git a/Palace/Catalog/TPPCatalogs+SE.swift b/Palace/Catalog/TPPCatalogs+SE.swift index 70065d72d..30c3d825b 100644 --- a/Palace/Catalog/TPPCatalogs+SE.swift +++ b/Palace/Catalog/TPPCatalogs+SE.swift @@ -9,6 +9,5 @@ import Foundation @objc extension NSObject { - func didSignOut() { - } + func didSignOut() {} } diff --git a/Palace/Catalog/TPPContentTypeBadge.swift b/Palace/Catalog/TPPContentTypeBadge.swift index b2846dddc..b754937ad 100644 --- a/Palace/Catalog/TPPContentTypeBadge.swift +++ b/Palace/Catalog/TPPContentTypeBadge.swift @@ -1,7 +1,6 @@ import UIKit final class TPPContentBadgeImageView: UIImageView { - @objc enum TPPBadgeImage: Int { case audiobook case ebook @@ -9,7 +8,7 @@ final class TPPContentBadgeImageView: UIImageView { func assetName() -> String { switch self { case .audiobook: - return "AudiobookBadge" + "AudiobookBadge" case .ebook: fatalError("No asset yet") } @@ -21,7 +20,8 @@ final class TPPContentBadgeImageView: UIImageView { setupView() } - required init?(coder aDecoder: NSCoder) { + @available(*, unavailable) + required init?(coder _: NSCoder) { fatalError("init(coder:) has not been implemented") } diff --git a/Palace/CatalogDomain/API/CatalogAPI.swift b/Palace/CatalogDomain/API/CatalogAPI.swift index 0113c584f..2892f9b71 100644 --- a/Palace/CatalogDomain/API/CatalogAPI.swift +++ b/Palace/CatalogDomain/API/CatalogAPI.swift @@ -1,10 +1,14 @@ import Foundation +// MARK: - CatalogAPI + public protocol CatalogAPI { func fetchFeed(at url: URL) async throws -> CatalogFeed? func search(query: String, baseURL: URL) async throws -> CatalogFeed? } +// MARK: - DefaultCatalogAPI + public final class DefaultCatalogAPI: CatalogAPI { public let client: NetworkClient public let parser: OPDSParser @@ -22,12 +26,16 @@ public final class DefaultCatalogAPI: CatalogAPI { public func search(query: String, baseURL: URL) async throws -> CatalogFeed? { guard let catalogFeed = try await fetchFeed(at: baseURL) else { - throw NSError(domain: NSURLErrorDomain, code: NSURLErrorBadURL, userInfo: [NSLocalizedDescriptionKey: "Could not load catalog feed"]) + throw NSError( + domain: NSURLErrorDomain, + code: NSURLErrorBadURL, + userInfo: [NSLocalizedDescriptionKey: "Could not load catalog feed"] + ) } - + let opdsFeed = catalogFeed.opdsFeed var searchURL: URL? - + if let links = opdsFeed.links as? [TPPOPDSLink] { for link in links { if link.rel == "search" && link.href != nil { @@ -36,20 +44,28 @@ public final class DefaultCatalogAPI: CatalogAPI { } } } - + if let searchURL = searchURL { return try await withCheckedThrowingContinuation { continuation in TPPOpenSearchDescription.withURL(searchURL, shouldResetCache: false) { description in guard let description = description else { - continuation.resume(throwing: NSError(domain: NSURLErrorDomain, code: NSURLErrorBadURL, userInfo: [NSLocalizedDescriptionKey: "Could not load OpenSearch description"])) + continuation.resume(throwing: NSError( + domain: NSURLErrorDomain, + code: NSURLErrorBadURL, + userInfo: [NSLocalizedDescriptionKey: "Could not load OpenSearch description"] + )) return } - + guard let searchResultURL = description.opdsurl(forSearching: query) else { - continuation.resume(throwing: NSError(domain: NSURLErrorDomain, code: NSURLErrorBadURL, userInfo: [NSLocalizedDescriptionKey: "Could not create search URL"])) + continuation.resume(throwing: NSError( + domain: NSURLErrorDomain, + code: NSURLErrorBadURL, + userInfo: [NSLocalizedDescriptionKey: "Could not create search URL"] + )) return } - + Task { do { let searchResults = try await self.fetchFeed(at: searchResultURL) @@ -65,12 +81,14 @@ public final class DefaultCatalogAPI: CatalogAPI { var items = comps?.queryItems ?? [] items.append(URLQueryItem(name: "q", value: query)) comps?.queryItems = items - guard let url = comps?.url else { - throw NSError(domain: NSURLErrorDomain, code: NSURLErrorBadURL, userInfo: [NSLocalizedDescriptionKey: "Could not create search URL"]) + guard let url = comps?.url else { + throw NSError( + domain: NSURLErrorDomain, + code: NSURLErrorBadURL, + userInfo: [NSLocalizedDescriptionKey: "Could not create search URL"] + ) } return try await fetchFeed(at: url) } } } - - diff --git a/Palace/CatalogDomain/Models/CatalogModels.swift b/Palace/CatalogDomain/Models/CatalogModels.swift index c35b945eb..b36e31140 100644 --- a/Palace/CatalogDomain/Models/CatalogModels.swift +++ b/Palace/CatalogDomain/Models/CatalogModels.swift @@ -1,29 +1,33 @@ import Foundation +// MARK: - CatalogFeed + public struct CatalogFeed { public let title: String public let entries: [CatalogEntry] public let opdsFeed: TPPOPDSFeed init?(feed: TPPOPDSFeed?) { - guard let feed else { return nil } - self.title = feed.title ?? "Catalog" - self.opdsFeed = feed + guard let feed else { + return nil + } + title = feed.title ?? "Catalog" + opdsFeed = feed let entries = (feed.entries as? [TPPOPDSEntry]) ?? [] self.entries = entries.map { CatalogEntry(entry: $0) } } } +// MARK: - CatalogEntry + public struct CatalogEntry: Identifiable { public let id: String public let title: String public let authors: [String] init(entry: TPPOPDSEntry) { - self.id = entry.identifier - self.title = entry.title - self.authors = (entry.authorStrings as? [String]) ?? [] + id = entry.identifier + title = entry.title + authors = (entry.authorStrings as? [String]) ?? [] } } - - diff --git a/Palace/CatalogDomain/Parsing/OPDSParser.swift b/Palace/CatalogDomain/Parsing/OPDSParser.swift index 8d8697a78..e87e6c2f6 100644 --- a/Palace/CatalogDomain/Parsing/OPDSParser.swift +++ b/Palace/CatalogDomain/Parsing/OPDSParser.swift @@ -7,18 +7,20 @@ public final class OPDSParser { public var errorDescription: String? { switch self { - case .invalidXML: return "Unable to parse OPDS XML." - case .invalidFeed: return "Invalid or unsupported OPDS feed format." + case .invalidXML: "Unable to parse OPDS XML." + case .invalidFeed: "Invalid or unsupported OPDS feed format." } } } func parseFeed(from data: Data) throws -> CatalogFeed { - guard let xml = TPPXML(data: data) else { throw ParserError.invalidXML } + guard let xml = TPPXML(data: data) else { + throw ParserError.invalidXML + } let feed = TPPOPDSFeed(xml: xml) - guard let catalogFeed = CatalogFeed(feed: feed) else { throw ParserError.invalidFeed } + guard let catalogFeed = CatalogFeed(feed: feed) else { + throw ParserError.invalidFeed + } return catalogFeed } } - - diff --git a/Palace/CatalogDomain/Repository/CatalogRepository.swift b/Palace/CatalogDomain/Repository/CatalogRepository.swift index 0d908b30f..911705124 100644 --- a/Palace/CatalogDomain/Repository/CatalogRepository.swift +++ b/Palace/CatalogDomain/Repository/CatalogRepository.swift @@ -1,48 +1,55 @@ import Foundation +// MARK: - CatalogRepositoryProtocol + public protocol CatalogRepositoryProtocol { func loadTopLevelCatalog(at url: URL) async throws -> CatalogFeed? func search(query: String, baseURL: URL) async throws -> CatalogFeed? func invalidateCache(for url: URL) } +// MARK: - CatalogRepository + public final class CatalogRepository: CatalogRepositoryProtocol { private let api: CatalogAPI private var memoryCache: [String: CachedFeed] = [:] private let cacheQueue = DispatchQueue(label: "catalog.cache.queue", qos: .userInitiated) - + private struct CachedFeed { let feed: CatalogFeed let timestamp: Date - + var isExpired: Bool { Date().timeIntervalSince(timestamp) > 600 // 10 minutes } } - + public init(api: CatalogAPI) { self.api = api } public func loadTopLevelCatalog(at url: URL) async throws -> CatalogFeed? { let cacheKey = url.absoluteString - + let cachedEntry = await withCheckedContinuation { continuation in cacheQueue.async { continuation.resume(returning: self.memoryCache[cacheKey]) } } - + if let entry = cachedEntry, !entry.isExpired { return entry.feed } - + // Fetch from API guard let feed = try await api.fetchFeed(at: url) else { - throw NSError(domain: "CatalogRepository", code: 0, - userInfo: [NSLocalizedDescriptionKey: "Failed to fetch catalog feed"]) + throw NSError( + domain: "CatalogRepository", + code: 0, + userInfo: [NSLocalizedDescriptionKey: "Failed to fetch catalog feed"] + ) } - + // Cache the result await withCheckedContinuation { continuation in cacheQueue.async { @@ -50,11 +57,11 @@ public final class CatalogRepository: CatalogRepositoryProtocol { continuation.resume() } } - + Task.detached(priority: .background) { await self.preloadRelatedFacets(from: feed) } - + return feed } @@ -68,29 +75,33 @@ public final class CatalogRepository: CatalogRepositoryProtocol { self.memoryCache[cacheKey] = nil } } - + // MARK: - Background Preloading - + private func preloadRelatedFacets(from feed: CatalogFeed) async { - guard let links = feed.opdsFeed.links as? [TPPOPDSLink] else { return } - + guard let links = feed.opdsFeed.links as? [TPPOPDSLink] else { + return + } + let facetURLs = links .filter { $0.rel == TPPOPDSRelationFacet } - .compactMap { $0.href } + .compactMap(\.href) .prefix(5) // Limit preloading to avoid excessive network usage - + for url in facetURLs { let cacheKey = url.absoluteString - + // Check if already cached let isCached = await withCheckedContinuation { continuation in cacheQueue.async { continuation.resume(returning: self.memoryCache[cacheKey] != nil) } } - - if isCached { continue } - + + if isCached { + continue + } + do { if let preloadedFeed = try await api.fetchFeed(at: url) { await withCheckedContinuation { continuation in @@ -106,5 +117,3 @@ public final class CatalogRepository: CatalogRepositoryProtocol { } } } - - diff --git a/Palace/CatalogUI/ViewModels/CatalogFilterModels.swift b/Palace/CatalogUI/ViewModels/CatalogFilterModels.swift index 98b185475..fdd0dc0b0 100644 --- a/Palace/CatalogUI/ViewModels/CatalogFilterModels.swift +++ b/Palace/CatalogUI/ViewModels/CatalogFilterModels.swift @@ -1,5 +1,7 @@ import Foundation +// MARK: - CatalogFilter + struct CatalogFilter: Identifiable, Hashable { let id: String let title: String @@ -7,6 +9,8 @@ struct CatalogFilter: Identifiable, Hashable { let active: Bool } +// MARK: - CatalogFilterGroup + struct CatalogFilterGroup: Identifiable, Hashable { let id: String let name: String diff --git a/Palace/CatalogUI/ViewModels/CatalogLaneMoreViewModel.swift b/Palace/CatalogUI/ViewModels/CatalogLaneMoreViewModel.swift index 68ae71f1a..ecc3d2d57 100644 --- a/Palace/CatalogUI/ViewModels/CatalogLaneMoreViewModel.swift +++ b/Palace/CatalogUI/ViewModels/CatalogLaneMoreViewModel.swift @@ -1,9 +1,12 @@ -import Foundation import Combine +import Foundation + +// MARK: - CatalogLaneMoreViewModel @MainActor final class CatalogLaneMoreViewModel: ObservableObject { // MARK: - Published Properties + @Published private(set) var lanes: [CatalogLaneModel] = [] @Published private(set) var ungroupedBooks: [TPPBook] = [] @Published private(set) var isLoading = true @@ -12,45 +15,49 @@ final class CatalogLaneMoreViewModel: ObservableObject { @Published var currentSort: CatalogSort = .titleAZ @Published var appliedSelections: Set = [] @Published var isApplyingFilters: Bool = false - + // MARK: - Private Properties + private let repository: CatalogRepositoryProtocol private let url: URL private var currentLoadTask: Task? - + // MARK: - Computed Properties + var activeFiltersCount: Int { appliedSelections.count } - + var allBooks: [TPPBook] { if !lanes.isEmpty { - return lanes.flatMap { $0.books } + return lanes.flatMap(\.books) } return ungroupedBooks } - + var sortedBooks: [TPPBook] { let books = allBooks.map { TPPBookRegistry.shared.updatedBookMetadata($0) ?? $0 } return sortBooks(books, by: currentSort) } - + // MARK: - Initialization + init(url: URL, repository: CatalogRepositoryProtocol = CatalogRepository()) { self.url = url self.repository = repository } - + // MARK: - Public Methods + func load() async { await fetchAndApplyFeed(at: url) } - + func refresh() async { (repository as? CatalogRepository)?.invalidateCache(for: url) await fetchAndApplyFeed(at: url) } - + func applySort(_ sort: CatalogSort) async { currentSort = sort // Re-sort the existing data @@ -66,13 +73,15 @@ final class CatalogLaneMoreViewModel: ObservableObject { ungroupedBooks = sortBooks(ungroupedBooks, by: sort) } } - + func applyFilters(_ selections: Set) async { - guard !selections.isEmpty else { return } - + guard !selections.isEmpty else { + return + } + isApplyingFilters = true appliedSelections = selections - + // Find the filter URLs to apply var filterURLs: [URL] = [] for group in facetGroups { @@ -82,15 +91,15 @@ final class CatalogLaneMoreViewModel: ObservableObject { } } } - + // Apply the first filter URL (simplified approach) if let firstURL = filterURLs.first { await fetchAndApplyFeed(at: firstURL) } - + isApplyingFilters = false } - + func clearFilters() async { appliedSelections.removeAll() await fetchAndApplyFeed(at: url) @@ -98,27 +107,32 @@ final class CatalogLaneMoreViewModel: ObservableObject { } // MARK: - Private Methods + private extension CatalogLaneMoreViewModel { func fetchAndApplyFeed(at url: URL) async { currentLoadTask?.cancel() currentLoadTask = Task { [weak self] in - guard let self else { return } - + guard let self else { + return + } + do { isLoading = true error = nil - + guard let feed = try await repository.loadTopLevelCatalog(at: url) else { await MainActor.run { self.error = "Failed to load catalog" } return } - + let mapped = await Task.detached(priority: .userInitiated) { - return await CatalogViewModel.mapFeed(feed) + await CatalogViewModel.mapFeed(feed) }.value - - if Task.isCancelled { return } - + + if Task.isCancelled { + return + } + await MainActor.run { self.lanes = mapped.lanes self.ungroupedBooks = mapped.ungroupedBooks @@ -126,7 +140,9 @@ private extension CatalogLaneMoreViewModel { self.isLoading = false } } catch { - if Task.isCancelled { return } + if Task.isCancelled { + return + } await MainActor.run { self.error = error.localizedDescription self.isLoading = false @@ -134,17 +150,17 @@ private extension CatalogLaneMoreViewModel { } } } - + func sortBooks(_ books: [TPPBook], by sort: CatalogSort) -> [TPPBook] { switch sort { case .titleAZ: - return books.sorted { $0.title.localizedCaseInsensitiveCompare($1.title) == .orderedAscending } + books.sorted { $0.title.localizedCaseInsensitiveCompare($1.title) == .orderedAscending } case .titleZA: - return books.sorted { $0.title.localizedCaseInsensitiveCompare($1.title) == .orderedDescending } + books.sorted { $0.title.localizedCaseInsensitiveCompare($1.title) == .orderedDescending } case .authorAZ: - return books.sorted { ($0.authors ?? "").localizedCaseInsensitiveCompare($1.authors ?? "") == .orderedAscending } + books.sorted { ($0.authors ?? "").localizedCaseInsensitiveCompare($1.authors ?? "") == .orderedAscending } case .authorZA: - return books.sorted { ($0.authors ?? "").localizedCaseInsensitiveCompare($1.authors ?? "") == .orderedDescending } + books.sorted { ($0.authors ?? "").localizedCaseInsensitiveCompare($1.authors ?? "") == .orderedDescending } } } } diff --git a/Palace/CatalogUI/ViewModels/CatalogSearchViewModel.swift b/Palace/CatalogUI/ViewModels/CatalogSearchViewModel.swift index afa0dbf52..743977ddd 100644 --- a/Palace/CatalogUI/ViewModels/CatalogSearchViewModel.swift +++ b/Palace/CatalogUI/ViewModels/CatalogSearchViewModel.swift @@ -1,40 +1,41 @@ -import Foundation import Combine +import Foundation // MARK: - SearchView Model + @MainActor class CatalogSearchViewModel: ObservableObject { @Published var searchQuery: String = "" @Published var filteredBooks: [TPPBook] = [] @Published var isLoading: Bool = false @Published var errorMessage: String? - + private var allBooks: [TPPBook] = [] private let repository: CatalogRepositoryProtocol private let baseURL: () -> URL? private var searchTask: Task? private var debounceTimer: Timer? - + init(repository: CatalogRepositoryProtocol, baseURL: @escaping () -> URL?) { self.repository = repository self.baseURL = baseURL } - + deinit { debounceTimer?.invalidate() searchTask?.cancel() } - + func updateBooks(_ books: [TPPBook]) { allBooks = books if searchQuery.isEmpty { filteredBooks = books } } - + func updateSearchQuery(_ query: String) { searchQuery = query - + debounceTimer?.invalidate() debounceTimer = Timer.scheduledTimer(withTimeInterval: 0.1, repeats: false) { [weak self] _ in Task { @MainActor in @@ -42,7 +43,7 @@ class CatalogSearchViewModel: ObservableObject { } } } - + func clearSearch() { searchQuery = "" debounceTimer?.invalidate() @@ -51,57 +52,66 @@ class CatalogSearchViewModel: ObservableObject { errorMessage = nil filteredBooks = allBooks } - + private func performSearch() { let query = searchQuery.trimmingCharacters(in: .whitespacesAndNewlines) - + // Cancel any existing search task searchTask?.cancel() - + guard !query.isEmpty else { // Show preloaded books when no search query filteredBooks = allBooks return } - + guard let url = baseURL() else { filteredBooks = [] return } - + searchTask = Task { [weak self] in do { - guard let self, !Task.isCancelled else { return } - - let feed = try await self.repository.search(query: query, baseURL: url) - - guard !Task.isCancelled else { return } - + guard let self, !Task.isCancelled else { + return + } + + let feed = try await repository.search(query: query, baseURL: url) + + guard !Task.isCancelled else { + return + } + await MainActor.run { - guard !Task.isCancelled else { return } - + guard !Task.isCancelled else { + return + } + if let feed = feed { // Extract books from search results let feedObjc = feed.opdsFeed var searchResults: [TPPBook] = [] - + if let opdsEntries = feedObjc.entries as? [TPPOPDSEntry] { searchResults = opdsEntries.compactMap { CatalogViewModel.makeBook(from: $0) } } - + self.filteredBooks = searchResults } else { self.filteredBooks = [] } } } catch { - guard !Task.isCancelled else { return } + guard !Task.isCancelled else { + return + } await MainActor.run { - guard !Task.isCancelled else { return } + guard !Task.isCancelled else { + return + } self?.filteredBooks = [] } } } } - } diff --git a/Palace/CatalogUI/ViewModels/CatalogViewModel.swift b/Palace/CatalogUI/ViewModels/CatalogViewModel.swift index d9aac5367..1473b80a4 100644 --- a/Palace/CatalogUI/ViewModels/CatalogViewModel.swift +++ b/Palace/CatalogUI/ViewModels/CatalogViewModel.swift @@ -1,5 +1,7 @@ -import Foundation import Combine +import Foundation + +// MARK: - CatalogViewModel @MainActor final class CatalogViewModel: ObservableObject { @@ -17,17 +19,18 @@ final class CatalogViewModel: ObservableObject { private let repository: CatalogRepositoryProtocol private let topLevelURLProvider: () -> URL? - + private var previousLanes: [CatalogLaneModel] = [] private var previousUngroupedBooks: [TPPBook] = [] private var previousFacetGroups: [CatalogFilterGroup] = [] private var previousEntryPoints: [CatalogFilter] = [] - + // MARK: - Public accessors for search + var searchRepository: CatalogRepositoryProtocol { repository } var searchBaseURL: () -> URL? { topLevelURLProvider } private var lastLoadedURL: URL? - private var currentLoadTask: Task? = nil + private var currentLoadTask: Task? init(repository: CatalogRepositoryProtocol, topLevelURLProvider: @escaping () -> URL?) { self.repository = repository @@ -37,28 +40,32 @@ final class CatalogViewModel: ObservableObject { // MARK: - Public API func load() async { - if (!lanes.isEmpty || !ungroupedBooks.isEmpty), let url = topLevelURLProvider(), url == lastLoadedURL { - return + if !lanes.isEmpty || !ungroupedBooks.isEmpty, let url = topLevelURLProvider(), url == lastLoadedURL { + return } - guard let url = topLevelURLProvider() else { + guard let url = topLevelURLProvider() else { await MainActor.run { self.isLoading = false } - return + return } - + await MainActor.run { self.isLoading = true self.errorMessage = nil } currentLoadTask?.cancel() - + currentLoadTask = Task { [weak self] in - guard let self, !Task.isCancelled else { return } - + guard let self, !Task.isCancelled else { + return + } + do { - guard let feed = try await self.repository.loadTopLevelCatalog(at: url) else { - guard !Task.isCancelled else { return } - await MainActor.run { + guard let feed = try await repository.loadTopLevelCatalog(at: url) else { + guard !Task.isCancelled else { + return + } + await MainActor.run { if !Task.isCancelled { self.errorMessage = "Failed to load catalog" self.isLoading = false @@ -67,16 +74,22 @@ final class CatalogViewModel: ObservableObject { return } - guard !Task.isCancelled else { return } - + guard !Task.isCancelled else { + return + } + let mapped = await Task.detached(priority: .userInitiated) { () -> MappedCatalog in return await Self.mapFeed(feed) }.value - guard !Task.isCancelled else { return } - + guard !Task.isCancelled else { + return + } + await MainActor.run { - guard !Task.isCancelled else { return } + guard !Task.isCancelled else { + return + } self.title = mapped.title self.entries = mapped.entries self.lanes = mapped.lanes @@ -87,15 +100,19 @@ final class CatalogViewModel: ObservableObject { self.isLoading = false } - guard !Task.isCancelled else { return } + guard !Task.isCancelled else { + return + } if !mapped.lanes.isEmpty { - let visibleBooks = mapped.lanes.prefix(3).flatMap { $0.books } - await self.prefetchThumbnails(for: Array(visibleBooks.prefix(30))) + let visibleBooks = mapped.lanes.prefix(3).flatMap(\.books) + await prefetchThumbnails(for: Array(visibleBooks.prefix(30))) } else if !mapped.ungroupedBooks.isEmpty { - await self.prefetchThumbnails(for: Array(mapped.ungroupedBooks.prefix(20))) + await prefetchThumbnails(for: Array(mapped.ungroupedBooks.prefix(20))) } } catch { - guard !Task.isCancelled else { return } + guard !Task.isCancelled else { + return + } await MainActor.run { if !Task.isCancelled { self.errorMessage = error.localizedDescription @@ -107,7 +124,9 @@ final class CatalogViewModel: ObservableObject { } func refresh() async { - guard let url = topLevelURLProvider() else { return } + guard let url = topLevelURLProvider() else { + return + } (repository as? CatalogRepository)?.invalidateCache(for: url) lanes.removeAll() ungroupedBooks.removeAll() @@ -118,15 +137,17 @@ final class CatalogViewModel: ObservableObject { @MainActor func applyFacet(_ facet: CatalogFilter) async { - guard let href = facet.href else { return } - + guard let href = facet.href else { + return + } + storePreviousState() - + isOptimisticLoading = true errorMessage = nil - + updateFacetGroupsOptimistically(selectedFacet: facet) - + do { if let feed = try await repository.loadTopLevelCatalog(at: href) { let feedObjc = feed.opdsFeed @@ -134,38 +155,44 @@ final class CatalogViewModel: ObservableObject { case .acquisitionUngrouped: if let opdsEntries = feedObjc.entries as? [TPPOPDSEntry] { let newUngrouped = opdsEntries.compactMap { Self.makeBook(from: $0) } - self.lanes = [] - self.ungroupedBooks = newUngrouped + lanes = [] + ungroupedBooks = newUngrouped } let (groups, entries) = Self.extractFacets(from: feedObjc) - self.facetGroups = groups - self.entryPoints = entries + facetGroups = groups + entryPoints = entries case .acquisitionGrouped: var groupTitleToBooks: [String: [TPPBook]] = [:] var groupTitleToMoreURL: [String: URL?] = [:] var orderedTitles: [String] = [] if let opdsEntries = feedObjc.entries as? [TPPOPDSEntry] { for entry in opdsEntries { - guard let group = entry.groupAttributes else { continue } + guard let group = entry.groupAttributes else { + continue + } let groupTitle = group.title ?? "" if let book = Self.makeBook(from: entry) { - if groupTitleToBooks[groupTitle] == nil { orderedTitles.append(groupTitle) } + if groupTitleToBooks[groupTitle] == nil { + orderedTitles.append(groupTitle) + } groupTitleToBooks[groupTitle, default: []].append(book) - if groupTitleToMoreURL[groupTitle] == nil { groupTitleToMoreURL[groupTitle] = group.href } + if groupTitleToMoreURL[groupTitle] == nil { + groupTitleToMoreURL[groupTitle] = group.href + } } } } - self.ungroupedBooks = [] + ungroupedBooks = [] let (_, entries) = Self.extractFacets(from: feedObjc) - self.facetGroups = [] - self.entryPoints = entries - self.lanes = orderedTitles.map { title in + facetGroups = [] + entryPoints = entries + lanes = orderedTitles.map { title in let books = groupTitleToBooks[title] ?? [] let isLoading = books.count < 3 return CatalogLaneModel( - title: title, - books: books, - moreURL: groupTitleToMoreURL[title] ?? nil, + title: title, + books: books, + moreURL: groupTitleToMoreURL[title], isLoading: isLoading ) } @@ -176,7 +203,7 @@ final class CatalogViewModel: ObservableObject { } } isOptimisticLoading = false - + triggerScrollToTop() } catch { restorePreviousState() @@ -188,56 +215,64 @@ final class CatalogViewModel: ObservableObject { /// Applies an entry point (e.g., Ebooks/Audiobooks) with optimistic loading. @MainActor func applyEntryPoint(_ facet: CatalogFilter) async { - guard let href = facet.href else { return } - + guard let href = facet.href else { + return + } + storePreviousState() - + isContentReloading = true isOptimisticLoading = true errorMessage = nil - + updateEntryPointsOptimistically(selectedEntryPoint: facet) - + lanes.removeAll() ungroupedBooks.removeAll() currentLoadTask?.cancel() - + do { if let feed = try await repository.loadTopLevelCatalog(at: href) { let feedObjc = feed.opdsFeed switch feedObjc.type { case .acquisitionUngrouped: if let opdsEntries = feedObjc.entries as? [TPPOPDSEntry] { - self.ungroupedBooks = opdsEntries.compactMap { Self.makeBook(from: $0) } + ungroupedBooks = opdsEntries.compactMap { Self.makeBook(from: $0) } } let (groups, entries) = Self.extractFacets(from: feedObjc) - self.facetGroups = groups - self.entryPoints = entries + facetGroups = groups + entryPoints = entries case .acquisitionGrouped: var groupTitleToBooks: [String: [TPPBook]] = [:] var groupTitleToMoreURL: [String: URL?] = [:] var orderedTitles: [String] = [] if let opdsEntries = feedObjc.entries as? [TPPOPDSEntry] { for entry in opdsEntries { - guard let group = entry.groupAttributes else { continue } + guard let group = entry.groupAttributes else { + continue + } let groupTitle = group.title ?? "" if let book = Self.makeBook(from: entry) { - if groupTitleToBooks[groupTitle] == nil { orderedTitles.append(groupTitle) } + if groupTitleToBooks[groupTitle] == nil { + orderedTitles.append(groupTitle) + } groupTitleToBooks[groupTitle, default: []].append(book) - if groupTitleToMoreURL[groupTitle] == nil { groupTitleToMoreURL[groupTitle] = group.href } + if groupTitleToMoreURL[groupTitle] == nil { + groupTitleToMoreURL[groupTitle] = group.href + } } } } - self.ungroupedBooks = [] + ungroupedBooks = [] let (_, entries) = Self.extractFacets(from: feedObjc) - self.entryPoints = entries - self.lanes = orderedTitles.map { title in + entryPoints = entries + lanes = orderedTitles.map { title in let books = groupTitleToBooks[title] ?? [] let isLoading = books.count < 3 return CatalogLaneModel( - title: title, - books: books, - moreURL: groupTitleToMoreURL[title] ?? nil, + title: title, + books: books, + moreURL: groupTitleToMoreURL[title], isLoading: isLoading ) } @@ -248,7 +283,7 @@ final class CatalogViewModel: ObservableObject { } } isOptimisticLoading = false - + triggerScrollToTop() } catch { restorePreviousState() @@ -259,7 +294,9 @@ final class CatalogViewModel: ObservableObject { } func handleAccountChange() async { - guard let url = topLevelURLProvider() else { return } + guard let url = topLevelURLProvider() else { + return + } if lastLoadedURL == nil || url != lastLoadedURL { await MainActor.run { @@ -274,7 +311,7 @@ final class CatalogViewModel: ObservableObject { } } -// MARK: - Models +// MARK: - CatalogLaneModel struct CatalogLaneModel: Identifiable { let id = UUID() @@ -282,7 +319,7 @@ struct CatalogLaneModel: Identifiable { let books: [TPPBook] let moreURL: URL? let isLoading: Bool - + init(title: String, books: [TPPBook], moreURL: URL?, isLoading: Bool = false) { self.title = title self.books = books @@ -361,11 +398,16 @@ extension CatalogViewModel { if let entries = feed.entries as? [TPPOPDSEntry] { for entry in entries { if let group = entry.groupAttributes, - let book = makeBook(from: entry) { + let book = makeBook(from: entry) + { let title = group.title ?? "" - if titleToBooks[title] == nil { orderedTitles.append(title) } + if titleToBooks[title] == nil { + orderedTitles.append(title) + } titleToBooks[title, default: []].append(book) - if titleToMoreURL[title] == nil { titleToMoreURL[title] = group.href } + if titleToMoreURL[title] == nil { + titleToMoreURL[title] = group.href + } } } } @@ -373,9 +415,9 @@ extension CatalogViewModel { let books = titleToBooks[title] ?? [] let isLoading = books.count < 3 return CatalogLaneModel( - title: title, - books: books, - moreURL: titleToMoreURL[title] ?? nil, + title: title, + books: books, + moreURL: titleToMoreURL[title], isLoading: isLoading ) } @@ -389,7 +431,9 @@ extension CatalogViewModel { var entryPoints: [CatalogFilter] = [] for case let link as TPPOPDSLink in feed.links { - guard link.rel == TPPOPDSRelationFacet else { continue } + guard link.rel == TPPOPDSRelationFacet else { + continue + } var isEntryPoint = false var groupName: String? @@ -402,9 +446,13 @@ extension CatalogViewModel { } // Determine active flag from attributes - let isActive: Bool = link.attributes.contains { (k, v) in - guard let keyStr = k as? String, TPPOPDSAttributeKeyStringIsActiveFacet(keyStr) else { return false } - if let s = v as? String { return s.localizedCaseInsensitiveContains("true") } + let isActive: Bool = link.attributes.contains { k, v in + guard let keyStr = k as? String, TPPOPDSAttributeKeyStringIsActiveFacet(keyStr) else { + return false + } + if let s = v as? String { + return s.localizedCaseInsensitiveContains("true") + } return false } @@ -418,7 +466,9 @@ extension CatalogViewModel { if isEntryPoint { entryPoints.append(facet) } else if let groupName = groupName { - if !groupNames.contains(groupName) { groupNames.append(groupName) } + if !groupNames.contains(groupName) { + groupNames.append(groupName) + } groupToFacets[groupName, default: []].append(facet) } } @@ -436,52 +486,58 @@ extension CatalogViewModel { } static func makeBook(from entry: TPPOPDSEntry) -> TPPBook? { - guard var book = TPPBook(entry: entry) else { return nil } + guard var book = TPPBook(entry: entry) else { + return nil + } if let updated = TPPBookRegistry.shared.updatedBookMetadata(book) { book = updated } - if book.defaultBookContentType == .unsupported { return nil } - if book.defaultAcquisition == nil { return nil } + if book.defaultBookContentType == .unsupported { + return nil + } + if book.defaultAcquisition == nil { + return nil + } return book } - + // MARK: - Scroll Management - + func triggerScrollToTop() { shouldScrollToTop = false DispatchQueue.main.async { self.shouldScrollToTop = true } } - + func resetScrollTrigger() { shouldScrollToTop = false } - + // MARK: - Optimistic Loading Helpers - + private func storePreviousState() { previousLanes = lanes previousUngroupedBooks = ungroupedBooks previousFacetGroups = facetGroups previousEntryPoints = entryPoints } - + private func restorePreviousState() { lanes = previousLanes ungroupedBooks = previousUngroupedBooks facetGroups = previousFacetGroups entryPoints = previousEntryPoints } - + private func updateFacetGroupsOptimistically(selectedFacet: CatalogFilter) { var updatedGroups: [CatalogFilterGroup] = [] - + for group in facetGroups { var updatedFilters: [CatalogFilter] = [] - + for filter in group.filters { if filter.id == selectedFacet.id { let updatedFilter = CatalogFilter( @@ -501,7 +557,7 @@ extension CatalogViewModel { updatedFilters.append(updatedFilter) } } - + let updatedGroup = CatalogFilterGroup( id: group.id, name: group.name, @@ -509,13 +565,13 @@ extension CatalogViewModel { ) updatedGroups.append(updatedGroup) } - + facetGroups = updatedGroups } - + private func updateEntryPointsOptimistically(selectedEntryPoint: CatalogFilter) { var updatedEntryPoints: [CatalogFilter] = [] - + for entryPoint in entryPoints { if entryPoint.id == selectedEntryPoint.id { let updated = CatalogFilter( @@ -535,10 +591,7 @@ extension CatalogViewModel { updatedEntryPoints.append(updated) } } - + entryPoints = updatedEntryPoints } - } - - diff --git a/Palace/CatalogUI/Views/CatalogContentView.swift b/Palace/CatalogUI/Views/CatalogContentView.swift index cd120aea3..2bea4e4de 100644 --- a/Palace/CatalogUI/Views/CatalogContentView.swift +++ b/Palace/CatalogUI/Views/CatalogContentView.swift @@ -1,15 +1,16 @@ import SwiftUI // MARK: - CatalogContentView + struct CatalogContentView: View { @ObservedObject var viewModel: CatalogViewModel let onBookSelected: (TPPBook) -> Void let onLaneMoreTapped: (String, URL) -> Void - + var body: some View { VStack(alignment: .leading, spacing: 0) { selectorsView - + ScrollViewReader { proxy in ScrollView { LazyVStack(alignment: .leading, spacing: 24) { @@ -39,6 +40,7 @@ struct CatalogContentView: View { } // MARK: - Private Views + private extension CatalogContentView { @ViewBuilder var selectorsView: some View { @@ -90,6 +92,7 @@ private extension CatalogContentView { } // MARK: - CatalogLoadingView + struct CatalogLoadingView: View { var body: some View { VStack(alignment: .leading, spacing: 24) { @@ -100,4 +103,3 @@ struct CatalogLoadingView: View { .padding(.vertical, 0) } } - diff --git a/Palace/CatalogUI/Views/CatalogFiltersSheetView.swift b/Palace/CatalogUI/Views/CatalogFiltersSheetView.swift index 869841b26..86b359402 100644 --- a/Palace/CatalogUI/Views/CatalogFiltersSheetView.swift +++ b/Palace/CatalogUI/Views/CatalogFiltersSheetView.swift @@ -1,33 +1,38 @@ import SwiftUI -// MARK: - Keys & Utilities +// MARK: - FacetKey -private struct FacetKey { +private enum FacetKey { static func isAllTitle(_ title: String?) -> Bool { let t = (title ?? "").trimmingCharacters(in: .whitespacesAndNewlines).lowercased() return t == "all" || t == "all formats" || t == "all collections" || t == "all distributors" } + static func make(group: String, title: String, href: String) -> String { "\(group)|\(title)|\(href)" } } +// MARK: - FacetGroupModel + private struct FacetGroupModel: Identifiable { let id: String let name: String let items: [CatalogFilter] - + var orderedItems: [CatalogFilter] { items.sorted { lhs, rhs in let la = FacetKey.isAllTitle(lhs.title) let ra = FacetKey.isAllTitle(rhs.title) - if la == ra { return lhs.title < rhs.title } + if la == ra { + return lhs.title < rhs.title + } return la && !ra } } } -// MARK: - Public Sheet +// MARK: - CatalogFiltersSheetView struct CatalogFiltersSheetView: View { let facetGroups: [CatalogFilterGroup] @@ -35,10 +40,10 @@ struct CatalogFiltersSheetView: View { let onApply: () -> Void let onCancel: () -> Void let isApplying: Bool - + @State private var expanded: Set = [] @State private var tempSelection: Set = [] - + private var groups: [FacetGroupModel] { facetGroups .filter { !$0.name.lowercased().contains("sort") } @@ -50,7 +55,7 @@ struct CatalogFiltersSheetView: View { ) } } - + var body: some View { NavigationView { ScrollView { @@ -66,7 +71,7 @@ struct CatalogFiltersSheetView: View { .padding(.horizontal) .padding(.vertical, 12) .background(Color(UIColor.systemBackground)) - + if expanded.contains(group.id) { ForEach(group.orderedItems, id: \.id) { facet in FacetRowButton( @@ -82,7 +87,7 @@ struct CatalogFiltersSheetView: View { } } .padding(.top) - + ResultsButton(isApplying: isApplying, onApply: { selection = tempSelection onApply() @@ -108,38 +113,42 @@ private extension CatalogFiltersSheetView { href: facet.href?.absoluteString ?? "" ) } - + func toggle(_ groupID: String) { - if expanded.contains(groupID) { expanded.remove(groupID) } else { expanded.insert(groupID) } + if expanded.contains(groupID) { + expanded.remove(groupID) + } else { + expanded.insert(groupID) + } } - + func clearGroup(_ group: FacetGroupModel) { let groupKeys = Set(group.items.map { key(for: group, facet: $0) }) tempSelection.subtract(groupKeys) - + if let all = group.items.first(where: { FacetKey.isAllTitle($0.title) }) { tempSelection.insert(key(for: group, facet: all)) } else if let firstItem = group.items.first { tempSelection.insert(key(for: group, facet: firstItem)) } } - + func toggleTempSelection(in group: FacetGroupModel, facet: CatalogFilter) { let k = key(for: group, facet: facet) let groupKeys = Set(group.items.map { key(for: group, facet: $0) }) - + tempSelection.subtract(groupKeys) tempSelection.insert(k) } - + func configureInitialSelection() { expanded = [] tempSelection = selection - + for group in groups { let groupKeys = Set(group.items.map { key(for: group, facet: $0) }) let currentSelections = tempSelection.intersection(groupKeys) - + if currentSelections.isEmpty { if let all = group.items.first(where: { FacetKey.isAllTitle($0.title) }) { tempSelection.insert(key(for: group, facet: all)) @@ -156,14 +165,14 @@ private extension CatalogFiltersSheetView { } } -// MARK: - Subviews +// MARK: - FacetSectionHeader private struct FacetSectionHeader: View { let title: String let onClear: () -> Void let isExpanded: Bool let toggleExpanded: () -> Void - + var body: some View { HStack(spacing: 10) { Text(title).font(.headline).foregroundColor(.primary) @@ -177,7 +186,7 @@ private struct FacetSectionHeader: View { // no separator } .frame(width: 50) - + Button(action: toggleExpanded) { Image(systemName: "chevron.right") .rotationEffect(.degrees(isExpanded ? 90 : 0)) @@ -190,11 +199,13 @@ private struct FacetSectionHeader: View { } } +// MARK: - FacetRowButton + private struct FacetRowButton: View { let isSelected: Bool let title: String let onTap: () -> Void - + var body: some View { Button(action: onTap) { HStack { @@ -212,11 +223,13 @@ private struct FacetRowButton: View { } } +// MARK: - ResultsButton + private struct ResultsButton: View { let isApplying: Bool let onApply: () -> Void let onCancel: () -> Void - + var body: some View { HStack(spacing: 12) { Button(action: onCancel) { @@ -232,7 +245,7 @@ private struct ResultsButton: View { ) } .disabled(isApplying) - + Button(action: onApply) { HStack(spacing: 8) { if isApplying { @@ -240,7 +253,7 @@ private struct ResultsButton: View { .progressViewStyle(CircularProgressViewStyle(tint: buttonForeground)) .scaleEffect(0.8) } - + Text(isApplying ? "APPLYING..." : "APPLY FILTERS") .bold() } @@ -251,16 +264,16 @@ private struct ResultsButton: View { .clipShape(RoundedRectangle(cornerRadius: 8)) } .disabled(isApplying) - + Spacer() } } - + private var buttonBackground: Color { Color(UIColor { trait in trait.userInterfaceStyle == .dark ? .white : .black }) } + private var buttonForeground: Color { Color(UIColor { trait in trait.userInterfaceStyle == .dark ? .black : .white }) } } - diff --git a/Palace/CatalogUI/Views/CatalogLaneMoreContentView.swift b/Palace/CatalogUI/Views/CatalogLaneMoreContentView.swift index a919fc82f..aa8a93378 100644 --- a/Palace/CatalogUI/Views/CatalogLaneMoreContentView.swift +++ b/Palace/CatalogUI/Views/CatalogLaneMoreContentView.swift @@ -1,11 +1,12 @@ import SwiftUI // MARK: - CatalogLaneMoreContentView + struct CatalogLaneMoreContentView: View { @ObservedObject var viewModel: CatalogLaneMoreViewModel let onBookSelected: (TPPBook) -> Void let onLaneMoreTapped: (String, URL) -> Void - + var body: some View { if viewModel.isLoading { loadingView @@ -20,6 +21,7 @@ struct CatalogLaneMoreContentView: View { } // MARK: - Private Views + private extension CatalogLaneMoreContentView { var loadingView: some View { ScrollView { @@ -27,12 +29,12 @@ private extension CatalogLaneMoreContentView { } .frame(maxWidth: .infinity, maxHeight: .infinity) } - + func errorView(_ error: String) -> some View { Text(error) .padding() } - + var lanesView: some View { ScrollView { LazyVStack(alignment: .leading, spacing: 24) { @@ -52,7 +54,7 @@ private extension CatalogLaneMoreContentView { } .refreshable { await viewModel.refresh() } } - + var booksListView: some View { ScrollView { BookListView( @@ -64,4 +66,3 @@ private extension CatalogLaneMoreContentView { .refreshable { await viewModel.refresh() } } } - diff --git a/Palace/CatalogUI/Views/CatalogLaneMoreView.swift b/Palace/CatalogUI/Views/CatalogLaneMoreView.swift index 9a1b32ff6..58afbcc7b 100644 --- a/Palace/CatalogUI/Views/CatalogLaneMoreView.swift +++ b/Palace/CatalogUI/Views/CatalogLaneMoreView.swift @@ -1,35 +1,37 @@ -import SwiftUI import Combine +import SwiftUI -// MARK: - Filter State Management +// MARK: - FilterState struct FilterState { - let appliedFilter: String // The filter that was applied to get to this state + let appliedFilter: String // The filter that was applied to get to this state let books: [TPPBook] let facetGroups: [CatalogFilterGroup] let feedURL: URL - let selectedFilters: Set // All filters applied up to this point + let selectedFilters: Set // All filters applied up to this point } +// MARK: - CatalogLaneMoreView + struct CatalogLaneMoreView: View { // MARK: - Properties - + let title: String let url: URL - + // MARK: - Content State @State private var lanes: [CatalogLaneModel] = [] @State private var ungroupedBooks: [TPPBook] = [] @State private var isLoading = true @State private var error: String? - + // MARK: - UI State @State private var showingSortSheet: Bool = false @State private var showingFiltersSheet: Bool = false @State private var showSearch: Bool = false - + // MARK: - Filter State @State private var facetGroups: [CatalogFilterGroup] = [] @@ -37,14 +39,14 @@ struct CatalogLaneMoreView: View { @State private var appliedSelections: Set = [] @State private var isApplyingFilters: Bool = false @State private var currentSort: CatalogSort = .titleAZ - + // MARK: - Account & Logo State - + @StateObject private var logoObserver = CatalogLogoObserver() @State private var currentAccountUUID: String = AccountsManager.shared.currentAccount?.uuid ?? "" - + // MARK: - Initialization - + init(title: String = "", url: URL) { self.title = title self.url = url @@ -99,14 +101,22 @@ struct CatalogLaneMoreView: View { pendingSelections.removeAll() Task { await load() } } - .onReceive(NotificationCenter.default.publisher(for: Notification.Name("ToggleSampleNotification")).receive(on: RunLoop.main)) { note in - guard let info = note.userInfo as? [String: Any], let identifier = info["bookIdentifier"] as? String else { return } + .onReceive(NotificationCenter.default.publisher(for: Notification.Name("ToggleSampleNotification")) + .receive(on: RunLoop.main) + ) { note in + guard let info = note.userInfo as? [String: Any], + let identifier = info["bookIdentifier"] as? String + else { + return + } let action = (info["action"] as? String) ?? "toggle" if action == "close" { SamplePreviewManager.shared.close() return } - if let book = TPPBookRegistry.shared.book(forIdentifier: identifier) ?? (lanes.flatMap { $0.books } + ungroupedBooks).first(where: { $0.identifier == identifier }) { + if let book = TPPBookRegistry.shared.book(forIdentifier: identifier) ?? (lanes.flatMap(\.books) + ungroupedBooks) + .first(where: { $0.identifier == identifier }) + { SamplePreviewManager.shared.toggle(for: book) } } @@ -123,12 +133,15 @@ struct CatalogLaneMoreView: View { // Prefer targeted updates using the download progress publisher to avoid rebuilding all lanes per tick .onReceive(MyBooksDownloadCenter.shared.downloadProgressPublisher .throttle(for: .milliseconds(350), scheduler: RunLoop.main, latest: true) - .map { $0.0 } - .removeDuplicates()) { changedId in - applyRegistryUpdates(changedIdentifier: changedId) - } + .map(\.0) + .removeDuplicates() + ) { changedId in + applyRegistryUpdates(changedIdentifier: changedId) + } .onChange(of: showingFiltersSheet) { presented in - guard presented else { return } + guard presented else { + return + } // Set up pending selections based on current applied state // Don't reset until user actually applies - preserve existing filtering if they cancel if !appliedSelections.isEmpty { @@ -160,7 +173,7 @@ struct CatalogLaneMoreView: View { isLoading = true error = nil defer { isLoading = false } - + if let savedState = coordinator.resolveCatalogFilterState(for: url) { restoreFilterState(savedState) if !appliedSelections.isEmpty { @@ -193,16 +206,22 @@ struct CatalogLaneMoreView: View { var titleToBooks: [String: [TPPBook]] = [:] var titleToMoreURL: [String: URL?] = [:] for entry in entries { - guard let group = entry.groupAttributes else { continue } + guard let group = entry.groupAttributes else { + continue + } let groupTitle = group.title ?? "" if let book = CatalogViewModel.makeBook(from: entry) { - if titleToBooks[groupTitle] == nil { orderedTitles.append(groupTitle) } + if titleToBooks[groupTitle] == nil { + orderedTitles.append(groupTitle) + } titleToBooks[groupTitle, default: []].append(book) - if titleToMoreURL[groupTitle] == nil { titleToMoreURL[groupTitle] = group.href } + if titleToMoreURL[groupTitle] == nil { + titleToMoreURL[groupTitle] = group.href + } } } lanes = orderedTitles.map { title in - CatalogLaneModel(title: title, books: titleToBooks[title] ?? [], moreURL: titleToMoreURL[title] ?? nil) + CatalogLaneModel(title: title, books: titleToBooks[title] ?? [], moreURL: titleToMoreURL[title]) } case .acquisitionUngrouped: ungroupedBooks = entries.compactMap { CatalogViewModel.makeBook(from: $0) } @@ -238,14 +257,22 @@ struct CatalogLaneMoreView: View { var changed = false for bIdx in books.indices { let book = books[bIdx] - if let changedIdentifier, book.identifier != changedIdentifier { continue } + if let changedIdentifier, book.identifier != changedIdentifier { + continue + } let updated = TPPBookRegistry.shared.updatedBookMetadata(book) ?? book if updated != book { books[bIdx] = updated changed = true } } - if changed { newLanes[idx] = CatalogLaneModel(title: newLanes[idx].title, books: books, moreURL: newLanes[idx].moreURL) } + if changed { + newLanes[idx] = CatalogLaneModel( + title: newLanes[idx].title, + books: books, + moreURL: newLanes[idx].moreURL + ) + } } lanes = newLanes } @@ -255,11 +282,17 @@ struct CatalogLaneMoreView: View { var anyChanged = false for idx in books.indices { let book = books[idx] - if let changedIdentifier, book.identifier != changedIdentifier { continue } + if let changedIdentifier, book.identifier != changedIdentifier { + continue + } let updated = TPPBookRegistry.shared.updatedBookMetadata(book) ?? book - if updated != book { books[idx] = updated; anyChanged = true } + if updated != book { + books[idx] = updated; anyChanged = true + } + } + if anyChanged { + ungroupedBooks = books } - if anyChanged { ungroupedBooks = books } } } @@ -302,7 +335,7 @@ struct CatalogLaneMoreView: View { private func clearActiveFacets() async { for group in facetGroups { let facets = group.filters - if facets.contains(where: { $0.active }), let first = facets.first, let href = first.href { + if facets.contains(where: \.active), let first = facets.first, let href = first.href { await applyFacetHref(href) } } @@ -326,7 +359,9 @@ struct CatalogLaneMoreView: View { private func applySingleFilters() async { let specificFilters: [ParsedKey] = pendingSelections .compactMap { selection in - guard let parsed = parseKey(selection) else { return nil } + guard let parsed = parseKey(selection) else { + return nil + } return parsed.isDefaultTitle ? nil : parsed } @@ -350,29 +385,29 @@ struct CatalogLaneMoreView: View { let api = DefaultCatalogAPI(client: client, parser: parser) // FRESH START: Reset to original feed and rebuild filter sequence completely - await fetchAndApplyFeed(at: url) // This gives us clean facet groups + await fetchAndApplyFeed(at: url) // This gives us clean facet groups var currentFacetGroups = facetGroups - + // Sort filters by priority for consistent application order let sortedFilters = specificFilters.sorted { filter1, filter2 in let priority1 = getGroupPriority(filter1.group) let priority2 = getGroupPriority(filter2.group) return priority1 < priority2 } - + // Apply each filter sequentially, starting fresh each time for filter in sortedFilters { // Find the filter in the current facet groups if let filterURL = findFilterInCurrentFacets(filter, in: currentFacetGroups) { if let feed = try await api.fetchFeed(at: filterURL) { // Update books and facets with this filter's results - if let entries = feed.opdsFeed.entries as? [TPPOPDSEntry] { - ungroupedBooks = entries.compactMap { CatalogViewModel.makeBook(from: $0) } - sortBooksInPlace() - } - + if let entries = feed.opdsFeed.entries as? [TPPOPDSEntry] { + ungroupedBooks = entries.compactMap { CatalogViewModel.makeBook(from: $0) } + sortBooksInPlace() + } + // Update facet groups for next filter (preserves current filter state) - if feed.opdsFeed.type == TPPOPDSFeedType.acquisitionUngrouped { + if feed.opdsFeed.type == TPPOPDSFeedType.acquisitionUngrouped { currentFacetGroups = CatalogViewModel.extractFacets(from: feed.opdsFeed).0 } } @@ -383,26 +418,27 @@ struct CatalogLaneMoreView: View { appliedSelections = Set( specificFilters.map { makeGroupTitleKey(group: $0.group, title: $0.title) } ) - + saveFilterState() } catch { self.error = error.localizedDescription } } - - + /// Reconstruct pending selections from applied selections using current facets private func reconstructSelectionsFromCurrentFacets() -> Set { var reconstructed: Set = [] - + for appliedSelection in appliedSelections { let parts = appliedSelection.split(separator: "|", omittingEmptySubsequences: false).map(String.init) - guard parts.count >= 2 else { continue } - + guard parts.count >= 2 else { + continue + } + let groupName = parts[0] let title = parts[1] - + // Find this filter in the fresh facet groups for group in facetGroups where group.name == groupName { for filter in group.filters where filter.title == title { @@ -412,10 +448,10 @@ struct CatalogLaneMoreView: View { } } } - + return reconstructed } - + /// Find a specific filter in the current facet groups (simplified approach) private func findFilterInCurrentFacets(_ filter: ParsedKey, in currentFacetGroups: [CatalogFilterGroup]) -> URL? { for group in currentFacetGroups { @@ -435,16 +471,21 @@ struct CatalogLaneMoreView: View { // MARK: - Sort private enum CatalogSort: CaseIterable { - case authorAZ, authorZA, recentlyAddedAZ, recentlyAddedZA, titleAZ, titleZA + case authorAZ + case authorZA + case recentlyAddedAZ + case recentlyAddedZA + case titleAZ + case titleZA var localizedString: String { switch self { - case .authorAZ: return "Author (A-Z)" - case .authorZA: return "Author (Z-A)" - case .recentlyAddedAZ: return "Recently Added (A-Z)" - case .recentlyAddedZA: return "Recently Added (Z-A)" - case .titleAZ: return "Title (A-Z)" - case .titleZA: return "Title (Z-A)" + case .authorAZ: "Author (A-Z)" + case .authorZA: "Author (Z-A)" + case .recentlyAddedAZ: "Recently Added (A-Z)" + case .recentlyAddedZA: "Recently Added (Z-A)" + case .titleAZ: "Title (A-Z)" + case .titleZA: "Title (Z-A)" } } } @@ -482,6 +523,7 @@ struct CatalogLaneMoreView: View { private func makeKey(group: String, title: String, hrefString: String) -> String { "\(group)|\(title)|\(hrefString)" } + /// Group-title-only key private func makeGroupTitleKey(group: String, title: String) -> String { "\(group)|\(title)" @@ -489,7 +531,9 @@ struct CatalogLaneMoreView: View { internal func parseKey(_ key: String) -> ParsedKey? { let parts = key.split(separator: "|", omittingEmptySubsequences: false).map(String.init) - guard parts.count >= 3 else { return nil } + guard parts.count >= 3 else { + return nil + } return ParsedKey(group: parts[0], title: parts[1], hrefString: parts[2]) } @@ -498,7 +542,9 @@ struct CatalogLaneMoreView: View { var out: Set = [] let wanted: [String: Set] = Dictionary(grouping: keys.compactMap { key -> (String, String)? in let parts = key.split(separator: "|", omittingEmptySubsequences: false).map(String.init) - guard parts.count >= 2 else { return nil } + guard parts.count >= 2 else { + return nil + } return (parts[0], parts[1]) }) { $0.0 }.mapValues { Set($0.map { normalizeTitle($0.1) }) } for group in facetGroups where !group.name.lowercased().contains("sort") { @@ -527,8 +573,10 @@ struct CatalogLaneMoreView: View { private func selectionKeysFromActiveFacets(includeDefaults: Bool) -> Set { var out: [String] = [] for group in facetGroups { - if group.name.lowercased().contains("sort") { continue } - let facets = group.filters.filter { $0.active } + if group.name.lowercased().contains("sort") { + continue + } + let facets = group.filters.filter(\.active) for facet in facets { let rawTitle = facet.title let parsed = ParsedKey(group: group.name, title: rawTitle, hrefString: facet.href?.absoluteString ?? "") @@ -546,35 +594,42 @@ struct CatalogLaneMoreView: View { .filter { !$0.name.lowercased().contains("sort") } .flatMap { group in group.filters - .filter { $0.active } + .filter(\.active) .compactMap { facet -> (String, URL)? in let title = facet.title let url = facet.href - guard let url else { return nil } + guard let url else { + return nil + } let parsed = ParsedKey(group: group.name, title: title, hrefString: url.absoluteString) return (includeDefaults || !parsed.isDefaultTitle) ? (title, url) : nil } } - .map { $0.1 } + .map(\.1) } private var activeFiltersCount: Int { appliedSelections.filter { groupTitleKey in // appliedSelections contains group|title keys, need to check if title is default let parts = groupTitleKey.split(separator: "|", omittingEmptySubsequences: false).map(String.init) - guard parts.count >= 2 else { return false } + guard parts.count >= 2 else { + return false + } let title = parts[1] let t = title.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() let isDefaultTitle = t == "all" || t == "all formats" || t == "all collections" || t == "all distributors" - return !isDefaultTitle // Exclude "All" filters from count + return !isDefaultTitle // Exclude "All" filters from count }.count } - + /// Groups selected filters by their facet groups in priority order for proper chaining /// Returns [(groupName, [URL])] sorted by group priority - private func groupSelectedFiltersByFacetGroup(_ facetURLs: [URL], currentFacetGroups: [CatalogFilterGroup]) -> [(String, [URL])] { + private func groupSelectedFiltersByFacetGroup(_ facetURLs: [URL], currentFacetGroups: [CatalogFilterGroup]) -> [( + String, + [URL] + )] { var filtersByGroup: [String: [URL]] = [:] - + for url in facetURLs { if let groupName = findFacetGroupName(for: url, in: currentFacetGroups) { filtersByGroup[groupName, default: []].append(url) @@ -584,21 +639,21 @@ struct CatalogLaneMoreView: View { filtersByGroup[category, default: []].append(url) } } - + // Sort groups by priority (Collection first, then others) - return filtersByGroup.sorted { (group1, group2) in + return filtersByGroup.sorted { group1, group2 in let priority1 = getGroupPriority(group1.key) let priority2 = getGroupPriority(group2.key) return priority1 < priority2 } } - + /// Prioritizes selected filters for sequential application (additive OPDS 1.2 style) /// Returns filters in order they should be applied, with each building on the previous internal func prioritizeSelectedFilters(_ facetURLs: [URL], currentFacetGroups: [CatalogFilterGroup]) -> [URL] { // Group filters by their facet group, then prioritize groups var filtersByGroup: [String: [URL]] = [:] - + for url in facetURLs { if let groupName = findFacetGroupName(for: url, in: currentFacetGroups) { filtersByGroup[groupName, default: []].append(url) @@ -608,23 +663,23 @@ struct CatalogLaneMoreView: View { filtersByGroup[category, default: []].append(url) } } - + // Sort groups by priority, then return one filter per group in priority order - let sortedGroups = filtersByGroup.sorted { (group1, group2) in + let sortedGroups = filtersByGroup.sorted { group1, group2 in let priority1 = getGroupPriority(group1.key) let priority2 = getGroupPriority(group2.key) return priority1 < priority2 } - + // Take the first (or only) filter from each group // In OPDS 1.2, you can only have one active filter per group anyway - return sortedGroups.compactMap { $0.value.first } + return sortedGroups.compactMap(\.value.first) } - + /// Groups facet URLs by their type/group for sequential application private func groupFacetsByType(_ facetURLs: [URL], currentFacetGroups: [CatalogFilterGroup]) -> [(String, [URL])] { var groupedFacets: [String: [URL]] = [:] - + for url in facetURLs { if let groupName = findFacetGroupName(for: url, in: currentFacetGroups) { groupedFacets[groupName, default: []].append(url) @@ -634,15 +689,15 @@ struct CatalogLaneMoreView: View { groupedFacets[category, default: []].append(url) } } - + // Sort groups by priority - return groupedFacets.sorted { (group1, group2) in + return groupedFacets.sorted { group1, group2 in let priority1 = getGroupPriority(group1.key) let priority2 = getGroupPriority(group2.key) return priority1 < priority2 } } - + /// Finds the group name for a facet URL by matching it against current facet groups internal func findFacetGroupName(for url: URL, in facetGroups: [CatalogFilterGroup]) -> String? { for group in facetGroups { @@ -654,12 +709,12 @@ struct CatalogLaneMoreView: View { } return nil } - + /// Categorizes a facet URL when no group is found internal func categorizeFacetURL(_ url: URL) -> String { let urlString = url.absoluteString.lowercased() let queryString = url.query?.lowercased() ?? "" - + if urlString.contains("collection") || urlString.contains("library") { return "Collection" } else if urlString.contains("format") || urlString.contains("media") { @@ -674,41 +729,53 @@ struct CatalogLaneMoreView: View { return "Other" } } - + /// Gets priority for group ordering internal func getGroupPriority(_ groupName: String) -> Int { let name = groupName.lowercased() // Collection Name should be applied first (most restrictive) - if name.contains("collection") || name.contains("library") { return 1 } + if name.contains("collection") || name.contains("library") { + return 1 + } // Distributor comes next - if name.contains("distributor") { return 2 } + if name.contains("distributor") { + return 2 + } // Format filters - if name.contains("format") || name.contains("media") { return 3 } + if name.contains("format") || name.contains("media") { + return 3 + } // Availability filters - if name.contains("availability") || name.contains("available") { return 4 } + if name.contains("availability") || name.contains("available") { + return 4 + } // Language filters - if name.contains("language") || name.contains("lang") { return 5 } + if name.contains("language") || name.contains("lang") { + return 5 + } // Subject/Genre filters - if name.contains("subject") || name.contains("genre") { return 6 } + if name.contains("subject") || name.contains("genre") { + return 6 + } return 10 } - + /// Finds the equivalent facet URL in the current (updated) facet groups /// This ensures we use the server's updated links that preserve previous filter state private func findEquivalentFacetURL(originalURL: URL, in currentFacetGroups: [CatalogFilterGroup]) -> URL? { // Extract the filter title from the original URL to find the equivalent facet let originalKey = makeKeyFromURL(originalURL) - guard let originalParsed = parseKey(originalKey) else { - return originalURL + guard let originalParsed = parseKey(originalKey) else { + return originalURL } - + // Find the facet group that matches for group in currentFacetGroups { // Match by group name (case insensitive) let groupMatches = group.name.lowercased().contains(originalParsed.group.lowercased()) || - originalParsed.group.lowercased().contains(group.name.lowercased()) || - group.id.lowercased() == originalParsed.group.lowercased() - + originalParsed.group.lowercased().contains(group.name.lowercased()) || + group.id.lowercased() == originalParsed.group.lowercased() + if groupMatches { for filter in group.filters { // Match by filter title (case insensitive) @@ -720,7 +787,7 @@ struct CatalogLaneMoreView: View { } return originalURL } - + /// Finds the best facet URL to apply from the current facet groups /// This is key: it looks for the equivalent facet in the updated facet groups private func findBestFacetURL(for originalURLs: [URL], in currentFacetGroups: [CatalogFilterGroup]) -> URL? { @@ -734,7 +801,7 @@ struct CatalogLaneMoreView: View { } } } - + // If no exact match, try to find a similar facet by title/content if let parsedOriginal = parseKey(makeKeyFromURL(originalURL)) { for group in currentFacetGroups { @@ -746,36 +813,37 @@ struct CatalogLaneMoreView: View { } } } - + // Fallback: return the first original URL if no match found in current facets return originalURLs.first } - + /// Helper to create a key from URL for parsing private func makeKeyFromURL(_ url: URL) -> String { // Extract meaningful parts from URL query parameters to create a parseable key guard let components = URLComponents(url: url, resolvingAgainstBaseURL: true), - let queryItems = components.queryItems else { + let queryItems = components.queryItems + else { return "unknown|unknown|\(url.absoluteString)" } - + // Look for known facet parameters to determine group and title var group = "unknown" var title = "unknown" - + for item in queryItems { switch item.name.lowercased() { case "collectionname": group = "Collection Name" title = item.value?.replacingOccurrences(of: "+", with: " ") ?? "unknown" case "distributor": - group = "Distributor" + group = "Distributor" title = item.value?.replacingOccurrences(of: "+", with: " ") ?? "unknown" case "available": group = "Availability" - title = item.value == "now" ? "Available now" : - item.value == "always" ? "Yours to keep" : - item.value ?? "unknown" + title = item.value == "now" ? "Available now" : + item.value == "always" ? "Yours to keep" : + item.value ?? "unknown" case "format": group = "Format" title = item.value?.uppercased() ?? "unknown" @@ -786,67 +854,72 @@ struct CatalogLaneMoreView: View { continue } } - + return "\(group)|\(title)|\(url.absoluteString)" } - + /// Prioritizes facet URLs to ensure consistent application order /// Priority order: Collection/Library -> Format -> Availability -> Language -> Other private func prioritizeFacetURLs(_ facetURLs: [URL]) -> [URL] { - return facetURLs.sorted { url1, url2 in + facetURLs.sorted { url1, url2 in let priority1 = getFacetPriority(url1) let priority2 = getFacetPriority(url2) - + if priority1 != priority2 { - return priority1 < priority2 // Lower number = higher priority + return priority1 < priority2 // Lower number = higher priority } - + // If same priority, sort alphabetically for consistency return url1.absoluteString < url2.absoluteString } } - + /// Determines the priority of a facet based on its URL or content /// Lower numbers = higher priority (applied first) private func getFacetPriority(_ url: URL) -> Int { let urlString = url.absoluteString.lowercased() let queryString = url.query?.lowercased() ?? "" - + // Collection/Library filters should be applied first (broadest filter) - if urlString.contains("collection") || urlString.contains("library") || - queryString.contains("collection") || queryString.contains("library") { + if urlString.contains("collection") || urlString.contains("library") || + queryString.contains("collection") || queryString.contains("library") + { return 1 } - + // Format filters (epub, pdf, audiobook, etc.) if urlString.contains("format") || urlString.contains("media") || - queryString.contains("format") || queryString.contains("media") || - urlString.contains("epub") || urlString.contains("pdf") || urlString.contains("audiobook") { + queryString.contains("format") || queryString.contains("media") || + urlString.contains("epub") || urlString.contains("pdf") || urlString.contains("audiobook") + { return 2 } - + // Availability filters (available, checked out, etc.) if urlString.contains("availability") || urlString.contains("available") || - queryString.contains("availability") || queryString.contains("available") { + queryString.contains("availability") || queryString.contains("available") + { return 3 } - + // Language filters if urlString.contains("language") || urlString.contains("lang") || - queryString.contains("language") || queryString.contains("lang") { + queryString.contains("language") || queryString.contains("lang") + { return 4 } - + // Subject/Genre filters if urlString.contains("subject") || urlString.contains("genre") || - queryString.contains("subject") || queryString.contains("genre") { + queryString.contains("subject") || queryString.contains("genre") + { return 5 } - + // All other filters get lowest priority (applied last for fine-tuning) return 10 } - + private var SortOptionsSheet: some View { VStack(alignment: .leading, spacing: 0) { Text(Strings.Catalog.sortBy) @@ -874,9 +947,9 @@ struct CatalogLaneMoreView: View { .background(Color(UIColor.systemBackground)) } } - + // MARK: - Search Functionality - + @ViewBuilder private var searchSection: some View { CatalogSearchView( @@ -886,18 +959,18 @@ struct CatalogLaneMoreView: View { onBookSelected: presentBookDetail ) } - + private var allBooks: [TPPBook] { if !lanes.isEmpty { - return lanes.flatMap { $0.books } + return lanes.flatMap(\.books) } return ungroupedBooks } - + private func presentSearch() { withAnimation { showSearch = true } } - + private func dismissSearch() { withAnimation { showSearch = false } } @@ -906,7 +979,6 @@ struct CatalogLaneMoreView: View { // MARK: - View Sections private extension CatalogLaneMoreView { - @ViewBuilder var toolbarSection: some View { if showSearch { @@ -915,13 +987,13 @@ private extension CatalogLaneMoreView { filterToolbar } } - + @ViewBuilder var searchToolbar: some View { // Search toolbar would go here EmptyView() } - + @ViewBuilder var filterToolbar: some View { FacetToolbarView( @@ -938,10 +1010,10 @@ private extension CatalogLaneMoreView { loadingIndicator } } - + Divider() } - + @ViewBuilder var loadingIndicator: some View { HStack(spacing: 6) { @@ -956,7 +1028,7 @@ private extension CatalogLaneMoreView { } .padding(.trailing, 12) } - + @ViewBuilder var contentSection: some View { if showSearch { @@ -971,7 +1043,7 @@ private extension CatalogLaneMoreView { booksView } } - + @ViewBuilder var loadingView: some View { ScrollView { @@ -979,12 +1051,12 @@ private extension CatalogLaneMoreView { } .frame(maxWidth: .infinity, maxHeight: .infinity) } - + func errorView(_ errorMessage: String) -> some View { Text(errorMessage) .padding() } - + @ViewBuilder var lanesView: some View { ScrollView { @@ -1006,7 +1078,7 @@ private extension CatalogLaneMoreView { } .refreshable { await fetchAndApplyFeed(at: url) } } - + @ViewBuilder var booksView: some View { ScrollView { @@ -1016,9 +1088,9 @@ private extension CatalogLaneMoreView { } .refreshable { await fetchAndApplyFeed(at: url) } } - + // MARK: - Filter State Persistence - + private func saveFilterState() { let sortString = currentSort.localizedString let state = CatalogLaneFilterState( @@ -1028,11 +1100,11 @@ private extension CatalogLaneMoreView { ) coordinator.storeCatalogFilterState(state, for: url) } - + private func restoreFilterState(_ state: CatalogLaneFilterState) { appliedSelections = state.appliedSelections facetGroups = state.facetGroups - + // Convert string back to enum if let restoredSort = CatalogSort.allCases.first(where: { $0.localizedString == state.currentSort }) { currentSort = restoredSort diff --git a/Palace/CatalogUI/Views/CatalogLaneRowView.swift b/Palace/CatalogUI/Views/CatalogLaneRowView.swift index f7097e82f..cf407bb34 100644 --- a/Palace/CatalogUI/Views/CatalogLaneRowView.swift +++ b/Palace/CatalogUI/Views/CatalogLaneRowView.swift @@ -1,5 +1,7 @@ import SwiftUI +// MARK: - CatalogLaneRowView + struct CatalogLaneRowView: View { let title: String let books: [TPPBook] @@ -15,7 +17,7 @@ struct CatalogLaneRowView: View { Self.header(title: title, moreURL: moreURL, onMoreTapped: onMoreTapped) .padding(.horizontal, 12) } - + if isLoading || books.isEmpty { laneSkeletonScroller } else { @@ -38,7 +40,7 @@ struct CatalogLaneRowView: View { height: 150, usePulseSkeleton: true ) - .padding(.vertical) + .padding(.vertical) } .buttonStyle(.plain) } @@ -46,7 +48,7 @@ struct CatalogLaneRowView: View { .padding(.horizontal, 12) } } - + @ViewBuilder private var laneSkeletonScroller: some View { LaneSkeletonView() @@ -71,10 +73,11 @@ struct CatalogLaneRowView: View { } } -// MARK: - Lane Skeleton View +// MARK: - LaneSkeletonView + private struct LaneSkeletonView: View { @State private var pulse: Bool = false - + var body: some View { ScrollView(.horizontal, showsIndicators: false) { LazyHStack(spacing: 12) { @@ -95,5 +98,3 @@ private struct LaneSkeletonView: View { } } } - - diff --git a/Palace/CatalogUI/Views/CatalogLaneSkeletonView.swift b/Palace/CatalogUI/Views/CatalogLaneSkeletonView.swift index 7457d9845..cd9dc1407 100644 --- a/Palace/CatalogUI/Views/CatalogLaneSkeletonView.swift +++ b/Palace/CatalogUI/Views/CatalogLaneSkeletonView.swift @@ -2,7 +2,7 @@ import SwiftUI struct CatalogLaneSkeletonView: View { var titleWidth: CGFloat = 160 - var itemSize: CGSize = CGSize(width: 120, height: 180) + var itemSize: CGSize = .init(width: 120, height: 180) var itemCount: Int = 8 @State private var pulse: Bool = false @@ -33,5 +33,3 @@ struct CatalogLaneSkeletonView: View { } } } - - diff --git a/Palace/CatalogUI/Views/CatalogLoadingViewController.swift b/Palace/CatalogUI/Views/CatalogLoadingViewController.swift index 7601287c3..ef77d19e5 100644 --- a/Palace/CatalogUI/Views/CatalogLoadingViewController.swift +++ b/Palace/CatalogUI/Views/CatalogLoadingViewController.swift @@ -17,5 +17,3 @@ final class CatalogLoadingViewController: UIViewController, TPPLoadingViewContro stopLoading() } } - - diff --git a/Palace/CatalogUI/Views/CatalogSearchView.swift b/Palace/CatalogUI/Views/CatalogSearchView.swift index 033156916..54c0c8539 100644 --- a/Palace/CatalogUI/Views/CatalogSearchView.swift +++ b/Palace/CatalogUI/Views/CatalogSearchView.swift @@ -1,40 +1,40 @@ import SwiftUI -// MARK: - SearchView +// MARK: - CatalogSearchView + struct CatalogSearchView: View { @StateObject private var viewModel: CatalogSearchViewModel let books: [TPPBook] let onBookSelected: (TPPBook) -> Void - + init( repository: CatalogRepositoryProtocol, baseURL: @escaping () -> URL?, books: [TPPBook], onBookSelected: @escaping (TPPBook) -> Void ) { - self._viewModel = StateObject(wrappedValue: CatalogSearchViewModel(repository: repository, baseURL: baseURL)) + _viewModel = StateObject(wrappedValue: CatalogSearchViewModel(repository: repository, baseURL: baseURL)) self.books = books self.onBookSelected = onBookSelected } - + init( books: [TPPBook], onBookSelected: @escaping (TPPBook) -> Void ) { - let client = URLSessionNetworkClient() let parser = OPDSParser() let api = DefaultCatalogAPI(client: client, parser: parser) let dummyRepository = CatalogRepository(api: api) - self._viewModel = StateObject(wrappedValue: CatalogSearchViewModel(repository: dummyRepository, baseURL: { nil })) + _viewModel = StateObject(wrappedValue: CatalogSearchViewModel(repository: dummyRepository, baseURL: { nil })) self.books = books self.onBookSelected = onBookSelected } - + var body: some View { VStack(spacing: 0) { searchBar - + ScrollView { BookListView( books: viewModel.filteredBooks, @@ -53,6 +53,7 @@ struct CatalogSearchView: View { } // MARK: - Private Views + private extension CatalogSearchView { var searchBar: some View { ZStack { @@ -67,7 +68,7 @@ private extension CatalogSearchView { .background(Color.gray.opacity(0.2)) .cornerRadius(10) .padding(.horizontal) - + if !viewModel.searchQuery.isEmpty { HStack { Spacer() diff --git a/Palace/CatalogUI/Views/CatalogView.swift b/Palace/CatalogUI/Views/CatalogView.swift index f593e6def..863521b89 100644 --- a/Palace/CatalogUI/Views/CatalogView.swift +++ b/Palace/CatalogUI/Views/CatalogView.swift @@ -1,8 +1,11 @@ import SwiftUI import UIKit +// MARK: - CatalogView + struct CatalogView: View { // MARK: - Properties + @EnvironmentObject private var coordinator: NavigationCoordinator @StateObject private var viewModel: CatalogViewModel @StateObject private var logoObserver = CatalogLogoObserver() @@ -12,17 +15,19 @@ struct CatalogView: View { @State private var showSearch: Bool = false // MARK: - Initialization + init(viewModel: CatalogViewModel) { _viewModel = StateObject(wrappedValue: viewModel) } // MARK: - Body + var body: some View { content .padding(.top) .navigationBarTitleDisplayMode(.inline) .toolbar { toolbarContent } - .onAppear { + .onAppear { setupCurrentAccount() coordinator.clearAllCatalogFilterStates() } @@ -39,6 +44,7 @@ struct CatalogView: View { private extension CatalogView { // MARK: - Toolbar and UI Components + @ToolbarContentBuilder var toolbarContent: some ToolbarContent { ToolbarItem(placement: .principal) { @@ -51,7 +57,7 @@ private extension CatalogView { } .actionSheet(isPresented: $showAccountDialog) { libraryPicker } } - + ToolbarItem(placement: .navigationBarTrailing) { if showSearch { Button(action: { dismissSearch() }) { @@ -64,12 +70,12 @@ private extension CatalogView { } } } - + private var libraryPicker: ActionSheet { var buttons: [ActionSheet.Button] = TPPSettings.shared.settingsAccountsList.map { account in - .default(Text(account.name)) { - switchToAccount(account) - } + .default(Text(account.name)) { + switchToAccount(account) + } } buttons.append(.default(Text(Strings.MyBooksView.addLibrary)) { showAddLibrarySheet = true @@ -80,7 +86,7 @@ private extension CatalogView { buttons: buttons ) } - + @ViewBuilder var addLibrarySheet: some View { UIViewControllerWrapper( @@ -102,7 +108,6 @@ private extension CatalogView { } } - @ViewBuilder private var searchSection: some View { if showSearch { @@ -149,7 +154,7 @@ private extension CatalogView { EmptyView() } } - + func presentBookDetail(_ book: TPPBook) { coordinator.store(book: book) coordinator.push(.bookDetail(BookRoute(id: book.identifier))) @@ -164,38 +169,39 @@ private extension CatalogView { func presentSearch() { withAnimation { showSearch = true } } - + func dismissSearch() { - withAnimation { + withAnimation { showSearch = false } } - + func handleTabChange() { if showSearch { dismissSearch() } } - + // MARK: - Account Management + func setupCurrentAccount() { let account = AccountsManager.shared.currentAccount account?.logoDelegate = logoObserver account?.loadLogo() currentAccountUUID = account?.uuid ?? "" } - + func handleAccountChange() { let account = AccountsManager.shared.currentAccount account?.logoDelegate = logoObserver account?.loadLogo() currentAccountUUID = account?.uuid ?? "" - + coordinator.clearAllCatalogFilterStates() - + Task { await viewModel.handleAccountChange() } } - + func switchToAccount(_ account: Account) { AccountsManager.shared.currentAccount = account if let urlString = account.catalogUrl, let url = URL(string: urlString) { @@ -204,23 +210,23 @@ private extension CatalogView { NotificationCenter.default.post(name: .TPPCurrentAccountDidChange, object: nil) Task { await viewModel.refresh() } } - + func addAndSwitchToAccount(_ account: Account) { if !TPPSettings.shared.settingsAccountIdsList.contains(account.uuid) { TPPSettings.shared.settingsAccountIdsList.append(account.uuid) } switchToAccount(account) } - + // MARK: - Computed Properties + var allBooks: [TPPBook] { if !viewModel.lanes.isEmpty { - return viewModel.lanes.flatMap { $0.books } + return viewModel.lanes.flatMap(\.books) } return viewModel.ungroupedBooks } - // MARK: - Subviews /// Top-level skeleton used during initial load. diff --git a/Palace/CatalogUI/Views/FacetToolbarView.swift b/Palace/CatalogUI/Views/FacetToolbarView.swift index 6240c7fe1..2b9c70c1f 100644 --- a/Palace/CatalogUI/Views/FacetToolbarView.swift +++ b/Palace/CatalogUI/Views/FacetToolbarView.swift @@ -43,5 +43,3 @@ struct FacetToolbarView: View { .padding(.vertical, 8) } } - - diff --git a/Palace/CatalogUI/Views/FacetsSelectorView.swift b/Palace/CatalogUI/Views/FacetsSelectorView.swift index 15eb62355..cf7d73912 100644 --- a/Palace/CatalogUI/Views/FacetsSelectorView.swift +++ b/Palace/CatalogUI/Views/FacetsSelectorView.swift @@ -1,5 +1,7 @@ import SwiftUI +// MARK: - FacetsSelectorView + struct FacetsSelectorView: View { let facetGroups: [CatalogFilterGroup] let onSelect: (CatalogFilter) -> Void @@ -28,7 +30,7 @@ struct FacetsSelectorView: View { } } -// MARK: - Entry Points (Grouped feed filtering) +// MARK: - EntryPointsSelectorView struct EntryPointsSelectorView: View { let entryPoints: [CatalogFilter] @@ -60,7 +62,9 @@ struct EntryPointsSelectorView: View { } } .onChange(of: selectionIndex) { idx in - guard entryPoints.indices.contains(idx) else { return } + guard entryPoints.indices.contains(idx) else { + return + } pendingIndex = idx // Debounce slight delay to avoid double reloads when tabs change DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { @@ -84,5 +88,3 @@ struct EntryPointsSelectorView: View { private extension NSObject { @objc var _uuid: String { String(ObjectIdentifier(self).hashValue) } } - - diff --git a/Palace/CatalogUI/Views/LibraryLogoNotifier.swift b/Palace/CatalogUI/Views/LibraryLogoNotifier.swift index 9561300af..53577908e 100644 --- a/Palace/CatalogUI/Views/LibraryLogoNotifier.swift +++ b/Palace/CatalogUI/Views/LibraryLogoNotifier.swift @@ -5,12 +5,12 @@ extension Notification.Name { static let TPPAccountLogoUpdated = Notification.Name("TPPAccountLogoUpdated") } +// MARK: - CatalogLogoObserver + final class CatalogLogoObserver: NSObject, ObservableObject, AccountLogoDelegate { @Published var token = UUID() - func logoDidUpdate(in account: Account, to newLogo: UIImage) { + func logoDidUpdate(in _: Account, to _: UIImage) { DispatchQueue.main.async { self.token = UUID() } } } - - diff --git a/Palace/CatalogUI/Views/LibraryNavTitle.swift b/Palace/CatalogUI/Views/LibraryNavTitle.swift index b5c22060b..87185dd95 100644 --- a/Palace/CatalogUI/Views/LibraryNavTitle.swift +++ b/Palace/CatalogUI/Views/LibraryNavTitle.swift @@ -2,8 +2,10 @@ import Foundation import SwiftUI import UIKit +// MARK: - LibraryNavTitleView + struct LibraryNavTitleView: View { - var onTap: (() -> Void)? = nil + var onTap: (() -> Void)? @ViewBuilder var body: some View { @@ -33,6 +35,8 @@ struct LibraryNavTitleView: View { } } +// MARK: - LibraryNavTitleFactory + @objc final class LibraryNavTitleFactory: NSObject { @objc static func makeTitleView() -> UIView { let container = UIStackView() @@ -60,5 +64,3 @@ struct LibraryNavTitleView: View { return container } } - - diff --git a/Palace/ErrorHandling/NSError+NYPLAdditions.swift b/Palace/ErrorHandling/NSError+NYPLAdditions.swift index 696df891f..435d53f80 100644 --- a/Palace/ErrorHandling/NSError+NYPLAdditions.swift +++ b/Palace/ErrorHandling/NSError+NYPLAdditions.swift @@ -9,7 +9,6 @@ import Foundation extension NSError { - /// The localized description and recovery suggestion, if present, separated /// by a newline. @objc var localizedDescriptionWithRecovery: String { diff --git a/Palace/ErrorHandling/ProblemReportEmail.swift b/Palace/ErrorHandling/ProblemReportEmail.swift index bb4517b31..88438d8a6 100644 --- a/Palace/ErrorHandling/ProblemReportEmail.swift +++ b/Palace/ErrorHandling/ProblemReportEmail.swift @@ -1,120 +1,134 @@ import MessageUI import UIKit +// MARK: - ProblemReportEmail + @objcMembers class ProblemReportEmail: NSObject { typealias DisplayStrings = Strings.ProblemReportEmail static let sharedInstance = ProblemReportEmail() - + fileprivate weak var lastPresentingViewController: UIViewController? - + func beginComposing( to emailAddress: String, presentingViewController: UIViewController, - book: TPPBook?) - { + book: TPPBook? + ) { beginComposing(to: emailAddress, presentingViewController: presentingViewController, body: generateBody(book: book)) } - + func beginComposing( to emailAddress: String, presentingViewController: UIViewController, - body: String) - { + body: String + ) { guard MFMailComposeViewController.canSendMail() else { let alertController = UIAlertController( title: DisplayStrings.noAccountSetupTitle, - message: String(format: NSLocalizedString("Please contact %@ to report an issue.", comment: "Alert message"), - emailAddress), - preferredStyle: .alert) + message: String( + format: NSLocalizedString("Please contact %@ to report an issue.", comment: "Alert message"), + emailAddress + ), + preferredStyle: .alert + ) alertController.addAction( - UIAlertAction(title: Strings.Generic.ok, - style: .default, - handler: nil)) + UIAlertAction( + title: Strings.Generic.ok, + style: .default, + handler: nil + ) + ) presentingViewController.present(alertController, animated: true) return } - - self.lastPresentingViewController = presentingViewController - - let mailComposeViewController = MFMailComposeViewController.init() + + lastPresentingViewController = presentingViewController + + let mailComposeViewController = MFMailComposeViewController() mailComposeViewController.mailComposeDelegate = self mailComposeViewController.setSubject(TPPLocalizationNotNeeded("Problem Report")) mailComposeViewController.setToRecipients([emailAddress]) mailComposeViewController.setMessageBody(body, isHTML: false) presentingViewController.present(mailComposeViewController, animated: true) } - + func generateBody(book: TPPBook?) -> String { let nativeHeight = UIScreen.main.nativeBounds.height let systemVersion = UIDevice.current.systemVersion - let idiom: String - switch UIDevice.current.userInterfaceIdiom { + let idiom = switch UIDevice.current.userInterfaceIdiom { case .carPlay: - idiom = "carPlay" + "carPlay" case .pad: - idiom = "pad" + "pad" case .phone: - idiom = "phone" + "phone" case .tv: - idiom = "tv" + "tv" case .mac: - idiom = "mac" + "mac" default: - idiom = "unspecified" -//#if swift(>=5.9) + "unspecified" + // #if swift(>=5.9) // case .vision: // return "vision" -//#endif + // #endif // @unknown default: // for Xcode < 15 // idiom = "unspecified" } - + let appVersion = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "1.0" let bodyWithoutBook = "\n\n---\nIdiom: \(idiom)\nPlatform: iOS\nOS: \(systemVersion)\nHeight: \(nativeHeight)\nPalace Version: \(appVersion)\nLibrary: \(AccountsManager.shared.currentAccount?.name ?? "")" - let body: String - if let book = book { - body = bodyWithoutBook + "\nTitle: \(book.title)\nID: \(book.identifier)" + let body: String = if let book = book { + bodyWithoutBook + "\nTitle: \(book.title)\nID: \(book.identifier)" } else { - body = bodyWithoutBook + bodyWithoutBook } return body } } +// MARK: MFMailComposeViewControllerDelegate + extension ProblemReportEmail: MFMailComposeViewControllerDelegate { func mailComposeController( _ controller: MFMailComposeViewController, didFinishWith result: MFMailComposeResult, - error: Error?) - { + error: Error? + ) { controller.dismiss(animated: true, completion: nil) - + switch result { case .failed: if let error = error { let alertController = UIAlertController( title: Strings.Generic.error, message: error.localizedDescription, - preferredStyle: .alert) + preferredStyle: .alert + ) alertController.addAction( UIAlertAction( title: Strings.Generic.ok, style: .default, - handler: nil)) - self.lastPresentingViewController?.present(alertController, animated: true, completion: nil) + handler: nil + ) + ) + lastPresentingViewController?.present(alertController, animated: true, completion: nil) } case .sent: let alertController = UIAlertController( title: DisplayStrings.reportSentTitle, message: DisplayStrings.reportSentBody, - preferredStyle: .alert) + preferredStyle: .alert + ) alertController.addAction( UIAlertAction( title: Strings.Generic.ok, style: .default, - handler: nil)) - self.lastPresentingViewController?.present(alertController, animated: true, completion: nil) + handler: nil + ) + ) + lastPresentingViewController?.present(alertController, animated: true, completion: nil) case .cancelled: fallthrough case .saved: break diff --git a/Palace/ErrorHandling/TPPAlertUtils.swift b/Palace/ErrorHandling/TPPAlertUtils.swift index 433ce9c6a..380362ba0 100644 --- a/Palace/ErrorHandling/TPPAlertUtils.swift +++ b/Palace/ErrorHandling/TPPAlertUtils.swift @@ -1,7 +1,7 @@ import Foundation import UIKit -@objcMembers class TPPAlertUtils : NSObject { +@objcMembers class TPPAlertUtils: NSObject { /** Generates an alert view controller. If the `message` is non-nil, it will be used instead of deriving the error message from the `error`. @@ -10,13 +10,15 @@ import UIKit - Parameter error: An error. If the error contains a localizedDescription, that will be used for the alert message. - Returns: The alert controller to be presented. */ - class func alert(title: String?, - message: String?, - error: NSError?) -> UIAlertController { + class func alert( + title: String?, + message: String?, + error: NSError? + ) -> UIAlertController { if let message = message { - return alert(title: title, message: message) + alert(title: title, message: message) } else { - return alert(title: title, error: error) + alert(title: title, error: error) } } @@ -71,15 +73,17 @@ import UIKit metadata["error"] = error metadata["message"] = "Error object contained no usable error message for the user, so we defaulted to a generic one." } - TPPErrorLogger.logError(withCode: .genericErrorMsgDisplayed, - summary: "Displayed error alert with generic message", - metadata: metadata) + TPPErrorLogger.logError( + withCode: .genericErrorMsgDisplayed, + summary: "Displayed error alert with generic message", + metadata: metadata + ) } } return alert(title: title, message: message) } - + /** Generates an alert view with localized strings and default style @param title the alert title; can be localization key @@ -87,9 +91,9 @@ import UIKit @return the alert */ class func alert(title: String?, message: String?) -> UIAlertController { - return alert(title: title, message: message, style: .default) + alert(title: title, message: message, style: .default) } - + /** Generates an alert view with localized strings @param title the alert title; can be localization key @@ -100,15 +104,15 @@ import UIKit class func alert(title: String?, message: String?, style: UIAlertAction.Style) -> UIAlertController { let alertTitle = (title?.count ?? 0) > 0 ? NSLocalizedString(title!, comment: "") : "Alert" let alertMessage = (message?.count ?? 0) > 0 ? NSLocalizedString(message!, comment: "") : "" - let alertController = UIAlertController.init( + let alertController = UIAlertController( title: alertTitle, message: alertMessage, preferredStyle: .alert - ) - alertController.addAction(UIAlertAction.init(title: NSLocalizedString("OK", comment: ""), style: style, handler: nil)) + ) + alertController.addAction(UIAlertAction(title: NSLocalizedString("OK", comment: ""), style: style, handler: nil)) return alertController } - + /** Adds a problem document's contents to the alert @param controller the alert to modify @@ -167,7 +171,7 @@ import UIKit alert.message = "\(existingMsg)\(docDetail)" } } - + /** Presents an alert view from another given view, assuming the current window's root view controller is `NYPLRootTabBarController::shared`. @@ -178,10 +182,12 @@ import UIKit - animated: Whether to animate the presentation of the alert or not. - completion: Callback passed on to UIViewcontroller::present(). */ - class func presentFromViewControllerOrNil(alertController: UIAlertController?, - viewController: UIViewController?, - animated: Bool, - completion: (() -> Void)?) { + class func presentFromViewControllerOrNil( + alertController: UIAlertController?, + viewController: UIViewController?, + animated: Bool, + completion: (() -> Void)? + ) { guard let alertController = alertController else { return } @@ -190,21 +196,28 @@ import UIKit if let vc = viewController { DispatchQueue.main.async { vc.present(alertController, animated: animated, completion: completion) - if let msg = alertController.message { Log.info(#file, msg) } + if let msg = alertController.message { + Log.info(#file, msg) + } } return } // SwiftUI-first: present from the app's top-most UIKit controller - guard let root = (UIApplication.shared.delegate as? TPPAppDelegate)?.topViewController() else { return } + guard let root = (UIApplication.shared.delegate as? TPPAppDelegate)?.topViewController() else { + return + } let top = topMostViewController(from: root) DispatchQueue.main.async { top.present(alertController, animated: animated, completion: completion) - if let msg = alertController.message { Log.info(#file, msg) } + if let msg = alertController.message { + Log.info(#file, msg) + } } } // MARK: - Helpers + private class func topMostViewController(from base: UIViewController) -> UIViewController { if let nav = base as? UINavigationController, let visible = nav.visibleViewController { return topMostViewController(from: visible) diff --git a/Palace/ErrorHandling/TPPPresentationUtils.swift b/Palace/ErrorHandling/TPPPresentationUtils.swift index a5cf921e4..06ab9e1ec 100644 --- a/Palace/ErrorHandling/TPPPresentationUtils.swift +++ b/Palace/ErrorHandling/TPPPresentationUtils.swift @@ -21,9 +21,11 @@ class TPPPresentationUtils: NSObject { /// - vc: The view controller to be presented. /// - animated: Whether to animate the presentation of not. /// - completion: Completion handler to be called when the presentation ends. - @objc class func safelyPresent(_ vc: UIViewController, - animated: Bool = true, - completion: (() -> Void)? = nil) { + @objc class func safelyPresent( + _ vc: UIViewController, + animated: Bool = true, + completion: (() -> Void)? = nil + ) { // Ensure this block is always executed on the main thread if !Thread.isMainThread { DispatchQueue.main.async { @@ -31,36 +33,38 @@ class TPPPresentationUtils: NSObject { } return } - + let delegate = UIApplication.shared.delegate guard var base = delegate?.window??.rootViewController else { - TPPErrorLogger.logError(withCode: .missingExpectedObject, - summary: "Unable to find rootViewController", - metadata: [ - "DelegateIsNil": (delegate == nil), - "WindowIsNil": (delegate?.window == nil) - ]) + TPPErrorLogger.logError( + withCode: .missingExpectedObject, + summary: "Unable to find rootViewController", + metadata: [ + "DelegateIsNil": delegate == nil, + "WindowIsNil": (delegate?.window == nil) + ] + ) return } - + while true { guard let topBase = base.presentedViewController else { break } base = topBase } - + if let baseNavController = base as? UINavigationController, let inputNavController = vc as? UINavigationController, baseNavController.viewControllers.count == inputNavController.viewControllers.count, let baseVC = baseNavController.viewControllers.first, - let inputVC = inputNavController.viewControllers.first { - + let inputVC = inputNavController.viewControllers.first + { if type(of: baseVC) == type(of: inputVC) { return } } - + base.present(vc, animated: animated, completion: completion) } } diff --git a/Palace/ErrorHandling/TPPProblemDocument.swift b/Palace/ErrorHandling/TPPProblemDocument.swift index 8dd4f5374..da9d7a9c8 100644 --- a/Palace/ErrorHandling/TPPProblemDocument.swift +++ b/Palace/ErrorHandling/TPPProblemDocument.swift @@ -5,11 +5,11 @@ import Foundation */ @objcMembers class TPPProblemDocument: NSObject, Codable { static let TypeNoActiveLoan = - "http://librarysimplified.org/terms/problem/no-active-loan"; + "http://librarysimplified.org/terms/problem/no-active-loan" static let TypeLoanAlreadyExists = - "http://librarysimplified.org/terms/problem/loan-already-exists"; + "http://librarysimplified.org/terms/problem/loan-already-exists" static let TypeInvalidCredentials = - "http://librarysimplified.org/terms/problem/credentials-invalid"; + "http://librarysimplified.org/terms/problem/credentials-invalid" private static let noStatus: Int = -1 @@ -35,13 +35,13 @@ import Foundation /// Per RFC7807, a URI reference that identifies the specific occurrence of /// the problem. let instance: String? - - private init(_ dict: [String : Any]) { - self.type = dict[TPPProblemDocument.typeKey] as? String - self.title = dict[TPPProblemDocument.titleKey] as? String - self.status = dict[TPPProblemDocument.statusKey] as? Int - self.detail = dict[TPPProblemDocument.detailKey] as? String - self.instance = dict[TPPProblemDocument.instanceKey] as? String + + private init(_ dict: [String: Any]) { + type = dict[TPPProblemDocument.typeKey] as? String + title = dict[TPPProblemDocument.titleKey] as? String + status = dict[TPPProblemDocument.statusKey] as? Int + detail = dict[TPPProblemDocument.detailKey] as? String + instance = dict[TPPProblemDocument.instanceKey] as? String super.init() } @@ -60,18 +60,20 @@ import Foundation @objc(forExpiredOrMissingCredentials:) static func forExpiredOrMissingCredentials(hasCredentials: Bool) -> TPPProblemDocument { if hasCredentials { - return TPPProblemDocument([ + TPPProblemDocument([ TPPProblemDocument.typeKey: TPPProblemDocument.TypeInvalidCredentials, TPPProblemDocument.titleKey: Strings.TPPProblemDocument.authenticationExpiredTitle, TPPProblemDocument.detailKey: - Strings.TPPProblemDocument.authenticationExpiredBody]) + Strings.TPPProblemDocument.authenticationExpiredBody + ]) } else { - return TPPProblemDocument([ + TPPProblemDocument([ TPPProblemDocument.typeKey: TPPProblemDocument.TypeInvalidCredentials, TPPProblemDocument.titleKey: Strings.TPPProblemDocument.authenticationRequiredTitle, TPPProblemDocument.detailKey: - Strings.TPPProblemDocument.authenticationRequireBody]) + Strings.TPPProblemDocument.authenticationRequireBody + ]) } } @@ -80,13 +82,13 @@ import Foundation @param data data with which to populate the ProblemDocument @return a ProblemDocument built from the given data */ - @objc static func fromData(_ data: Data) throws -> TPPProblemDocument { + static func fromData(_ data: Data) throws -> TPPProblemDocument { let jsonDecoder = JSONDecoder() jsonDecoder.keyDecodingStrategy = .convertFromSnakeCase - + return try jsonDecoder.decode(TPPProblemDocument.self, from: data) } - + /// Factory method to create a problem document after an api call. /// /// - Parameters: @@ -94,14 +96,16 @@ import Foundation /// - responseError: Error possibly containing a problem document. /// - Returns: A problem document instance if a problem document was found, /// or `nil` otherwise. - @objc class func fromResponseError(_ responseError: NSError?, - responseData: Data?) -> TPPProblemDocument? { + class func fromResponseError( + _ responseError: NSError?, + responseData: Data? + ) -> TPPProblemDocument? { if let problemDocFromError = responseError?.problemDocument { return problemDocFromError } else if let responseData = responseData { return try? TPPProblemDocument.fromData(responseData) } - return nil + return nil } /** @@ -109,21 +113,21 @@ import Foundation @param dict data with which to populate the ProblemDocument @return a ProblemDocument built from the given dicationary */ - @objc static func fromDictionary(_ dict: [String : Any]) -> TPPProblemDocument { - return TPPProblemDocument(dict) + static func fromDictionary(_ dict: [String: Any]) -> TPPProblemDocument { + TPPProblemDocument(dict) } - @objc var dictionaryValue: [String: Any] { - return [ + var dictionaryValue: [String: Any] { + [ TPPProblemDocument.typeKey: type ?? "", TPPProblemDocument.titleKey: title ?? "", TPPProblemDocument.statusKey: status ?? TPPProblemDocument.noStatus, TPPProblemDocument.detailKey: detail ?? "", - TPPProblemDocument.instanceKey: instance ?? "", + TPPProblemDocument.instanceKey: instance ?? "" ] } - @objc var stringValue: String { - return "\(title == nil ? "" : title! + ": ")\(detail ?? "")" + var stringValue: String { + "\(title == nil ? "" : title! + ": ")\(detail ?? "")" } } diff --git a/Palace/ErrorHandling/TPPProblemDocumentCacheManager.swift b/Palace/ErrorHandling/TPPProblemDocumentCacheManager.swift index eeb8ea596..1c46d6ba9 100644 --- a/Palace/ErrorHandling/TPPProblemDocumentCacheManager.swift +++ b/Palace/ErrorHandling/TPPProblemDocumentCacheManager.swift @@ -2,48 +2,50 @@ extension Notification.Name { static let TPPProblemDocumentWasCached = Notification.Name("TPPProblemDocumentWasCached") } -@objc extension NSNotification { - public static let TPPProblemDocumentWasCached = Notification.Name.TPPProblemDocumentWasCached +@objc public extension NSNotification { + static let TPPProblemDocumentWasCached = Notification.Name.TPPProblemDocumentWasCached } -@objcMembers class TPPProblemDocumentCacheManager : NSObject { +// MARK: - TPPProblemDocumentCacheManager + +@objcMembers class TPPProblemDocumentCacheManager: NSObject { struct DocWithTimestamp { let doc: TPPProblemDocument let timestamp: Date - + init(_ document: TPPProblemDocument) { doc = document - timestamp = Date.init() + timestamp = Date() } } - + // Static values static let CACHE_SIZE = 5 static let shared = TPPProblemDocumentCacheManager() - + // For Objective-C classes class func sharedInstance() -> TPPProblemDocumentCacheManager { - return TPPProblemDocumentCacheManager.shared + TPPProblemDocumentCacheManager.shared } - + // Member values - private var cache: [String : [DocWithTimestamp]] - + private var cache: [String: [DocWithTimestamp]] + override init() { - cache = [String : [DocWithTimestamp]]() + cache = [String: [DocWithTimestamp]]() super.init() } - + // MARK: - Write - - @objc func cacheProblemDocument(_ doc: TPPProblemDocument, key: String) { - let timeStampDoc = DocWithTimestamp.init(doc) + + func cacheProblemDocument(_ doc: TPPProblemDocument, key: String) { + let timeStampDoc = DocWithTimestamp(doc) guard var vals = cache[key] else { cache[key] = [timeStampDoc] NotificationCenter.default.post(name: NSNotification.Name.TPPProblemDocumentWasCached, object: doc) return } - + if vals.count >= TPPProblemDocumentCacheManager.CACHE_SIZE { vals.removeFirst(1) vals.append(timeStampDoc) @@ -51,14 +53,14 @@ extension Notification.Name { } NotificationCenter.default.post(name: NSNotification.Name.TPPProblemDocumentWasCached, object: doc) } - + @objc(clearCachedDocForBookIdentifier:) func clearCachedDoc(_ key: String) { cache[key] = [] } - + // MARK: - Read - + func getLastCachedDoc(_ key: String) -> TPPProblemDocument? { guard let cachedDocuments = cache[key] else { return nil diff --git a/Palace/Fonts/Font+PalaceUIKit.swift b/Palace/Fonts/Font+PalaceUIKit.swift index 20650350f..5db0c0b23 100644 --- a/Palace/Fonts/Font+PalaceUIKit.swift +++ b/Palace/Fonts/Font+PalaceUIKit.swift @@ -8,48 +8,49 @@ import SwiftUI +// MARK: - PalaceFontModifier + public struct PalaceFontModifier: ViewModifier { - var style: Font.TextStyle - var size: CGFloat? = nil - var weight: Font.Weight? = nil - + var size: CGFloat? + var weight: Font.Weight? + public func body(content: Content) -> some View { content.font( .custom(palaceFontName, size: size ?? fontSize(for: style), relativeTo: style) - .weight(weight ?? fontWeight(for: style)) + .weight(weight ?? fontWeight(for: style)) ) } - + private let palaceFontName = "OpenSans-Regular" - + // Font sizes are described in pixels: https://www.figma.com/file/BxLs5QNmU5tCIKhO9ccAyh/TPP-UI---Style-Guidelines?type=design&node-id=1-12&mode=design&t=sGPJYuRIuFdWCIg3-0 private func fontSize(for textStyle: Font.TextStyle) -> CGFloat { switch textStyle { - case .largeTitle: return 34 - case .title: return 28 - case .title2: return 22 - case .title3: return 20 - case .headline: return 17 - case .subheadline: return 15 - case .body: return 17 - default: return UIFont.preferredFont(forTextStyle: translateTextStyle(textStyle)).pointSize + case .largeTitle: 34 + case .title: 28 + case .title2: 22 + case .title3: 20 + case .headline: 17 + case .subheadline: 15 + case .body: 17 + default: UIFont.preferredFont(forTextStyle: translateTextStyle(textStyle)).pointSize } } - + private func fontWeight(for textStyle: Font.TextStyle) -> Font.Weight { switch textStyle { - case .largeTitle: return .bold - case .title: return .bold - case .title2: return .bold - case .title3: return .bold - case .headline: return .bold - case .subheadline: return .bold - case .body: return .regular - default: return .regular + case .largeTitle: .bold + case .title: .bold + case .title2: .bold + case .title3: .bold + case .headline: .bold + case .subheadline: .bold + case .body: .regular + default: .regular } } - + private func translateTextStyle(_ textStyle: Font.TextStyle) -> UIFont.TextStyle { switch textStyle { case .largeTitle: return .largeTitle @@ -70,9 +71,10 @@ public struct PalaceFontModifier: ViewModifier { public extension View { func palaceFont(_ style: Font.TextStyle, weight: Font.Weight? = nil) -> some View { - self.modifier(PalaceFontModifier(style: style, weight: weight)) + modifier(PalaceFontModifier(style: style, weight: weight)) } + func palaceFont(size: CGFloat, weight: Font.Weight? = nil) -> some View { - self.modifier(PalaceFontModifier(style: .body, size: size, weight: weight)) + modifier(PalaceFontModifier(style: .body, size: size, weight: weight)) } } diff --git a/Palace/Fonts/UIFont+Extensions.swift b/Palace/Fonts/UIFont+Extensions.swift index 6603f824c..7df062f5b 100644 --- a/Palace/Fonts/UIFont+Extensions.swift +++ b/Palace/Fonts/UIFont+Extensions.swift @@ -4,11 +4,11 @@ extension UIFont { @objc class func palaceFont(ofSize fontSize: CGFloat) -> UIFont { UIFont(name: TPPConfiguration.systemFontName(), size: fontSize)! } - + @objc class func semiBoldPalaceFont(ofSize fontSize: CGFloat) -> UIFont { UIFont(name: TPPConfiguration.semiBoldSystemFontName(), size: fontSize)! } - + @objc class func boldPalaceFont(ofSize fontSize: CGFloat) -> UIFont { UIFont(name: TPPConfiguration.boldSystemFontName(), size: fontSize)! } diff --git a/Palace/Holds/HoldsView.swift b/Palace/Holds/HoldsView.swift index 89c5861fc..aba81067c 100644 --- a/Palace/Holds/HoldsView.swift +++ b/Palace/Holds/HoldsView.swift @@ -1,16 +1,19 @@ import SwiftUI import UIKit +// MARK: - HoldsView + struct HoldsView: View { @EnvironmentObject private var coordinator: NavigationCoordinator typealias DisplayStrings = Strings.HoldsView - + @StateObject private var model = HoldsViewModel() @StateObject private var logoObserver = CatalogLogoObserver() @State private var currentAccountUUID: String = AccountsManager.shared.currentAccount?.uuid ?? "" private var allBooks: [TPPBook] { - model.reservedBookVMs.map { $0.book } + model.heldBookVMs.map { $0.book } + model.reservedBookVMs.map(\.book) + model.heldBookVMs.map(\.book) } + var body: some View { ZStack { mainContent @@ -55,15 +58,15 @@ struct HoldsView: View { account?.loadLogo() currentAccountUUID = account?.uuid ?? "" } - .sheet(isPresented: $model.showLibraryAccountView) { - UIViewControllerWrapper( - TPPAccountList { account in - model.loadAccount(account) - }, - updater: { _ in } - ) - } - + .sheet(isPresented: $model.showLibraryAccountView) { + UIViewControllerWrapper( + TPPAccountList { account in + model.loadAccount(account) + }, + updater: { _ in } + ) + } + if model.isLoading { loadingOverlay } @@ -72,7 +75,7 @@ struct HoldsView: View { private var mainContent: some View { VStack(alignment: .leading, spacing: 0) { - if model.showSearchSheet { + if model.showSearchSheet { searchBar .transition(.move(edge: .top).combined(with: .opacity)) } @@ -107,18 +110,18 @@ struct HoldsView: View { } } } - + /// Placeholder text when there are no holds at all private var emptyView: some View { Text(DisplayStrings.emptyMessage) - .multilineTextAlignment(.center) - .foregroundColor(Color(white: 0.667)) - .background(Color(TPPConfiguration.backgroundColor())) - .font(.system(size: 18)) - .padding(.horizontal, 24) - .padding(.top, 100) + .multilineTextAlignment(.center) + .foregroundColor(Color(white: 0.667)) + .background(Color(TPPConfiguration.backgroundColor())) + .font(.system(size: 18)) + .padding(.horizontal, 24) + .padding(.top, 100) } - + /// Semi‐transparent loading overlay private var loadingOverlay: some View { ProgressView() @@ -126,7 +129,7 @@ struct HoldsView: View { .frame(maxWidth: .infinity, maxHeight: .infinity) .background(Color.black.opacity(0.5).ignoresSafeArea()) } - + /// Leading bar button: "Pick a new library" private var leadingBarButton: some View { Button { @@ -136,9 +139,9 @@ struct HoldsView: View { } .actionSheet(isPresented: $model.selectNewLibrary) { var buttons: [ActionSheet.Button] = TPPSettings.shared.settingsAccountsList.map { account in - .default(Text(account.name)) { - model.loadAccount(account) - } + .default(Text(account.name)) { + model.loadAccount(account) + } } buttons.append(.default(Text(Strings.MyBooksView.addLibrary)) { model.showLibraryAccountView = true @@ -150,7 +153,7 @@ struct HoldsView: View { ) } } - + private var trailingBarButton: some View { Button { withAnimation { model.showSearchSheet.toggle() } @@ -159,7 +162,7 @@ struct HoldsView: View { } .accessibilityLabel(NSLocalizedString("Search Reservations", comment: "")) } - + private func presentBookDetail(_ book: TPPBook) { coordinator.store(book: book) coordinator.push(.bookDetail(BookRoute(id: book.identifier))) @@ -188,19 +191,20 @@ struct HoldsView: View { } } +// MARK: - TPPHoldsViewController + @objc final class TPPHoldsViewController: NSObject { - /// Returns a `UINavigationController` containing our SwiftUI `HoldsView`. /// • The SwiftUI view uses `HoldsViewModel()` under the hood. /// • We set the navigation title and the tab-bar image here. @MainActor @objc static func makeSwiftUIView() -> UIViewController { let holdsRoot = HoldsView() - + let hosting = UIHostingController(rootView: holdsRoot) hosting.title = NSLocalizedString("Reservations", comment: "Nav title for Holds") hosting.tabBarItem.image = UIImage(named: "Holds") - + return hosting } } diff --git a/Palace/Holds/HoldsViewModel.swift b/Palace/Holds/HoldsViewModel.swift index adc5b2c07..de1ca6b4c 100644 --- a/Palace/Holds/HoldsViewModel.swift +++ b/Palace/Holds/HoldsViewModel.swift @@ -1,150 +1,156 @@ import Combine import SwiftUI +// MARK: - HoldsBookViewModel + @MainActor final class HoldsBookViewModel: ObservableObject, Identifiable { - let book: TPPBook - - var id: String { book.identifier } - - var isReserved: Bool { - var reservedFlag = false - book.defaultAcquisition?.availability.matchUnavailable( - nil, - limited: nil, - unlimited: nil, - reserved: { (_: TPPOPDSAcquisitionAvailabilityReserved) in reservedFlag = true }, - ready: { (_: TPPOPDSAcquisitionAvailabilityReady) in reservedFlag = true } - ) - return reservedFlag - } - - init(book: TPPBook) { - self.book = book - } + let book: TPPBook + + var id: String { book.identifier } + + var isReserved: Bool { + var reservedFlag = false + book.defaultAcquisition?.availability.matchUnavailable( + nil, + limited: nil, + unlimited: nil, + reserved: { (_: TPPOPDSAcquisitionAvailabilityReserved) in reservedFlag = true }, + ready: { (_: TPPOPDSAcquisitionAvailabilityReady) in reservedFlag = true } + ) + return reservedFlag + } + + init(book: TPPBook) { + self.book = book + } } +// MARK: - HoldsViewModel + @MainActor final class HoldsViewModel: ObservableObject { - @Published var reservedBookVMs: [HoldsBookViewModel] = [] - @Published var heldBookVMs: [HoldsBookViewModel] = [] - @Published var isLoading: Bool = false - @Published var showLibraryAccountView: Bool = false - @Published var selectNewLibrary: Bool = false - @Published var showSearchSheet: Bool = false - @Published var searchQuery: String = "" - @Published var visibleBooks: [TPPBook] = [] - private var cancellables = Set() - - init() { - NotificationCenter.default.publisher(for: .TPPSyncBegan) - .receive(on: RunLoop.main) - .sink { [weak self] _ in - self?.isLoading = true - } - .store(in: &cancellables) - - NotificationCenter.default.publisher(for: .TPPSyncEnded) - .receive(on: RunLoop.main) - .sink { [weak self] _ in - self?.isLoading = false - self?.reloadData() - } - .store(in: &cancellables) - - NotificationCenter.default.publisher(for: .TPPBookRegistryDidChange) - .receive(on: RunLoop.main) - .sink { [weak self] _ in - self?.reloadData() - } - .store(in: &cancellables) - - reloadData() + @Published var reservedBookVMs: [HoldsBookViewModel] = [] + @Published var heldBookVMs: [HoldsBookViewModel] = [] + @Published var isLoading: Bool = false + @Published var showLibraryAccountView: Bool = false + @Published var selectNewLibrary: Bool = false + @Published var showSearchSheet: Bool = false + @Published var searchQuery: String = "" + @Published var visibleBooks: [TPPBook] = [] + private var cancellables = Set() + + init() { + NotificationCenter.default.publisher(for: .TPPSyncBegan) + .receive(on: RunLoop.main) + .sink { [weak self] _ in + self?.isLoading = true + } + .store(in: &cancellables) + + NotificationCenter.default.publisher(for: .TPPSyncEnded) + .receive(on: RunLoop.main) + .sink { [weak self] _ in + self?.isLoading = false + self?.reloadData() + } + .store(in: &cancellables) + + NotificationCenter.default.publisher(for: .TPPBookRegistryDidChange) + .receive(on: RunLoop.main) + .sink { [weak self] _ in + self?.reloadData() + } + .store(in: &cancellables) + + reloadData() + } + + private var allBooks: [TPPBook] { + reservedBookVMs.map(\.book) + heldBookVMs.map(\.book) + } + + func reloadData() { + let allHeld = TPPBookRegistry.shared.heldBooks + var reservedVMs: [HoldsBookViewModel] = [] + var heldVMs: [HoldsBookViewModel] = [] + + for book in allHeld { + let vm = HoldsBookViewModel(book: book) + if vm.isReserved { + reservedVMs.append(vm) + } else { + heldVMs.append(vm) + } } - private var allBooks: [TPPBook] { - reservedBookVMs.map { $0.book } + heldBookVMs.map { $0.book } + DispatchQueue.main.async { [weak self] in + guard let self else { + return + } + withAnimation { + self.reservedBookVMs = reservedVMs + self.heldBookVMs = heldVMs + self.visibleBooks = self.allBooks + self.updateBadgeCount() + } } - - func reloadData() { - let allHeld = TPPBookRegistry.shared.heldBooks - var reservedVMs: [HoldsBookViewModel] = [] - var heldVMs: [HoldsBookViewModel] = [] - - for book in allHeld { - let vm = HoldsBookViewModel(book: book) - if vm.isReserved { - reservedVMs.append(vm) - } else { - heldVMs.append(vm) - } - } - - DispatchQueue.main.async { [weak self] in - guard let self else { return } - withAnimation { - self.reservedBookVMs = reservedVMs - self.heldBookVMs = heldVMs - self.visibleBooks = self.allBooks - self.updateBadgeCount() - } + } + + func refresh() { + if TPPUserAccount.sharedAccount().needsAuth { + if TPPUserAccount.sharedAccount().hasCredentials() { + TPPBookRegistry.shared.sync() + } else { + DispatchQueue.main.async { + TPPAccountSignInViewController.requestCredentials { + self.reloadData() + } } + } + } else { + TPPBookRegistry.shared.load() } - - func refresh() { - if TPPUserAccount.sharedAccount().needsAuth { - if TPPUserAccount.sharedAccount().hasCredentials() { - TPPBookRegistry.shared.sync() - } else { - DispatchQueue.main.async { - TPPAccountSignInViewController.requestCredentials { - self.reloadData() - } - } - } - } else { - TPPBookRegistry.shared.load() - } - } - - private func updateBadgeCount() { - UIApplication.shared.applicationIconBadgeNumber = reservedBookVMs.count - } - - func loadAccount(_ account: Account) { - updateFeed(account) - showLibraryAccountView = false - selectNewLibrary = false - reloadData() - } - - private func updateFeed(_ account: Account) { - AccountsManager.shared.currentAccount = account - NotificationCenter.default.post(name: .TPPCurrentAccountDidChange, object: nil) - } - - var openSearchDescription: TPPOpenSearchDescription { - let title = NSLocalizedString("Search Reservations", comment: "") - let books = allBooks - return TPPOpenSearchDescription(title: title, books: books) - } - - @MainActor - func filterBooks(query: String) async { - if query.isEmpty { - self.visibleBooks = self.allBooks - } else { - let sourceBooks = self.allBooks - let filtered = await withCheckedContinuation { continuation in - DispatchQueue.global(qos: .userInitiated).async { - let result = sourceBooks.filter { - $0.title.localizedCaseInsensitiveContains(query) || - ($0.authors?.localizedCaseInsensitiveContains(query) ?? false) - } - continuation.resume(returning: result) - } - } - self.visibleBooks = filtered + } + + private func updateBadgeCount() { + UIApplication.shared.applicationIconBadgeNumber = reservedBookVMs.count + } + + func loadAccount(_ account: Account) { + updateFeed(account) + showLibraryAccountView = false + selectNewLibrary = false + reloadData() + } + + private func updateFeed(_ account: Account) { + AccountsManager.shared.currentAccount = account + NotificationCenter.default.post(name: .TPPCurrentAccountDidChange, object: nil) + } + + var openSearchDescription: TPPOpenSearchDescription { + let title = NSLocalizedString("Search Reservations", comment: "") + let books = allBooks + return TPPOpenSearchDescription(title: title, books: books) + } + + @MainActor + func filterBooks(query: String) async { + if query.isEmpty { + visibleBooks = allBooks + } else { + let sourceBooks = allBooks + let filtered = await withCheckedContinuation { continuation in + DispatchQueue.global(qos: .userInitiated).async { + let result = sourceBooks.filter { + $0.title.localizedCaseInsensitiveContains(query) || + ($0.authors?.localizedCaseInsensitiveContains(query) ?? false) + } + continuation.resume(returning: result) } + } + visibleBooks = filtered } + } } diff --git a/Palace/Keychain/TPPKeychainManager.swift b/Palace/Keychain/TPPKeychainManager.swift index ce135a630..f6065aea2 100644 --- a/Palace/Keychain/TPPKeychainManager.swift +++ b/Palace/Keychain/TPPKeychainManager.swift @@ -2,7 +2,6 @@ import Foundation import PalaceAudiobookToolkit @objcMembers final class TPPKeychainManager: NSObject { - private static let secClassItems: [String] = [ kSecClassGenericPassword as String, kSecClassInternetPassword as String, @@ -16,7 +15,7 @@ import PalaceAudiobookToolkit Log.info(#file, "Fresh install detected. Cleaning up all keychain items...") cleanupAllKeychainItems() } - + if TPPSettings.shared.appVersion != nil { updateKeychainForBackgroundFetch() manageFeedbooksData() @@ -27,7 +26,7 @@ import PalaceAudiobookToolkit // Clean up all keychain items private class func cleanupAllKeychainItems() { Log.info(#file, "Starting keychain cleanup...") - + // First, try to get all items to log what we're cleaning up for secClass in secClassItems { let query: [String: AnyObject] = [ @@ -44,10 +43,11 @@ import PalaceAudiobookToolkit } if status == errSecSuccess { - if let array = result as? Array> { + if let array = result as? [[String: Any]] { for item in array { if let keyData = item[kSecAttrAccount as String] as? Data, - let keyString = NSKeyedUnarchiver.unarchiveObject(with: keyData) as? String { + let keyString = NSKeyedUnarchiver.unarchiveObject(with: keyData) as? String + { Log.info(#file, "Found keychain item to clean up: \(keyString)") } } @@ -119,12 +119,12 @@ import PalaceAudiobookToolkit /// the first unlock per phone reboot. class func updateKeychainForBackgroundFetch() { let query: [String: AnyObject] = [ - kSecClass as String : kSecClassGenericPassword, - kSecAttrAccessible as String : kSecAttrAccessibleWhenUnlocked, //old default - kSecReturnData as String : kCFBooleanTrue, - kSecReturnAttributes as String : kCFBooleanTrue, - kSecReturnRef as String : kCFBooleanTrue, - kSecMatchLimit as String : kSecMatchLimitAll + kSecClass as String: kSecClassGenericPassword, + kSecAttrAccessible as String: kSecAttrAccessibleWhenUnlocked, // old default + kSecReturnData as String: kCFBooleanTrue, + kSecReturnAttributes as String: kCFBooleanTrue, + kSecReturnRef as String: kCFBooleanTrue, + kSecMatchLimit as String: kSecMatchLimitAll ] var result: AnyObject? @@ -132,13 +132,16 @@ import PalaceAudiobookToolkit SecItemCopyMatching(query as CFDictionary, UnsafeMutablePointer($0)) } - var values = [String:AnyObject]() + var values = [String: AnyObject]() if lastResultCode == noErr { - guard let array = result as? Array> else { return } + guard let array = result as? [[String: Any]] else { + return + } for item in array { if let keyData = item[kSecAttrAccount as String] as? Data, - let valueData = item[kSecValueData as String] as? Data, - let keyString = NSKeyedUnarchiver.unarchiveObject(with: keyData) as? String { + let valueData = item[kSecValueData as String] as? Data, + let keyString = NSKeyedUnarchiver.unarchiveObject(with: keyData) as? String + { let value = NSKeyedUnarchiver.unarchiveObject(with: valueData) as AnyObject values[keyString] = value } @@ -151,18 +154,19 @@ import PalaceAudiobookToolkit Log.debug(#file, "Keychain item \"\(key)\" updated with new accessible security level...") } } - + // Load feedbooks profile secrets private class func manageFeedbooksData() { // Go through each vendor and add their data to keychain so audiobook component can access securely for vendor in AudioBookVendors.allCases { guard let keyData = TPPSecrets.feedbookKeys(forVendor: vendor)?.data(using: .utf8), - let profile = TPPSecrets.feedbookInfo(forVendor: vendor)["profile"], - let tag = "feedbook_drm_profile_\(profile)".data(using: .utf8) else { - Log.error(#file, "Could not load secrets for Feedbook vendor: \(vendor.rawValue)") - continue + let profile = TPPSecrets.feedbookInfo(forVendor: vendor)["profile"], + let tag = "feedbook_drm_profile_\(profile)".data(using: .utf8) + else { + Log.error(#file, "Could not load secrets for Feedbook vendor: \(vendor.rawValue)") + continue } - + let addQuery: [String: Any] = [ kSecClass as String: kSecClassKey, kSecAttrApplicationTag as String: tag, @@ -175,14 +179,14 @@ import PalaceAudiobookToolkit } } } - + private class func manageFeedbookDrmPrivateKey() { // Request DRM certificates for all vendors for vendor in AudioBookVendors.allCases { vendor.updateDrmCertificate() } } - + class func logKeychainError(forVendor vendor: String, status: OSStatus, message: String) { // This is unexpected var errMsg = "" @@ -213,14 +217,15 @@ import PalaceAudiobookToolkit errMsg = "Unknown OSStatus: \(status)" } } - + TPPErrorLogger.logError( withCode: .keychainItemAddFail, summary: "Keychain error for vendor \(vendor)", metadata: [ "OSStatus": status, "SecCopyErrorMessage from OSStatus": errMsg, - "message": message, - ]) + "message": message + ] + ) } } diff --git a/Palace/Keychain/TPPKeychainStoredVariable.swift b/Palace/Keychain/TPPKeychainStoredVariable.swift index f969998b0..cf4055050 100644 --- a/Palace/Keychain/TPPKeychainStoredVariable.swift +++ b/Palace/Keychain/TPPKeychainStoredVariable.swift @@ -8,14 +8,20 @@ import Foundation +// MARK: - Keyable + protocol Keyable { var key: String { get set } } +// MARK: - TPPKeychainVariable + class TPPKeychainVariable: Keyable { var key: String { didSet { - guard key != oldValue else { return } + guard key != oldValue else { + return + } alreadyInited = false } @@ -29,13 +35,15 @@ class TPPKeychainVariable: Keyable { init(key: String, accountInfoQueue: DispatchQueue) { self.key = key - self.transaction = TPPKeychainVariableTransaction(accountInfoQueue: accountInfoQueue) + transaction = TPPKeychainVariableTransaction(accountInfoQueue: accountInfoQueue) } func read() -> VariableType? { transaction.perform { // If currently cached value is valid, return from cache - guard !alreadyInited else { return } + guard !alreadyInited else { + return + } // Otherwise, obtain the latest value from keychain cachedValue = TPPKeychain.shared()?.object(forKey: key) as? VariableType @@ -65,13 +73,17 @@ class TPPKeychainVariable: Keyable { } } +// MARK: - TPPKeychainCodableVariable + class TPPKeychainCodableVariable: TPPKeychainVariable { override func read() -> VariableType? { transaction.perform { - guard !alreadyInited else { return } + guard !alreadyInited else { + return + } guard let data = TPPKeychain.shared()?.object(forKey: key) as? Data else { - cachedValue = nil; - alreadyInited = true; + cachedValue = nil + alreadyInited = true return } cachedValue = try? JSONDecoder().decode(VariableType.self, from: data) @@ -96,6 +108,8 @@ class TPPKeychainCodableVariable: TPPKeychainVariable() diff --git a/Palace/Logging/AudiobookFileLogger.swift b/Palace/Logging/AudiobookFileLogger.swift index 4c44d08cc..7efc90d28 100644 --- a/Palace/Logging/AudiobookFileLogger.swift +++ b/Palace/Logging/AudiobookFileLogger.swift @@ -7,29 +7,31 @@ // class AudiobookFileLogger: TPPErrorLogger { - static let shared = AudiobookFileLogger() - + private var logsDirectoryUrl: URL? { let fileManager = FileManager.default - let logsPath = fileManager.urls(for: .documentDirectory, in: .userDomainMask).first?.appendingPathComponent("AudiobookLogs") + let logsPath = fileManager.urls(for: .documentDirectory, in: .userDomainMask).first? + .appendingPathComponent("AudiobookLogs") if let logsPath = logsPath, !fileManager.fileExists(atPath: logsPath.path) { try? fileManager.createDirectory(at: logsPath, withIntermediateDirectories: true, attributes: nil) } return logsPath } - + func getLogsDirectoryUrl() -> URL? { - return logsDirectoryUrl + logsDirectoryUrl } - + func logEvent(forBookId bookId: String, event: String) { - guard let logsDirectoryUrl = logsDirectoryUrl, TPPSettings.shared.customMainFeedURL == nil else { return } + guard let logsDirectoryUrl = logsDirectoryUrl, TPPSettings.shared.customMainFeedURL == nil else { + return + } print("New event logged: \(event.description)") let logFileUrl = logsDirectoryUrl.appendingPathComponent("\(bookId).log") let logMessage = "\(Date()): \(event)\n" - + if FileManager.default.fileExists(atPath: logFileUrl.path) { if let fileHandle = try? FileHandle(forWritingTo: logFileUrl) { fileHandle.seekToEndOfFile() @@ -42,13 +44,15 @@ class AudiobookFileLogger: TPPErrorLogger { try? logMessage.write(to: logFileUrl, atomically: true, encoding: .utf8) } } - + func retrieveLog(forBookId bookId: String) -> String? { - guard let logsDirectoryUrl = logsDirectoryUrl else { return nil } + guard let logsDirectoryUrl = logsDirectoryUrl else { + return nil + } let logFileUrl = logsDirectoryUrl.appendingPathComponent("\(bookId).log") return try? String(contentsOf: logFileUrl) } - + func retrieveLogs(forBookIds bookIds: [String]) -> [String: String] { var logs: [String: String] = [:] for bookId in bookIds { diff --git a/Palace/Logging/Log.swift b/Palace/Logging/Log.swift index 6a02037de..bc3883945 100644 --- a/Palace/Logging/Log.swift +++ b/Palace/Logging/Log.swift @@ -1,6 +1,6 @@ -import os -import Foundation import FirebaseCrashlytics +import Foundation +import os final class Log: NSObject { static var dateFormatter: DateFormatter = { @@ -12,18 +12,18 @@ final class Log: NSObject { private class func levelToString(_ level: OSLogType) -> String { switch level { case .debug: - return "DEBUG" + "DEBUG" case .info: - return "INFO" + "INFO" case .error: - return "ERROR" + "ERROR" case .fault: - return "FAULT" + "FAULT" default: - return "WARNING" + "WARNING" } } - + private class func log(_ level: OSLogType, _ tag: String, _ message: String) { let tag = trimTag(tag) @@ -52,11 +52,11 @@ final class Log: NSObject { class func debug(_ tag: String, _ message: String) { log(.debug, tag, message) } - + class func info(_ tag: String, _ message: String) { log(.info, tag, message) } - + class func warn(_ tag: String, _ message: String) { log(.default, tag, message) } @@ -74,35 +74,40 @@ final class Log: NSObject { } // MARK: - Performance Optimizations - + private static var lastPalaceLogMessages: [String: Date] = [:] private static let palaceLogThrottleInterval: TimeInterval = 0.3 - private static let throttleQueue = DispatchQueue(label: "org.thepalaceproject.palace.logging.throttle", attributes: .concurrent) - + private static let throttleQueue = DispatchQueue( + label: "org.thepalaceproject.palace.logging.throttle", + attributes: .concurrent + ) + private class func shouldThrottlePalaceLogging(level: OSLogType, tag: String, message: String) -> Bool { - guard level != .error && level != .fault else { return false } - + guard level != .error && level != .fault else { + return false + } + let now = Date() let messageKey = "\(tag):\(message.prefix(30))" - + return throttleQueue.sync { if let lastTime = lastPalaceLogMessages[messageKey] { if now.timeIntervalSince(lastTime) < palaceLogThrottleInterval { return true // Throttle this message } } - + // Use barrier to ensure exclusive write access throttleQueue.async(flags: .barrier) { lastPalaceLogMessages[messageKey] = now - + // Clean up old entries periodically to prevent memory growth if lastPalaceLogMessages.count > 50 { let cutoffTime = now.addingTimeInterval(-palaceLogThrottleInterval * 20) lastPalaceLogMessages = lastPalaceLogMessages.filter { $0.value > cutoffTime } } } - + return false } } diff --git a/Palace/Logging/TPPBook+Logging.swift b/Palace/Logging/TPPBook+Logging.swift index d9616270f..9b3335df7 100644 --- a/Palace/Logging/TPPBook+Logging.swift +++ b/Palace/Logging/TPPBook+Logging.swift @@ -11,13 +11,13 @@ import Foundation extension TPPBook { /// An informative short string describing the book, for logging purposes. @objc func loggableShortString() -> String { - return "<\(title) ID=\(identifier) Distributor=\(distributor ?? "")>" + "<\(title) ID=\(identifier) Distributor=\(distributor ?? "")>" } /// An informative dictionary detailing all aspects of the book that could /// be interesting for logging purposes. @objc func loggableDictionary() -> [String: Any] { - return [ + [ "bookTitle": title, "bookID": identifier, "bookDistributor": distributor ?? "", diff --git a/Palace/Logging/TPPCirculationAnalytics.swift b/Palace/Logging/TPPCirculationAnalytics.swift index b4888c3f6..cb5c934ad 100644 --- a/Palace/Logging/TPPCirculationAnalytics.swift +++ b/Palace/Logging/TPPCirculationAnalytics.swift @@ -3,15 +3,13 @@ import Foundation /// This class encapsulates analytic events sent to the server /// and keeps a local queue of failed attempts to retry them /// at a later time. -@objcMembers final class TPPCirculationAnalytics : NSObject { - - class func postEvent(_ event: String, withBook book: TPPBook) -> Void - { +@objcMembers final class TPPCirculationAnalytics: NSObject { + class func postEvent(_ event: String, withBook book: TPPBook) { if let requestURL = book.analyticsURL?.appendingPathComponent(event) { post(event, withURL: requestURL) } } - + private class func post(_ event: String, withURL url: URL) { let config = URLSessionConfiguration.ephemeral config.timeoutIntervalForRequest = 3 @@ -22,7 +20,7 @@ import Foundation var request = URLRequest(url: url) request.httpMethod = "GET" - let task = session.dataTask(with: request) { (_, response, error) in + let task = session.dataTask(with: request) { _, response, error in if let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200 { Log.info(#file, "Analytics Upload: Success for event \(event)") return @@ -44,9 +42,7 @@ import Foundation } } - - private class func addToOfflineAnalyticsQueue(_ event: String, _ bookURL: URL) -> Void - { + private class func addToOfflineAnalyticsQueue(_: String, _ bookURL: URL) { let libraryID = AccountsManager.shared.currentAccount?.uuid ?? "" let headers = TPPNetworkExecutor.shared.request(for: bookURL).allHTTPHeaderFields NetworkQueue.shared().addRequest(libraryID, nil, bookURL, .GET, nil, headers) diff --git a/Palace/Logging/TPPErrorLogger.swift b/Palace/Logging/TPPErrorLogger.swift index b8236d4a6..1e01b11d5 100644 --- a/Palace/Logging/TPPErrorLogger.swift +++ b/Palace/Logging/TPPErrorLogger.swift @@ -4,24 +4,30 @@ // import CFNetwork -import Foundation import FirebaseCore import FirebaseCrashlytics +import Foundation + +private let nullString = "null" -fileprivate let nullString = "null" +// MARK: - TPPSeverity @objc enum TPPSeverity: NSInteger { - case error, warning, info + case error + case warning + case info func stringValue() -> String { switch self { - case .error: return "error" - case .warning: return "warning" - case .info: return "info" + case .error: "error" + case .warning: "warning" + case .info: "info" } } } +// MARK: - TPPErrorCode + /// Detailed error codes that span across different error reports. /// E.g. you could have a `invalidURLSession` for a number of different api /// calls, happening in catalog loading, sign-in, etc. So the `summary` of @@ -122,30 +128,33 @@ fileprivate let nullString = "null" // keychain case keychainItemAddFail = 1300 - + // localization case locationAccessDenied = 1400 case failedToGetLocation = 1401 case unknownLocationError = 1402 } +// MARK: - TPPErrorLogger + /// Facility to report error situations to a remote logging system such as /// Crashlytics. /// /// Please refer to the following page for guidelines on how to file an /// effective error report: /// https://github.com/NYPL-Simplified/Simplified/wiki/Error-reporting-on-iOS -@objcMembers class TPPErrorLogger : NSObject { +@objcMembers class TPPErrorLogger: NSObject { + static let clientDomain = "org.thepalaceproject.palace" - @objc static let clientDomain = "org.thepalaceproject.palace" - - //---------------------------------------------------------------------------- - // MARK:- Configuration + // ---------------------------------------------------------------------------- + // MARK: - Configuration class func configureCrashAnalytics() { // Only enable Crashlytics on Production builds - guard Bundle.main.applicationEnvironment == .production else { return } - + guard Bundle.main.applicationEnvironment == .production else { + return + } + #if FEATURE_CRASH_REPORTING if let deviceID = UIDevice.current.identifierForVendor?.uuidString { Crashlytics.crashlytics().setCustomValue(deviceID, forKey: "PalaceDeviceID") @@ -155,7 +164,9 @@ fileprivate let nullString = "null" class func setUserID(_ userID: String?) { // Only enable Crashlytics on Production builds - guard Bundle.main.applicationEnvironment == .production else { return } + guard Bundle.main.applicationEnvironment == .production else { + return + } #if FEATURE_CRASH_REPORTING if let userIDmd5 = userID?.md5hex() { @@ -166,37 +177,44 @@ fileprivate let nullString = "null" #endif } - //---------------------------------------------------------------------------- - // MARK:- Generic methods for error logging + // ---------------------------------------------------------------------------- + // MARK: - Generic methods for error logging /// Reports an error. /// - Parameters: /// - error: Any originating error that occurred. /// - summary: This will be the top line (searchable) in Crashlytics UI. /// - metadata: Any additional metadata to be logged. - class func logError(_ error: Error?, - summary: String, - metadata: [String: Any]? = nil) { - logError(error, - code: .ignore, - summary: summary, - metadata: metadata) + class func logError( + _ error: Error?, + summary: String, + metadata: [String: Any]? = nil + ) { + logError( + error, + code: .ignore, + summary: summary, + metadata: metadata + ) } - /// Reports an error situation. /// - Parameters: /// - code: A code identifying the error situation. Searchable in /// Crashlytics UI. /// - summary: This will be the top line (searchable) in Crashlytics UI. /// - metadata: Any additional metadata to be logged. - class func logError(withCode code: TPPErrorCode, - summary: String, - metadata: [String: Any]? = nil) { - logError(nil, - code: code, - summary: summary, - metadata: metadata) + class func logError( + withCode code: TPPErrorCode, + summary: String, + metadata: [String: Any]? = nil + ) { + logError( + nil, + code: code, + summary: summary, + metadata: metadata + ) } /// Use this function for logging low-level errors occurring in api execution @@ -213,22 +231,26 @@ fileprivate let nullString = "null" /// report, to ensure privacy. /// - response: Useful to understand if the error originated on the server. /// - metadata: Free-form dictionary for additional metadata to be logged. - class func logNetworkError(_ originalError: Error? = nil, - code: TPPErrorCode = .ignore, - summary: String?, - request: URLRequest?, - response: URLResponse? = nil, - metadata: [String: Any]? = nil) { - logError(originalError, - code: (code != .ignore ? code : TPPErrorCode.apiCall), - summary: summary ?? "Network error", - request: request, - response: response, - metadata: metadata) + class func logNetworkError( + _ originalError: Error? = nil, + code: TPPErrorCode = .ignore, + summary: String?, + request: URLRequest?, + response: URLResponse? = nil, + metadata: [String: Any]? = nil + ) { + logError( + originalError, + code: code != .ignore ? code : TPPErrorCode.apiCall, + summary: summary ?? "Network error", + request: request, + response: response, + metadata: metadata + ) } - //---------------------------------------------------------------------------- - // MARK:- Sign up/in/out errors + // ---------------------------------------------------------------------------- + // MARK: - Sign up/in/out errors /// Report when there's an error logging in to an account. /// - Parameters: @@ -237,12 +259,14 @@ fileprivate let nullString = "null" /// - response: The response that returned the error. /// - problemDocument: A structured error description returned by the server. /// - metadata: Free-form dictionary for additional metadata to be logged. - class func logLoginError(_ error: NSError?, - library: Account?, - response: URLResponse?, - problemDocument: TPPProblemDocument?, - metadata: [String: Any]?) { - var metadata = metadata ?? [String : Any]() + class func logLoginError( + _ error: NSError?, + library: Account?, + response: URLResponse?, + problemDocument: TPPProblemDocument?, + metadata: [String: Any]? + ) { + var metadata = metadata ?? [String: Any]() if let error = error { metadata[NSUnderlyingErrorKey] = error } @@ -265,9 +289,11 @@ fileprivate let nullString = "null" addAccountInfoToMetadata(&metadata) let userInfo = additionalInfo(severity: .error, metadata: metadata) - let err = NSError(domain: "SignIn error: problem document available", - code: errorCode, - userInfo: userInfo) + let err = NSError( + domain: "SignIn error: problem document available", + code: errorCode, + userInfo: userInfo + ) record(error: err) } @@ -278,10 +304,12 @@ fileprivate let nullString = "null" @param libraryName name of the library @return */ - class func logLocalAuthFailed(error: NSError?, - library: Account?, - metadata: [String: Any]?) { - var metadata = metadata ?? [String : Any]() + class func logLocalAuthFailed( + error: NSError?, + library: Account?, + metadata: [String: Any]? + ) { + var metadata = metadata ?? [String: Any]() if let library = library { metadata["libraryUUID"] = library.uuid metadata["libraryName"] = library.name @@ -291,13 +319,17 @@ fileprivate let nullString = "null" metadata[NSUnderlyingErrorKey] = error } addAccountInfoToMetadata(&metadata) - - let userInfo = additionalInfo(severity: .info, - message: "Local Login Failed With Error", - metadata: metadata) - let err = NSError(domain: "SignIn error: Adobe activation", - code: TPPErrorCode.adeptAuthFail.rawValue, - userInfo: userInfo) + + let userInfo = additionalInfo( + severity: .info, + message: "Local Login Failed With Error", + metadata: metadata + ) + let err = NSError( + domain: "SignIn error: Adobe activation", + code: TPPErrorCode.adeptAuthFail.rawValue, + userInfo: userInfo + ) record(error: err) } @@ -307,17 +339,20 @@ fileprivate let nullString = "null" - Parameter accountId: id of the library account. */ class func logInvalidLicensor(withAccountID accountId: String?) { - var metadata = [String : Any]() + var metadata = [String: Any]() metadata["accountTypeID"] = accountId ?? nullString addAccountInfoToMetadata(&metadata) - + let userInfo = additionalInfo( severity: .warning, message: "No Valid Licensor available to deauthorize device. Signing out TPPAccount credentials anyway with no message to the user.", - metadata: metadata) - let err = NSError(domain: "SignOut deauthorization error: no licensor", - code: TPPErrorCode.invalidLicensor.rawValue, - userInfo: userInfo) + metadata: metadata + ) + let err = NSError( + domain: "SignOut deauthorization error: no licensor", + code: TPPErrorCode.invalidLicensor.rawValue, + userInfo: userInfo + ) record(error: err) } @@ -329,11 +364,13 @@ fileprivate let nullString = "null" /// - summary: This will be the top line (searchable) in Crashlytics UI. /// - barcode: The clear-text barcode used to authenticate. This will be /// hashed. - class func logUserProfileDocumentAuthError(_ error: NSError?, - summary: String, - barcode: String?, - metadata: [String: Any]? = nil) { - var userInfo = metadata ?? [String : Any]() + class func logUserProfileDocumentAuthError( + _ error: NSError?, + summary: String, + barcode: String?, + metadata: [String: Any]? = nil + ) { + var userInfo = metadata ?? [String: Any]() addAccountInfoToMetadata(&userInfo) userInfo = additionalInfo(severity: .error, metadata: userInfo) if let barcode = barcode { @@ -343,27 +380,31 @@ fileprivate let nullString = "null" userInfo[NSUnderlyingErrorKey] = originalError } - let err = NSError(domain: summary, - code: TPPErrorCode.userProfileDocFail.rawValue, - userInfo: userInfo) + let err = NSError( + domain: summary, + code: TPPErrorCode.userProfileDocFail.rawValue, + userInfo: userInfo + ) record(error: err) } - //---------------------------------------------------------------------------- - // MARK:- Misc + // ---------------------------------------------------------------------------- + // MARK: - Misc /** Report when user launches the app. */ class func logNewAppLaunch() { - var metadata = [String : Any]() + var metadata = [String: Any]() addAccountInfoToMetadata(&metadata) - + let userInfo = additionalInfo(severity: .info, metadata: metadata) - let err = NSError(domain: clientDomain, - code: TPPErrorCode.appLaunch.rawValue, - userInfo: userInfo) + let err = NSError( + domain: clientDomain, + code: TPPErrorCode.appLaunch.rawValue, + userInfo: userInfo + ) record(error: err) } @@ -375,32 +416,38 @@ fileprivate let nullString = "null" @return */ class func logBarcodeException(_ exception: NSException?, library: String?) { - var metadata: [String : Any] = [ + var metadata: [String: Any] = [ "Library": library ?? nullString, "ExceptionName": exception?.name ?? nullString, - "ExceptionReason": exception?.reason ?? nullString, + "ExceptionReason": exception?.reason ?? nullString ] addAccountInfoToMetadata(&metadata) let userInfo = additionalInfo(severity: .info, metadata: metadata) - let err = NSError(domain: "SignIn error: BarcodeScanner exception", - code: TPPErrorCode.barcodeException.rawValue, - userInfo: userInfo) + let err = NSError( + domain: "SignIn error: BarcodeScanner exception", + code: TPPErrorCode.barcodeException.rawValue, + userInfo: userInfo + ) record(error: err) } - class func logCatalogInitError(withCode code: TPPErrorCode, - response: URLResponse?, - metadata: [String: Any]?) { + class func logCatalogInitError( + withCode code: TPPErrorCode, + response: URLResponse?, + metadata: [String: Any]? + ) { var metadata = metadata ?? [String: Any]() if let response = response { metadata["response"] = response } - logError(withCode: code, - summary: "Catalog VC Initialization", - metadata: metadata) + logError( + withCode: code, + summary: "Catalog VC Initialization", + metadata: metadata + ) } /** @@ -410,11 +457,13 @@ fileprivate let nullString = "null" - parameter summary: This will be the top line (searchable) in Crashlytics UI. - parameter metadata: Any additional metadata to be logged for more context. */ - class func logProblemDocumentParseError(_ originalError: NSError, - problemDocumentData: Data?, - url: URL?, - summary: String, - metadata: [String: Any]? = nil) { + class func logProblemDocumentParseError( + _ originalError: NSError, + problemDocumentData: Data?, + url: URL?, + summary: String, + metadata: [String: Any]? = nil + ) { var metadata = metadata ?? [String: Any]() addAccountInfoToMetadata(&metadata) metadata["url"] = url ?? nullString @@ -428,21 +477,26 @@ fileprivate let nullString = "null" let userInfo = additionalInfo( severity: .error, - metadata: metadata) + metadata: metadata + ) - let err = NSError(domain: summary, - code: TPPErrorCode.parseProblemDocFail.rawValue, - userInfo: userInfo) + let err = NSError( + domain: summary, + code: TPPErrorCode.parseProblemDocFail.rawValue, + userInfo: userInfo + ) record(error: err) } - - //---------------------------------------------------------------------------- - // MARK:- Private helpers + + // ---------------------------------------------------------------------------- + // MARK: - Private helpers private class func record(error: NSError) { // Only enable Crashlytics on Production builds - guard Bundle.main.applicationEnvironment == .production else { return } + guard Bundle.main.applicationEnvironment == .production else { + return + } #if FEATURE_CRASH_REPORTING Crashlytics.crashlytics().record(error: error) @@ -459,14 +513,16 @@ fileprivate let nullString = "null" /// - request: The request that returned the error. /// - response: The response that returned the error. /// - metadata: Any additional metadata to be logged. - private class func logError(_ originalError: Error?, - code: TPPErrorCode = .ignore, - summary: String, - request: URLRequest? = nil, - response: URLResponse? = nil, - metadata: [String: Any]? = nil) { + private class func logError( + _ originalError: Error?, + code: TPPErrorCode = .ignore, + summary: String, + request: URLRequest? = nil, + response: URLResponse? = nil, + metadata: [String: Any]? = nil + ) { // compute metadata - var metadata = metadata ?? [String : Any]() + var metadata = metadata ?? [String: Any]() addAccountInfoToMetadata(&metadata) if let request = request { Log.error(#file, "Request \(request.loggableString) failed.") @@ -481,16 +537,22 @@ fileprivate let nullString = "null" } // compute final summary and code, plus severity - let (finalSummary, finalCode, severity) = fixUpSummary(summary, - code: code, - with: originalError) + let (finalSummary, finalCode, severity) = fixUpSummary( + summary, + code: code, + with: originalError + ) // build error report - let userInfo = additionalInfo(severity: severity, - metadata: metadata) - let err = NSError(domain: finalSummary, - code: finalCode, - userInfo: userInfo) + let userInfo = additionalInfo( + severity: severity, + metadata: metadata + ) + let err = NSError( + domain: finalSummary, + code: finalCode, + userInfo: userInfo + ) record(error: err) } @@ -503,22 +565,23 @@ fileprivate let nullString = "null" /// - err: The error to inspect. /// - Returns: A tuple with the final suggested summary and code to use /// to file a report on Crashlytics. - private class func fixUpSummary(_ summary: String, - code: TPPErrorCode, - with err: Error?) -> (summary: String, code: Int, severity: TPPSeverity) { + private class func fixUpSummary( + _ summary: String, + code: TPPErrorCode, + with err: Error? + ) -> (summary: String, code: Int, severity: TPPSeverity) { if let nserr = err as NSError? { if let (finalSummary, finalCode) = customSummaryAndCode(from: nserr) { return (summary: finalSummary, code: finalCode.rawValue, severity: .warning) } } - let finalCode: Int - if code != .ignore { - finalCode = code.rawValue + let finalCode: Int = if code != .ignore { + code.rawValue } else if let nserr = err as NSError? { - finalCode = nserr.code + nserr.code } else { - finalCode = TPPErrorCode.ignore.rawValue + TPPErrorCode.ignore.rawValue } return (summary: summary, code: finalCode, severity: .error) @@ -535,7 +598,6 @@ fileprivate let nullString = "null" let cfErrorDomainNetwork = (kCFErrorDomainCFNetwork as String) switch err.domain { - case NSURLErrorDomain: switch err.code { case NSURLErrorUserCancelledAuthentication: @@ -562,30 +624,44 @@ fileprivate let nullString = "null" let code = err.code if code == CFNetworkErrors.cfurlErrorUserCancelledAuthentication.rawValue { - return (summary: "User Cancelled Authentication", - code: .clientSideUserInterruption) + return ( + summary: "User Cancelled Authentication", + code: .clientSideUserInterruption + ) } else if code == CFNetworkErrors.cfurlErrorCancelled.rawValue - || code == CFNetworkErrors.cfNetServiceErrorCancel.rawValue { - return (summary: "Request Cancelled", - code: .clientSideUserInterruption) + || code == CFNetworkErrors.cfNetServiceErrorCancel.rawValue + { + return ( + summary: "Request Cancelled", + code: .clientSideUserInterruption + ) } else if code == CFNetworkErrors.cfurlErrorTimedOut.rawValue - || code == CFNetworkErrors.cfNetServiceErrorTimeout.rawValue { - return (summary: "Request Timeout", - code: .clientSideTransientError) + || code == CFNetworkErrors.cfNetServiceErrorTimeout.rawValue + { + return ( + summary: "Request Timeout", + code: .clientSideTransientError + ) } else if code == CFNetworkErrors.cfurlErrorNetworkConnectionLost.rawValue { - return (summary: "Connection Lost/Severed", - code: .clientSideTransientError) + return ( + summary: "Connection Lost/Severed", + code: .clientSideTransientError + ) } else if code == CFNetworkErrors.cfurlErrorNotConnectedToInternet.rawValue - || code == CFNetworkErrors.cfurlErrorInternationalRoamingOff.rawValue { - return (summary: "No Internet Connection", - code: .clientSideTransientError) + || code == CFNetworkErrors.cfurlErrorInternationalRoamingOff.rawValue + { + return ( + summary: "No Internet Connection", + code: .clientSideTransientError + ) } else if code == CFNetworkErrors.cfurlErrorCallIsActive.rawValue - || code == CFNetworkErrors.cfurlErrorDataNotAllowed.rawValue { + || code == CFNetworkErrors.cfurlErrorDataNotAllowed.rawValue + { return (summary: "User Device Cannot Connect", code: .clientSideTransientError) } @@ -618,9 +694,11 @@ fileprivate let nullString = "null" /// - severity: How severe the event is. /// - message: An optional message. /// - metadata: Any additional metadata. - private class func additionalInfo(severity: TPPSeverity, - message: String? = nil, - metadata: [String: Any]? = nil) -> [String: Any] { + private class func additionalInfo( + severity: TPPSeverity, + message: String? = nil, + metadata: [String: Any]? = nil + ) -> [String: Any] { var dict = metadata ?? [:] dict["severity"] = severity.stringValue() diff --git a/Palace/Logging/URLRequest+Logging.swift b/Palace/Logging/URLRequest+Logging.swift index 652f2c7b5..3cfe1389f 100644 --- a/Palace/Logging/URLRequest+Logging.swift +++ b/Palace/Logging/URLRequest+Logging.swift @@ -9,7 +9,6 @@ import Foundation extension URLRequest { - /// Since a request can include sensitive data such as access tokens, etc, /// this computed variable includes a "safe" set of data that we can log. var loggableString: String { @@ -24,12 +23,12 @@ extension URLRequest { @objc extension NSURLRequest { var loggableString: String { - return (self as URLRequest).loggableString + (self as URLRequest).loggableString } } extension URLRequest { var isTokenAuthorized: Bool { - self.allHTTPHeaderFields?["Authorization"]?.hasPrefix("Bearer") ?? false + allHTTPHeaderFields?["Authorization"]?.hasPrefix("Bearer") ?? false } } diff --git a/Palace/Migrations/SEMigrations.swift b/Palace/Migrations/SEMigrations.swift index faf75e510..45dd7abe9 100644 --- a/Palace/Migrations/SEMigrations.swift +++ b/Palace/Migrations/SEMigrations.swift @@ -11,14 +11,14 @@ extension TPPMigrationManager { static func runMigrations() { // Fetch and parse app version let appVersion = TPPSettings.shared.appVersion ?? "" - let appVersionTokens = appVersion.split(separator: ".").compactMap({ Int($0) }) + let appVersionTokens = appVersion.split(separator: ".").compactMap { Int($0) } // Run through migration stages if version(appVersionTokens, isLessThan: [3, 2, 0]) { // v3.2.0 - migrate1(); + migrate1() } if version(appVersionTokens, isLessThan: [3, 3, 0]) { // v3.3.0 - migrate2(); + migrate2() } // Migrate Network Queue DB @@ -27,7 +27,7 @@ extension TPPMigrationManager { // v3.2.0 // Account IDs are changing, so we need to migrate resources accordingly - private static func migrate1() -> Void { + private static func migrate1() { Log.info(#file, "Running 3.2.0 migration") // Build account map where the key is the old account ID and the value is the new account ID @@ -53,11 +53,12 @@ extension TPPMigrationManager { // Build old & new lists for reference in logic // Note: Can't use NYPLSettings because the swift version stops using optionals and performs coerscions - let oldAccountsList = UserDefaults.standard.array(forKey: "NYPLSettingsLibraryAccountsKey")?.compactMap({ $0 as? Int }) ?? [Int]() - let newAccountsList = UserDefaults.standard.array(forKey: "NYPLSettingsLibraryAccountsKey")?.compactMap({ + let oldAccountsList = UserDefaults.standard.array(forKey: "NYPLSettingsLibraryAccountsKey")? + .compactMap { $0 as? Int } ?? [Int]() + let newAccountsList = UserDefaults.standard.array(forKey: "NYPLSettingsLibraryAccountsKey")?.compactMap { let idInt = $0 as? Int return $0 as? String ?? (idInt != nil ? accountMap[idInt!] : nil) - }) ?? [String]() + } ?? [String]() // Assign new uuid account list // The list of accounts would have been integers before; they will now be stored as a list of strings @@ -94,7 +95,10 @@ extension TPPMigrationManager { do { try FileManager.default.moveItem(atPath: oldDirectoryPath.path, toPath: newDirectoryPath.path) } catch { - Log.error(#file, "Could not move directory from \(oldDirectoryPath.path) to \(newDirectoryPath.path) \(error)") + Log.error( + #file, + "Could not move directory from \(oldDirectoryPath.path) to \(newDirectoryPath.path) \(error)" + ) } } } @@ -102,11 +106,19 @@ extension TPPMigrationManager { // v3.3.0 // Cached library registry results locations are changing - private static func migrate2() -> Void { + private static func migrate2() { Log.info(#file, "Running 3.3.0 migration") // Cache locations are changing for catalogs, so we'll simply remove anything at the old locations - let applicationSupportUrl = try! FileManager.default.url(for: .applicationSupportDirectory, in: .userDomainMask, appropriateFor: nil, create: true) + guard let applicationSupportUrl = try? FileManager.default.url( + for: .applicationSupportDirectory, + in: .userDomainMask, + appropriateFor: nil, + create: true + ) else { + Log.error(#file, "Failed to get application support directory URL for migration") + return + } let origBetaUrl = applicationSupportUrl.appendingPathComponent("library_list_beta.json") let origProdUrl = applicationSupportUrl.appendingPathComponent("library_list_prod.json") try? FileManager.default.removeItem(at: origBetaUrl) diff --git a/Palace/Migrations/TPPMigrationManager.swift b/Palace/Migrations/TPPMigrationManager.swift index b455c0f69..feac719f3 100644 --- a/Palace/Migrations/TPPMigrationManager.swift +++ b/Palace/Migrations/TPPMigrationManager.swift @@ -1,19 +1,22 @@ import Foundation /** -Manages data migrations as they are needed throughout the app's life + Manages data migrations as they are needed throughout the app's life -App version is cached in UserDefaults and last cached value is checked against current build version -and updates are applied as required + App version is cached in UserDefaults and last cached value is checked against current build version + and updates are applied as required -NetworkQueue migration is invoked from here, but the logic is self-contained in the NetworkQueue class. -This is because DB-related operations should likely be scoped to that file in the event the DB framework or logic changes, -that module would know best how to handle changes. -*/ + NetworkQueue migration is invoked from here, but the logic is self-contained in the NetworkQueue class. + This is because DB-related operations should likely be scoped to that file in the event the DB framework or logic changes, + that module would know best how to handle changes. + */ class TPPMigrationManager: NSObject { @objc static func migrate() { // Fetch target version - let targetVersion = Bundle.main.infoDictionary!["CFBundleShortVersionString"] as! String + guard let targetVersion = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String else { + Log.error(#file, "Failed to get CFBundleShortVersionString from bundle info") + return + } runMigrations() @@ -24,14 +27,14 @@ class TPPMigrationManager: NSObject { /// Compares app versions. /// /// - Note: An empty `a` version is considered "less than" a non-empty `b`. - /// + /// /// - Parameters: /// - a: An array of integers expressing a version number. /// - b: An array of integers expressing a version number. /// - Returns: `true` if version `a` is anterior to version `b`, or if `a` is /// empty and `b` is not, or if `a` and `b` coincide except `b` has more /// components than `a` (e.g. 1.2 vs 1.2.1). - static func version(_ a: [Int], isLessThan b:[Int]) -> Bool { + static func version(_ a: [Int], isLessThan b: [Int]) -> Bool { var i = 0 while i < a.count && i < b.count { guard a[i] == b[i] else { diff --git a/Palace/MyBooks/MyBooks/BookCell/BookCell.swift b/Palace/MyBooks/MyBooks/BookCell/BookCell.swift index 5a4bfabe8..9bb282b58 100644 --- a/Palace/MyBooks/MyBooks/BookCell/BookCell.swift +++ b/Palace/MyBooks/MyBooks/BookCell/BookCell.swift @@ -6,16 +6,16 @@ // Copyright © 2023 The Palace Project. All rights reserved. // -import SwiftUI import Combine +import SwiftUI struct BookCell: View { @ObservedObject var model: BookCellModel - + var body: some View { bookCell } - + @ViewBuilder private var bookCell: some View { switch model.state { case .downloading, .downloadFailed: diff --git a/Palace/MyBooks/MyBooks/BookCell/BookCellModel.swift b/Palace/MyBooks/MyBooks/BookCell/BookCellModel.swift index afd755877..67a355211 100644 --- a/Palace/MyBooks/MyBooks/BookCell/BookCellModel.swift +++ b/Palace/MyBooks/MyBooks/BookCell/BookCellModel.swift @@ -6,22 +6,24 @@ // Copyright © 2023 The Palace Project. All rights reserved. // -import Foundation import Combine -import SwiftUI +import Foundation import PalaceAudiobookToolkit +import SwiftUI + +// MARK: - BookCellState enum BookCellState { case normal(BookButtonState) case downloading(BookButtonState) case downloadFailed(BookButtonState) - + var buttonState: BookButtonState { switch self { - case .normal(let state), - .downloading(let state), - .downloadFailed(let state): - return state + case let .normal(state), + let .downloading(state), + let .downloadFailed(state): + state } } } @@ -39,10 +41,12 @@ extension BookCellState { } } +// MARK: - BookCellModel + @MainActor class BookCellModel: ObservableObject { typealias DisplayStrings = Strings.BookCell - + @Published var image = ImageProviders.MyBooksView.bookPlaceholder ?? UIImage() @Published var showAlert: AlertModel? @Published var isLoading: Bool = false { @@ -50,19 +54,19 @@ class BookCellModel: ObservableObject { statePublisher.send(isLoading) } } - + @Published private var currentBookIdentifier: String? - + private var cancellables = Set() let imageCache: ImageCacheType private var isFetchingImage = false #if LCP private var didPrefetchLCPStreaming = false #endif - + var statePublisher = PassthroughSubject() var state: BookCellState - + @Published var book: TPPBook { didSet { if book.identifier != currentBookIdentifier { @@ -71,40 +75,41 @@ class BookCellModel: ObservableObject { } } } - + @Published var isManagingHold: Bool = false @Published private(set) var stableButtonState: BookButtonState = .unsupported @Published private(set) var registryState: TPPBookState - @Published private var localBookStateOverride: TPPBookState? = nil + @Published private var localBookStateOverride: TPPBookState? @Published var showHalfSheet: Bool = false var title: String { book.title } var authors: String { book.authors ?? "" } var showUnreadIndicator: Bool { - if case .normal(let bookState) = state, bookState == .downloadSuccessful { - return true + if case let .normal(bookState) = state, bookState == .downloadSuccessful { + true } else { - return false + false } } - + var buttonTypes: [BookButtonType] { - if localBookStateOverride == .returning { return BookButtonState.returning.buttonTypes(book: book) } + if localBookStateOverride == .returning { + return BookButtonState.returning.buttonTypes(book: book) + } return stableButtonState.buttonTypes(book: book) } - - + // MARK: - Initializer - + init(book: TPPBook, imageCache: ImageCacheType) { self.book = book - self.state = BookCellState(BookButtonState(book) ?? .unsupported) - self.isLoading = TPPBookRegistry.shared.processing(forIdentifier: book.identifier) - self.currentBookIdentifier = book.identifier + state = BookCellState(BookButtonState(book) ?? .unsupported) + isLoading = TPPBookRegistry.shared.processing(forIdentifier: book.identifier) + currentBookIdentifier = book.identifier self.imageCache = imageCache - self.registryState = TPPBookRegistry.shared.state(for: book.identifier) - self.stableButtonState = self.computeButtonState(book: book, registryState: self.registryState, isManagingHold: self.isManagingHold) + registryState = TPPBookRegistry.shared.state(for: book.identifier) + stableButtonState = computeButtonState(book: book, registryState: registryState, isManagingHold: isManagingHold) registerForNotifications() loadBookCoverImage() bindRegistryState() @@ -113,17 +118,17 @@ class BookCellModel: ObservableObject { prefetchLCPStreamingIfPossible() #endif } - + deinit { NotificationCenter.default.removeObserver(self) } - + // MARK: - Image Loading - + func loadBookCoverImage() { let simpleKey = book.identifier let thumbnailKey = "\(book.identifier)_thumbnail" - + if let cachedImage = imageCache.get(for: simpleKey) ?? imageCache.get(for: thumbnailKey) { image = cachedImage } else if let registryImage = TPPBookRegistry.shared.cachedThumbnailImage(for: book) { @@ -132,23 +137,29 @@ class BookCellModel: ObservableObject { fetchAndCacheImage() } } - + private func fetchAndCacheImage() { - guard !isFetchingImage else { return } + guard !isFetchingImage else { + return + } isFetchingImage = true isLoading = true - + DispatchQueue.main.async { [weak self] in - guard let self = self else { return } - TPPBookRegistry.shared.thumbnailImage(for: self.book) { [weak self] fetchedImage in - guard let self = self, let fetchedImage else { return } - self.setImageAndCache(fetchedImage) - self.isLoading = false - self.isFetchingImage = false + guard let self = self else { + return + } + TPPBookRegistry.shared.thumbnailImage(for: book) { [weak self] fetchedImage in + guard let self = self, let fetchedImage else { + return + } + setImageAndCache(fetchedImage) + isLoading = false + isFetchingImage = false } } } - + private func setImageAndCache(_ image: UIImage) { let simpleKey = book.identifier let thumbnailKey = "\(book.identifier)_thumbnail" @@ -156,18 +167,22 @@ class BookCellModel: ObservableObject { imageCache.set(image, for: thumbnailKey) self.image = image } - + // MARK: - Notification Handling - + private func registerForNotifications() { - NotificationCenter.default.addObserver(self, selector: #selector(updateButtons), - name: .TPPReachabilityChanged, object: nil) + NotificationCenter.default.addObserver( + self, + selector: #selector(updateButtons), + name: .TPPReachabilityChanged, + object: nil + ) } private func bindRegistryState() { TPPBookRegistry.shared.bookStatePublisher .filter { [weak self] in $0.0 == self?.book.identifier } - .map { $0.1 } + .map(\.1) .receive(on: DispatchQueue.main) .sink { [weak self] newState in self?.registryState = newState @@ -179,7 +194,9 @@ class BookCellModel: ObservableObject { let availability = book.defaultAcquisition?.availability // Only reflect actual download state from registry; do not treat UI image loading as download-in-progress let isProcessingDownload = registryState == .downloading - if case .holding = registryState, isManagingHold { return .managingHold } + if case .holding = registryState, isManagingHold { + return .managingHold + } return BookButtonMapper.map( registryState: registryState, availability: availability, @@ -197,7 +214,7 @@ class BookCellModel: ObservableObject { .receive(on: DispatchQueue.main) .assign(to: &$stableButtonState) } - + @objc private func updateButtons() { Task { @MainActor [weak self] in self?.isLoading = false @@ -232,15 +249,17 @@ extension BookCellModel { return } } - + func didSelectRead() { isLoading = true switch book.defaultBookContentType { case .epub: ReaderService.shared.openEPUB(book) - self.isLoading = false + isLoading = false case .pdf: - guard let url = MyBooksDownloadCenter.shared.fileUrl(for: book.identifier) else { self.isLoading = false; return } + guard let url = MyBooksDownloadCenter.shared.fileUrl(for: book.identifier) else { + isLoading = false; return + } let data = try? Data(contentsOf: url) let metadata = TPPPDFDocumentMetadata(with: book) let document = TPPPDFDocument(data: data ?? Data()) @@ -248,11 +267,11 @@ extension BookCellModel { coordinator.storePDF(document: document, metadata: metadata, forBookId: book.identifier) coordinator.push(.pdf(BookRoute(id: book.identifier))) } - self.isLoading = false + isLoading = false case .audiobook: openAudiobookFromCell() default: - self.isLoading = false + isLoading = false } } @@ -262,37 +281,46 @@ extension BookCellModel { } } - - private func presentAudiobookFrom(json: [String: Any], decryptor: DRMDecryptor?) { + private func presentAudiobookFrom(json _: [String: Any], decryptor _: DRMDecryptor?) { BookService.open(book) - self.isLoading = false + isLoading = false } private func licenseURL(forBookIdentifier identifier: String) -> URL? { -#if LCP - guard let contentURL = MyBooksDownloadCenter.shared.fileUrl(for: identifier) else { return nil } + #if LCP + guard let contentURL = MyBooksDownloadCenter.shared.fileUrl(for: identifier) else { + return nil + } let license = contentURL.deletingPathExtension().appendingPathExtension("lcpl") return FileManager.default.fileExists(atPath: license.path) ? license : nil -#else + #else return nil -#endif + #endif } #if LCP private func prefetchLCPStreamingIfPossible() { - guard !didPrefetchLCPStreaming, LCPAudiobooks.canOpenBook(book) else { return } - if let localURL = MyBooksDownloadCenter.shared.fileUrl(for: book.identifier), FileManager.default.fileExists(atPath: localURL.path) { + guard !didPrefetchLCPStreaming, LCPAudiobooks.canOpenBook(book) else { + return + } + if let localURL = MyBooksDownloadCenter.shared.fileUrl(for: book.identifier), + FileManager.default.fileExists(atPath: localURL.path) + { + return + } + guard let license = licenseURL(forBookIdentifier: book.identifier), + let lcpAudiobooks = LCPAudiobooks(for: license) + else { return } - guard let license = licenseURL(forBookIdentifier: book.identifier), let lcpAudiobooks = LCPAudiobooks(for: license) else { return } didPrefetchLCPStreaming = true lcpAudiobooks.startPrefetch() } #endif - + func didSelectReturn() { - self.isLoading = true - let identifier = self.book.identifier + isLoading = true + let identifier = book.identifier MyBooksDownloadCenter.shared.returnBook(withIdentifier: identifier) { [weak self] in DispatchQueue.main.async { self?.isLoading = false @@ -301,13 +329,15 @@ extension BookCellModel { } } } - + func didSelectDownload() { let account = TPPUserAccount.sharedAccount() if account.needsAuth && !account.hasCredentials() { TPPAccountSignInViewController.requestCredentials { [weak self] in - guard let self else { return } - self.startDownloadNow() + guard let self else { + return + } + startDownloadNow() } return } @@ -326,9 +356,11 @@ extension BookCellModel { let account = TPPUserAccount.sharedAccount() if account.needsAuth && !account.hasCredentials() { TPPAccountSignInViewController.requestCredentials { [weak self] in - guard let self else { return } + guard let self else { + return + } TPPUserNotifications.requestAuthorization() - MyBooksDownloadCenter.shared.startBorrow(for: self.book, attemptDownload: false) { [weak self] in + MyBooksDownloadCenter.shared.startBorrow(for: book, attemptDownload: false) { [weak self] in DispatchQueue.main.async { self?.isLoading = false } } } @@ -339,12 +371,12 @@ extension BookCellModel { DispatchQueue.main.async { self?.isLoading = false } } } - + func didSelectSample() { isLoading = true if book.defaultBookContentType == .audiobook { SamplePreviewManager.shared.toggle(for: book) - self.isLoading = false + isLoading = false return } EpubSampleFactory.createSample(book: book) { sampleURL, error in @@ -356,36 +388,44 @@ extension BookCellModel { } if let sampleWebURL = sampleURL as? EpubSampleWebURL { let web = BundledHTMLViewController(fileURL: sampleWebURL.url, title: self.book.title) - if let appDelegate = UIApplication.shared.delegate as? TPPAppDelegate, let top = appDelegate.topViewController() { + if let appDelegate = UIApplication.shared.delegate as? TPPAppDelegate, + let top = appDelegate.topViewController() + { top.present(web, animated: true) } return } if let url = sampleURL?.url { let web = BundledHTMLViewController(fileURL: url, title: self.book.title) - if let appDelegate = UIApplication.shared.delegate as? TPPAppDelegate, let top = appDelegate.topViewController() { + if let appDelegate = UIApplication.shared.delegate as? TPPAppDelegate, + let top = appDelegate.topViewController() + { top.present(web, animated: true) } } } } } - + func didSelectCancel() { MyBooksDownloadCenter.shared.cancelDownload(for: book.identifier) } } +// MARK: BookButtonProvider + extension BookCellModel: BookButtonProvider { func handleAction(for type: BookButtonType) { callDelegate(for: type) } - - func isProcessing(for type: BookButtonType) -> Bool { + + func isProcessing(for _: BookButtonType) -> Bool { isLoading } } +// MARK: HalfSheetProvider + extension BookCellModel: HalfSheetProvider { var bookState: TPPBookState { get { @@ -399,7 +439,7 @@ extension BookCellModel: HalfSheetProvider { } } } - + var buttonState: BookButtonState { let registryState = TPPBookRegistry.shared.state(for: book.identifier) let availability = book.defaultAcquisition?.availability @@ -410,11 +450,11 @@ extension BookCellModel: HalfSheetProvider { isProcessingDownload: isDownloading ) } - + var isFullSize: Bool { UIDevice.current.userInterfaceIdiom == .pad } - + var downloadProgress: Double { MyBooksDownloadCenter.shared.downloadProgress(for: book.identifier) } diff --git a/Palace/MyBooks/MyBooks/BookCell/ButtonView/BookButtonState.swift b/Palace/MyBooks/MyBooks/BookCell/ButtonView/BookButtonState.swift index a679e2274..b16dc3041 100644 --- a/Palace/MyBooks/MyBooks/BookCell/ButtonView/BookButtonState.swift +++ b/Palace/MyBooks/MyBooks/BookCell/ButtonView/BookButtonState.swift @@ -8,6 +8,8 @@ import Foundation +// MARK: - BookButtonState + enum BookButtonState: Equatable { case canBorrow case canHold @@ -26,7 +28,7 @@ enum BookButtonState: Equatable { extension BookButtonState { func buttonTypes(book: TPPBook, previewEnabled: Bool = true) -> [BookButtonType] { var buttons = [BookButtonType]() - + switch self { case .canBorrow: buttons.append(.get) @@ -51,7 +53,8 @@ extension BookButtonState { } case .downloadNeeded: if let authDef = TPPUserAccount.sharedAccount().authDefinition, - authDef.needsAuth || book.defaultAcquisitionIfOpenAccess != nil { + authDef.needsAuth || book.defaultAcquisitionIfOpenAccess != nil + { buttons = [.download, .return] } else { buttons = [.download, .remove] @@ -68,7 +71,8 @@ extension BookButtonState { if let authDef = TPPUserAccount.sharedAccount().authDefinition, authDef.needsAuth || - book.defaultAcquisitionIfOpenAccess != nil { + book.defaultAcquisitionIfOpenAccess != nil + { buttons.append(.return) } else { buttons.append(.remove) @@ -91,23 +95,25 @@ extension BookButtonState { return buttons } - + private func isHoldReady(book: TPPBook) -> Bool { - guard let availability = book.defaultAcquisition?.availability else { return false } - + guard let availability = book.defaultAcquisition?.availability else { + return false + } + var isReady = false availability.matchUnavailable { _ in isReady = false - } limited: { _ in + } limited: { _ in isReady = false } unlimited: { _ in - isReady = false + isReady = false } reserved: { _ in - isReady = false // Still waiting in queue + isReady = false // Still waiting in queue } ready: { _ in - isReady = true // Hold is ready to borrow! + isReady = true // Hold is ready to borrow! } - + return isReady } } @@ -118,10 +124,13 @@ extension BookButtonState { switch bookState { case .unregistered, .holding: guard let buttonState = Self.stateForAvailability(book.defaultAcquisition?.availability) else { - TPPErrorLogger.logError(withCode: .noURL, summary: "Unable to determine BookButtonsViewState because no Availability was provided") + TPPErrorLogger.logError( + withCode: .noURL, + summary: "Unable to determine BookButtonsViewState because no Availability was provided" + ) return nil } - + self = buttonState case .downloadNeeded: #if LCP @@ -167,7 +176,7 @@ extension BookButtonState { } reserved: { _ in state = .holdingFrontOfQueue } ready: { _ in - state = .canBorrow // Hold is ready, user can borrow + state = .canBorrow // Hold is ready, user can borrow } return state @@ -178,13 +187,14 @@ extension TPPBook { func supportsDeletion(for state: BookButtonState) -> Bool { var fullfillmentRequired = false #if FEATURE_DRM_CONNECTOR - fullfillmentRequired = state == .holding && self.revokeURL != nil + fullfillmentRequired = state == .holding && revokeURL != nil #endif - - let hasFullfillmentId = TPPBookRegistry.shared.fulfillmentId(forIdentifier: self.identifier) != nil - let isFullfiliable = !(hasFullfillmentId && fullfillmentRequired) && self.revokeURL != nil - let needsAuthentication = self.defaultAcquisitionIfOpenAccess == nil && TPPUserAccount.sharedAccount().authDefinition?.needsAuth ?? false - + + let hasFullfillmentId = TPPBookRegistry.shared.fulfillmentId(forIdentifier: identifier) != nil + let isFullfiliable = !(hasFullfillmentId && fullfillmentRequired) && revokeURL != nil + let needsAuthentication = defaultAcquisitionIfOpenAccess == nil && TPPUserAccount.sharedAccount().authDefinition? + .needsAuth ?? false + return isFullfiliable && !needsAuthentication } } diff --git a/Palace/MyBooks/MyBooks/BookCell/ButtonView/BookButtonType.swift b/Palace/MyBooks/MyBooks/BookCell/ButtonView/BookButtonType.swift index b74e52df3..967dc7344 100644 --- a/Palace/MyBooks/MyBooks/BookCell/ButtonView/BookButtonType.swift +++ b/Palace/MyBooks/MyBooks/BookCell/ButtonView/BookButtonType.swift @@ -8,6 +8,8 @@ import SwiftUI +// MARK: - BookButtonType + enum BookButtonType: String { case get case reserve @@ -26,9 +28,9 @@ enum BookButtonType: String { case returning var localizedTitle: String { - NSLocalizedString(self.rawValue, comment: "Book Action Button title") + NSLocalizedString(rawValue, comment: "Book Action Button title") } - + var displaysIndicator: Bool { switch self { case .read, .remove, .get, .download, .listen: @@ -37,7 +39,7 @@ enum BookButtonType: String { false } } - + var isDisabled: Bool { switch self { case .read, .listen, .remove: @@ -48,7 +50,7 @@ enum BookButtonType: String { } } -fileprivate typealias DisplayStrings = Strings.BookButton +private typealias DisplayStrings = Strings.BookButton extension BookButtonType { var title: String { @@ -108,23 +110,24 @@ extension BookButtonType { case .secondary, .tertiary: isDarkBackground ? .white : .black case .destructive: - .palaceErrorBase - + .palaceErrorBase } } func borderColor(_ isDarkBackground: Bool) -> Color { switch buttonStyle { case .secondary: - (isDarkBackground ? .white : .black) + isDarkBackground ? .white : .black case .destructive: - .palaceErrorBase + .palaceErrorBase default: - .clear + .clear } } } +// MARK: - ButtonStyleType + enum ButtonStyleType { case primary case secondary diff --git a/Palace/MyBooks/MyBooks/BookCell/ButtonView/BookButtonsView.swift b/Palace/MyBooks/MyBooks/BookCell/ButtonView/BookButtonsView.swift index 64d4e7298..011628af5 100644 --- a/Palace/MyBooks/MyBooks/BookCell/ButtonView/BookButtonsView.swift +++ b/Palace/MyBooks/MyBooks/BookCell/ButtonView/BookButtonsView.swift @@ -1,6 +1,8 @@ import SwiftUI -fileprivate typealias DisplayStrings = Strings.BookButton +private typealias DisplayStrings = Strings.BookButton + +// MARK: - BookButtonProvider @MainActor protocol BookButtonProvider: ObservableObject { @@ -11,6 +13,7 @@ protocol BookButtonProvider: ObservableObject { } // MARK: - BookButtonsView + struct BookButtonsView: View { @ObservedObject var provider: T var previewEnabled: Bool = true @@ -45,6 +48,7 @@ struct BookButtonsView: View { } // MARK: - ActionButton + struct ActionButton: View { let type: BookButtonType @ObservedObject var provider: T @@ -53,7 +57,7 @@ struct ActionButton: View { var onButtonTapped: ((BookButtonType) -> Void)? private var accessibilityString: String { - return type.title + type.title } var body: some View { @@ -95,7 +99,8 @@ struct ActionButton: View { } } -// MARK: - Button Size Enum +// MARK: - ButtonSize + enum ButtonSize { case large case medium @@ -103,30 +108,32 @@ enum ButtonSize { var height: CGFloat { switch self { - case .large: return 44 - case .medium: return 40 - case .small: return 34 + case .large: 44 + case .medium: 40 + case .small: 34 } } var font: Font { switch self { - case .large: return .semiBoldPalaceFont(size: 14) - case .medium: return .semiBoldPalaceFont(size: 13) - case .small: return .semiBoldPalaceFont(size: 12) + case .large: .semiBoldPalaceFont(size: 14) + case .medium: .semiBoldPalaceFont(size: 13) + case .small: .semiBoldPalaceFont(size: 12) } } var padding: EdgeInsets { switch self { - case .large: return EdgeInsets(top: 10, leading: 16, bottom: 10, trailing: 16) - case .medium: return EdgeInsets(top: 8, leading: 14, bottom: 8, trailing: 14) - case .small: return EdgeInsets(top: 6, leading: 12, bottom: 6, trailing: 12) + case .large: EdgeInsets(top: 10, leading: 16, bottom: 10, trailing: 16) + case .medium: EdgeInsets(top: 8, leading: 14, bottom: 8, trailing: 14) + case .small: EdgeInsets(top: 6, leading: 12, bottom: 6, trailing: 12) } } } -struct HapticFeedback { +// MARK: - HapticFeedback + +enum HapticFeedback { static func medium() { let generator = UIImpactFeedbackGenerator(style: .medium) generator.prepare() diff --git a/Palace/MyBooks/MyBooks/BookCell/DownloadingBookCell.swift b/Palace/MyBooks/MyBooks/BookCell/DownloadingBookCell.swift index a8f9966cc..26443096e 100644 --- a/Palace/MyBooks/MyBooks/BookCell/DownloadingBookCell.swift +++ b/Palace/MyBooks/MyBooks/BookCell/DownloadingBookCell.swift @@ -6,14 +6,15 @@ // Copyright © 2023 The Palace Project. All rights reserved. // -import SwiftUI import PalaceUIKit +import SwiftUI struct DownloadingBookCell: View { @ObservedObject var model: BookCellModel private let cellHeight = 125.0 @State private var progress = 0.0 - var downloadPublisher = NotificationCenter.default.publisher(for: NSNotification.Name.TPPMyBooksDownloadCenterDidChange) + var downloadPublisher = NotificationCenter.default + .publisher(for: NSNotification.Name.TPPMyBooksDownloadCenterDidChange) @Environment(\.colorScheme) private var colorScheme var body: some View { @@ -41,7 +42,7 @@ struct DownloadingBookCell: View { } .foregroundColor(Color(TPPConfiguration.backgroundColor())) } - + @ViewBuilder private var statusView: some View { switch model.state.buttonState { case .downloadFailed: @@ -65,7 +66,7 @@ struct DownloadingBookCell: View { .palaceFont(size: 12) .foregroundColor(Color(TPPConfiguration.backgroundColor())) .onReceive(downloadPublisher) { _ in - self.progress = MyBooksDownloadCenter.shared.downloadProgress(for: model.book.identifier) + progress = MyBooksDownloadCenter.shared.downloadProgress(for: model.book.identifier) } } diff --git a/Palace/MyBooks/MyBooks/BookCell/NormalBookCell.swift b/Palace/MyBooks/MyBooks/BookCell/NormalBookCell.swift index 0f99d7e69..920a9acf0 100644 --- a/Palace/MyBooks/MyBooks/BookCell/NormalBookCell.swift +++ b/Palace/MyBooks/MyBooks/BookCell/NormalBookCell.swift @@ -6,10 +6,9 @@ // Copyright © 2023 The Palace Project. All rights reserved. // - -import SwiftUI import Combine import PalaceUIKit +import SwiftUI struct NormalBookCell: View { @Environment(\.colorScheme) var colorScheme @@ -35,9 +34,9 @@ struct NormalBookCell: View { .padding(.bottom, 5) } .frame(maxWidth: .infinity, alignment: .leading) - .sheet(isPresented: $showHalfSheet, onDismiss: { + .sheet(isPresented: $showHalfSheet, onDismiss: { model.showHalfSheet = false - model.isManagingHold = false // Reset managing hold state when sheet is dismissed + model.isManagingHold = false // Reset managing hold state when sheet is dismissed }) { HalfSheetView( viewModel: model, @@ -66,15 +65,15 @@ struct NormalBookCell: View { .frame(width: cellHeight * 2.0 / 3.0) } - - @ViewBuilder private var infoView: some View { VStack(alignment: .leading) { Text(model.title) .lineLimit(2) .palaceFont(size: 17) .fixedSize(horizontal: false, vertical: true) - .accessibilityLabel(model.book.defaultBookContentType == .audiobook ? "\(model.book.title). Audiobook." : model.book.title) + .accessibilityLabel(model.book.defaultBookContentType == .audiobook ? "\(model.book.title). Audiobook." : model + .book.title + ) Text(model.authors) .palaceFont(size: 12) } @@ -85,15 +84,15 @@ struct NormalBookCell: View { } @ViewBuilder private var buttons: some View { - BookButtonsView(provider: model, size: buttonSize) { type in - switch type { - case .close: - withAnimation(.spring()) { self.showHalfSheet = false } - default: - model.callDelegate(for: type) - withAnimation(.spring()) { self.showHalfSheet = model.showHalfSheet } - } + BookButtonsView(provider: model, size: buttonSize) { type in + switch type { + case .close: + withAnimation(.spring()) { showHalfSheet = false } + default: + model.callDelegate(for: type) + withAnimation(.spring()) { showHalfSheet = model.showHalfSheet } } + } } @ViewBuilder private var unreadImageView: some View { @@ -106,7 +105,7 @@ struct NormalBookCell: View { } .opacity(model.showUnreadIndicator ? 1.0 : 0.0) } - + @ViewBuilder var borrowedInfoView: some View { if model.registryState == .holding { holdingInfoView @@ -114,7 +113,7 @@ struct NormalBookCell: View { loanTermsInfoView } } - + @ViewBuilder var holdingInfoView: some View { let details = model.book.getReservationDetails() if details.holdPosition > 0 { diff --git a/Palace/MyBooks/MyBooks/BookListSkeletonView.swift b/Palace/MyBooks/MyBooks/BookListSkeletonView.swift index 6a67702ea..609801ee4 100644 --- a/Palace/MyBooks/MyBooks/BookListSkeletonView.swift +++ b/Palace/MyBooks/MyBooks/BookListSkeletonView.swift @@ -1,7 +1,9 @@ import SwiftUI +// MARK: - BookRowSkeletonView + struct BookRowSkeletonView: View { - var imageSize: CGSize = CGSize(width: 100, height: 150) + var imageSize: CGSize = .init(width: 100, height: 150) @State private var pulse: Bool = false var body: some View { HStack(alignment: .top, spacing: 12) { @@ -39,9 +41,11 @@ struct BookRowSkeletonView: View { } } +// MARK: - BookListSkeletonView + struct BookListSkeletonView: View { var rows: Int = 8 - var imageSize: CGSize = CGSize(width: 100, height: 150) + var imageSize: CGSize = .init(width: 100, height: 150) var body: some View { ScrollView { @@ -54,5 +58,3 @@ struct BookListSkeletonView: View { } } } - - diff --git a/Palace/MyBooks/MyBooks/BookListView.swift b/Palace/MyBooks/MyBooks/BookListView.swift index 6d6b6a761..2c637c8dd 100644 --- a/Palace/MyBooks/MyBooks/BookListView.swift +++ b/Palace/MyBooks/MyBooks/BookListView.swift @@ -1,5 +1,7 @@ import SwiftUI +// MARK: - BookListView + struct BookListView: View { let books: [TPPBook] @Binding var isLoading: Bool @@ -37,7 +39,7 @@ struct BookListView: View { let screenWidth = UIScreen.main.bounds.width let screenHeight = UIScreen.main.bounds.height let actualIsLandscape = screenWidth > screenHeight - + let columnCount = actualIsLandscape ? 3 : 2 return Array(repeating: GridItem(.flexible(), spacing: 0), count: columnCount) } else { @@ -51,4 +53,3 @@ extension View { modifier(BorderStyleModifier()) } } - diff --git a/Palace/MyBooks/MyBooks/FacetView.swift b/Palace/MyBooks/MyBooks/FacetView.swift index a70f9c259..2c4c45e6e 100644 --- a/Palace/MyBooks/MyBooks/FacetView.swift +++ b/Palace/MyBooks/MyBooks/FacetView.swift @@ -6,9 +6,9 @@ // Copyright © 2023 The Palace Project. All rights reserved. // -import SwiftUI import Combine import PalaceUIKit +import SwiftUI struct FacetView: View { @ObservedObject var model: FacetViewModel @@ -42,7 +42,7 @@ struct FacetView: View { .border(Color(TPPConfiguration.mainColor()), width: 1) .cornerRadius(2) } - + private var dividerView: some View { Rectangle() .fill(Color(UIColor.lightGray.withAlphaComponent(0.9))) @@ -55,16 +55,16 @@ struct FacetView: View { if let secondaryFacet = model.facets.first(where: { $0 != model.activeSort }) { buttons.append(ActionSheet.Button.default(Text(secondaryFacet.localizedString)) { - self.model.activeSort = secondaryFacet + model.activeSort = secondaryFacet }) buttons.append(Alert.Button.default(Text(model.activeSort.localizedString)) { - self.model.activeSort = model.activeSort + model.activeSort = model.activeSort }) } else { buttons.append(ActionSheet.Button.cancel(Text(Strings.Generic.cancel))) } - return ActionSheet(title: Text(""), message: Text(""), buttons:buttons) + return ActionSheet(title: Text(""), message: Text(""), buttons: buttons) } } diff --git a/Palace/MyBooks/MyBooks/FacetViewModel.swift b/Palace/MyBooks/MyBooks/FacetViewModel.swift index 49e7dfa69..20876f327 100644 --- a/Palace/MyBooks/MyBooks/FacetViewModel.swift +++ b/Palace/MyBooks/MyBooks/FacetViewModel.swift @@ -6,29 +6,33 @@ // Copyright © 2023 The Palace Project. All rights reserved. // -import Foundation import Combine +import Foundation + +// MARK: - Facet enum Facet: String { case author case title - + var localizedString: String { switch self { case .author: - return Strings.FacetView.author + Strings.FacetView.author case .title: - return Strings.FacetView.title + Strings.FacetView.title } } } +// MARK: - FacetViewModel + class FacetViewModel: ObservableObject { @Published var groupName: String @Published var facets: [Facet] @Published var activeSort: Facet @Published var currentAccount: Account? - @Published var accountScreenURL: URL? = nil + @Published var accountScreenURL: URL? @Published var showAccountScreen = false @Published var logo: UIImage? @@ -43,24 +47,28 @@ class FacetViewModel: ObservableObject { registerForNotifications() updateAccount() } - + private func registerForNotifications() { - NotificationCenter.default.addObserver(self, selector: #selector(updateAccount), - name: .TPPCurrentAccountDidChange, - object: nil) + NotificationCenter.default.addObserver( + self, + selector: #selector(updateAccount), + name: .TPPCurrentAccountDidChange, + object: nil + ) } - + @objc private func updateAccount() { currentAccount = AccountsManager.shared.currentAccount currentAccount?.logoDelegate = self accountScreenURL = currentAccountURL - logo = currentAccount?.logo + logo = currentAccount?.logo } } +// MARK: AccountLogoDelegate + extension FacetViewModel: AccountLogoDelegate { - func logoDidUpdate(in account: Account, to newLogo: UIImage) { - self.logo = newLogo + func logoDidUpdate(in _: Account, to newLogo: UIImage) { + logo = newLogo } } - diff --git a/Palace/MyBooks/MyBooks/MyBooksView.swift b/Palace/MyBooks/MyBooks/MyBooksView.swift index f1be2d073..76d2e9cc0 100644 --- a/Palace/MyBooks/MyBooks/MyBooksView.swift +++ b/Palace/MyBooks/MyBooks/MyBooksView.swift @@ -1,6 +1,8 @@ -import SwiftUI import Combine import PalaceUIKit +import SwiftUI + +// MARK: - MyBooksView struct MyBooksView: View { @EnvironmentObject private var coordinator: NavigationCoordinator @@ -12,75 +14,85 @@ struct MyBooksView: View { // Centralized sample preview manager overlay var body: some View { - ZStack { - if model.isLoading { - BookListSkeletonView(rows: 10) - } else { - mainContent - } + ZStack { + if model.isLoading { + BookListSkeletonView(rows: 10) + } else { + mainContent } - .background(Color(TPPConfiguration.backgroundColor())) - .overlay(alignment: .bottom) { SamplePreviewBarView() } - .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItem(placement: .principal) { - LibraryNavTitleView(onTap: { - if let urlString = AccountsManager.shared.currentAccount?.homePageUrl, let url = URL(string: urlString) { - UIApplication.shared.open(url, options: [:], completionHandler: nil) - } - }) - .id(logoObserver.token.uuidString + currentAccountUUID) - } - ToolbarItem(placement: .navigationBarLeading) { leadingBarButton } - ToolbarItem(placement: .navigationBarTrailing) { - if model.showSearchSheet { - Button(action: { withAnimation { model.showSearchSheet = false; model.searchQuery = "" } }) { - Text(Strings.Generic.cancel) - } - } else { - trailingBarButton + } + .background(Color(TPPConfiguration.backgroundColor())) + .overlay(alignment: .bottom) { SamplePreviewBarView() } + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .principal) { + LibraryNavTitleView(onTap: { + if let urlString = AccountsManager.shared.currentAccount?.homePageUrl, let url = URL(string: urlString) { + UIApplication.shared.open(url, options: [:], completionHandler: nil) } - } + }) + .id(logoObserver.token.uuidString + currentAccountUUID) } - .onAppear { - model.showSearchSheet = false - let account = AccountsManager.shared.currentAccount - account?.logoDelegate = logoObserver - account?.loadLogo() - currentAccountUUID = account?.uuid ?? "" + ToolbarItem(placement: .navigationBarLeading) { leadingBarButton } + ToolbarItem(placement: .navigationBarTrailing) { + if model.showSearchSheet { + Button(action: { withAnimation { model.showSearchSheet = false; model.searchQuery = "" } }) { + Text(Strings.Generic.cancel) + } + } else { + trailingBarButton + } } - .onReceive(NotificationCenter.default.publisher(for: .TPPCurrentAccountDidChange)) { _ in - let account = AccountsManager.shared.currentAccount - account?.logoDelegate = logoObserver - account?.loadLogo() - currentAccountUUID = account?.uuid ?? "" + } + .onAppear { + model.showSearchSheet = false + let account = AccountsManager.shared.currentAccount + account?.logoDelegate = logoObserver + account?.loadLogo() + currentAccountUUID = account?.uuid ?? "" + } + .onReceive(NotificationCenter.default.publisher(for: .TPPCurrentAccountDidChange)) { _ in + let account = AccountsManager.shared.currentAccount + account?.logoDelegate = logoObserver + account?.loadLogo() + currentAccountUUID = account?.uuid ?? "" + } + .sheet(isPresented: $model.showLibraryAccountView) { + UIViewControllerWrapper( + TPPAccountList { account in + model.authenticateAndLoad(account: account) + model.showLibraryAccountView = false + }, + updater: { _ in } + ) + } + .actionSheet(isPresented: $showSortSheet) { sortActionSheet } + .onReceive(NotificationCenter.default.publisher(for: Notification.Name("ToggleSampleNotification")) + .receive(on: RunLoop.main) + ) { note in + guard let info = note.userInfo as? [String: Any], + let identifier = info["bookIdentifier"] as? String + else { + return } - .sheet(isPresented: $model.showLibraryAccountView) { - UIViewControllerWrapper( - TPPAccountList { account in - model.authenticateAndLoad(account: account) - model.showLibraryAccountView = false - }, - updater: { _ in } - ) + let action = (info["action"] as? String) ?? "toggle" + if action == "close" { + SamplePreviewManager.shared.close() + return } - .actionSheet(isPresented: $showSortSheet) { sortActionSheet } - .onReceive(NotificationCenter.default.publisher(for: Notification.Name("ToggleSampleNotification")).receive(on: RunLoop.main)) { note in - guard let info = note.userInfo as? [String: Any], let identifier = info["bookIdentifier"] as? String else { return } - let action = (info["action"] as? String) ?? "toggle" - if action == "close" { - SamplePreviewManager.shared.close() - return - } - if let book = TPPBookRegistry.shared.book(forIdentifier: identifier) ?? model.books.first(where: { $0.identifier == identifier }) { - SamplePreviewManager.shared.toggle(for: book) - } + if let book = TPPBookRegistry.shared.book(forIdentifier: identifier) ?? model.books + .first(where: { $0.identifier == identifier }) + { + SamplePreviewManager.shared.toggle(for: book) } + } } private var mainContent: some View { VStack(alignment: .leading, spacing: 0) { - if model.showSearchSheet { searchBar } + if model.showSearchSheet { + searchBar + } FacetToolbarView( title: nil, showFilter: false, @@ -113,7 +125,7 @@ struct MyBooksView: View { .refreshable { model.reloadData() } .scrollDismissesKeyboard(.interactively) .simultaneousGesture(DragGesture().onChanged { _ in - if model.showSearchSheet { + if model.showSearchSheet { model.searchQuery = "" } }) @@ -189,11 +201,11 @@ struct MyBooksView: View { private func existingLibraryButtons() -> [ActionSheet.Button] { TPPSettings.shared.settingsAccountsList.map { account in - .default(Text(account.name)) { - model.loadAccount(account) - model.showLibraryAccountView = false - model.selectNewLibrary = false - } + .default(Text(account.name)) { + model.loadAccount(account) + model.showLibraryAccountView = false + model.selectNewLibrary = false + } } } @@ -210,17 +222,17 @@ struct MyBooksView: View { } private func setupTabBarForiPad() { -#if os(iOS) + #if os(iOS) if UIDevice.current.userInterfaceIdiom == .pad { UITabBar.appearance().isHidden = false } -#endif + #endif } } extension View { func searchBarStyle() -> some View { - self.padding(8) + padding(8) .textFieldStyle(.automatic) .background(Color.gray.opacity(0.2)) .cornerRadius(10) @@ -228,6 +240,6 @@ extension View { } func centered() -> some View { - self.horizontallyCentered().verticallyCentered() + horizontallyCentered().verticallyCentered() } } diff --git a/Palace/MyBooks/MyBooks/MyBooksViewModel.swift b/Palace/MyBooks/MyBooks/MyBooksViewModel.swift index c104527dc..a2f814606 100644 --- a/Palace/MyBooks/MyBooks/MyBooksViewModel.swift +++ b/Palace/MyBooks/MyBooks/MyBooksViewModel.swift @@ -6,18 +6,23 @@ // Copyright © 2022 The Palace Project. All rights reserved. // -import Foundation import Combine +import Foundation + +// MARK: - Group enum Group: Int { case groupSortBy } +// MARK: - MyBooksViewModel + @MainActor @objc class MyBooksViewModel: NSObject, ObservableObject { typealias DisplayStrings = Strings.MyBooksView // MARK: - Public Properties + @Published private(set) var books: [TPPBook] = [] @Published var isLoading = false @Published var alert: AlertModel? @@ -31,15 +36,17 @@ enum Group: Int { var isPad: Bool { UIDevice.current.isIpad } // MARK: - Private Properties + var activeFacetSort: Facet let facetViewModel: FacetViewModel private var observers = Set() private var bookRegistry: TPPBookRegistry { TPPBookRegistry.shared } // MARK: - Initialization + override init() { - self.activeFacetSort = .author - self.facetViewModel = FacetViewModel( + activeFacetSort = .author + facetViewModel = FacetViewModel( groupName: DisplayStrings.sortBy, facets: [.title, .author] ) @@ -47,7 +54,7 @@ enum Group: Int { registerPublishers() registerNotifications() - + loadData() } @@ -56,26 +63,31 @@ enum Group: Int { } // MARK: - Public Methods + func loadData() { - guard !isLoading else { return } + guard !isLoading else { + return + } isLoading = true let registryBooks = bookRegistry.myBooks let isConnected = Reachability.shared.isConnectedToNetwork() let newBooks = isConnected - ? registryBooks - : registryBooks.filter { !$0.isExpired } + ? registryBooks + : registryBooks.filter { !$0.isExpired } // Update published properties - self.books = newBooks - self.showInstructionsLabel = newBooks.isEmpty || bookRegistry.state == .unloaded - self.sortData() - self.isLoading = false + books = newBooks + showInstructionsLabel = newBooks.isEmpty || bookRegistry.state == .unloaded + sortData() + isLoading = false } func reloadData() { - guard !isLoading else { return } + guard !isLoading else { + return + } if TPPUserAccount.sharedAccount().needsAuth, !TPPUserAccount.sharedAccount().hasCredentials() { TPPAccountSignInViewController.requestCredentials(completion: nil) @@ -91,21 +103,23 @@ enum Group: Int { if query.isEmpty { loadData() } else { - let currentBooks = self.books + let currentBooks = books let filteredBooks = await Task.detached(priority: .userInitiated) { currentBooks.filter { $0.title.localizedCaseInsensitiveContains(query) || - ($0.authors?.localizedCaseInsensitiveContains(query) ?? false) + ($0.authors?.localizedCaseInsensitiveContains(query) ?? false) } }.value - self.books = filteredBooks + books = filteredBooks } } @objc func authenticateAndLoad(account: Account) { account.loadAuthenticationDocument { [weak self] success in - guard let self = self, success else { return } + guard let self = self, success else { + return + } DispatchQueue.main.async { if !TPPSettings.shared.settingsAccountIdsList.contains(account.uuid) { @@ -128,13 +142,14 @@ enum Group: Int { } // MARK: - Private Methods + private func sortData() { books.sort { first, second in switch activeFacetSort { case .author: - return "\(first.authors ?? "") \(first.title)" < "\(second.authors ?? "") \(second.title)" + "\(first.authors ?? "") \(first.title)" < "\(second.authors ?? "") \(second.title)" case .title: - return "\(first.title) \(first.authors ?? "")" < "\(second.title) \(second.authors ?? "")" + "\(first.title) \(first.authors ?? "")" < "\(second.title) \(second.authors ?? "")" } } } @@ -146,8 +161,14 @@ enum Group: Int { } // MARK: - Notification Handling + private func registerNotifications() { - NotificationCenter.default.addObserver(self, selector: #selector(handleBookRegistryStateChange(_:)), name: .TPPBookRegistryStateDidChange, object: nil) + NotificationCenter.default.addObserver( + self, + selector: #selector(handleBookRegistryStateChange(_:)), + name: .TPPBookRegistryStateDidChange, + object: nil + ) // Debounce high-frequency updates from registry changes and sync end let registryChange = NotificationCenter.default.publisher(for: .TPPBookRegistryDidChange) @@ -186,12 +207,14 @@ enum Group: Int { } DispatchQueue.main.async { [weak self] in - guard let self else { return } + guard let self else { + return + } if newState == .unregistered { // Remove locally so it doesn't flash back in until next sync - self.books.removeAll { $0.identifier == identifier } + books.removeAll { $0.identifier == identifier } } else { - self.loadData() + loadData() } } } @@ -199,9 +222,11 @@ enum Group: Int { private func registerPublishers() { facetViewModel.$activeSort .sink { [weak self] sort in - guard let self = self else { return } - self.activeFacetSort = sort - self.sortData() + guard let self = self else { + return + } + activeFacetSort = sort + sortData() } .store(in: &observers) } diff --git a/Palace/MyBooks/MyBooksDownloadCenter.swift b/Palace/MyBooks/MyBooksDownloadCenter.swift index 06d3c232f..59ea8c3f4 100644 --- a/Palace/MyBooks/MyBooksDownloadCenter.swift +++ b/Palace/MyBooks/MyBooksDownloadCenter.swift @@ -6,38 +6,40 @@ // Copyright © 2023 The Palace Project. All rights reserved. // +import Combine import Foundation -import UIKit import PalaceAudiobookToolkit -import Combine +import UIKit #if FEATURE_OVERDRIVE import OverdriveProcessor #endif +// MARK: - MyBooksDownloadCenter + @objc class MyBooksDownloadCenter: NSObject, URLSessionDelegate { typealias DisplayStrings = Strings.MyDownloadCenter - + @objc static let shared = MyBooksDownloadCenter() - + private var userAccount: TPPUserAccount private var reauthenticator: Reauthenticator private var bookRegistry: TPPBookRegistryProvider - + private var bookIdentifierOfBookToRemove: String? private var broadcastScheduled = false private var session: URLSession! - - private var bookIdentifierToDownloadInfo: [String: MyBooksDownloadInfo ] = [:] + + private var bookIdentifierToDownloadInfo: [String: MyBooksDownloadInfo] = [:] private var bookIdentifierToDownloadProgress: [String: Progress] = [:] private var bookIdentifierToDownloadTask: [String: URLSessionDownloadTask] = [:] private var taskIdentifierToBook: [Int: TPPBook] = [:] private var taskIdentifierToRedirectAttempts: [Int: Int] = [:] private let downloadQueue = DispatchQueue(label: "com.palace.downloadQueue", qos: .background) let downloadProgressPublisher = PassthroughSubject<(String, Double), Never>() - private var maxConcurrentDownloads: Int = 5 + private var maxConcurrentDownloads: Int = 5 private var pendingStartQueue: [TPPBook] = [] - + init( userAccount: TPPUserAccount = TPPUserAccount.sharedAccount(), reauthenticator: Reauthenticator = TPPReauthenticator(), @@ -46,18 +48,17 @@ import OverdriveProcessor self.userAccount = userAccount self.bookRegistry = bookRegistry self.reauthenticator = reauthenticator - + super.init() - -#if FEATURE_DRM_CONNECTOR - if !(AdobeCertificate.defaultCertificate?.hasExpired ?? true) - { + + #if FEATURE_DRM_CONNECTOR + if !(AdobeCertificate.defaultCertificate?.hasExpired ?? true) { NYPLADEPT.sharedInstance().delegate = self } -#else + #else NSLog("Cannot import ADEPT") -#endif - + #endif + let backgroundIdentifier = (Bundle.main.bundleIdentifier ?? "") + ".downloadCenterBackgroundIdentifier" let configuration = URLSessionConfiguration.background(withIdentifier: backgroundIdentifier) configuration.isDiscretionary = false @@ -65,143 +66,177 @@ import OverdriveProcessor if #available(iOS 13.0, *) { configuration.allowsConstrainedNetworkAccess = true } - self.session = URLSession(configuration: configuration, delegate: self, delegateQueue: .main) - + session = URLSession(configuration: configuration, delegate: self, delegateQueue: .main) + // Setup intelligent download management setupNetworkMonitoring() } - - func startBorrow(for book: TPPBook, attemptDownload shouldAttemptDownload: Bool, borrowCompletion: (() -> Void)? = nil) { + + func startBorrow( + for book: TPPBook, + attemptDownload shouldAttemptDownload: Bool, + borrowCompletion: (() -> Void)? = nil + ) { bookRegistry.setProcessing(true, for: book.identifier) - - TPPOPDSFeed.withURL((book.defaultAcquisition)?.hrefURL, shouldResetCache: true, useTokenIfAvailable: true) { [weak self] feed, error in - self?.bookRegistry.setProcessing(false, for: book.identifier) - - if let feed = feed, - let borrowedEntry = feed.entries.first as? TPPOPDSEntry, - let borrowedBook = TPPBook(entry: borrowedEntry) { - - let location = self?.bookRegistry.location(forIdentifier: borrowedBook.identifier) - - // Determine correct registry state based on availability - var newState: TPPBookState = .downloadNeeded - borrowedBook.defaultAcquisition?.availability.matchUnavailable( - { _ in newState = .holding }, - limited: { _ in newState = .downloadNeeded }, - unlimited: { _ in newState = .downloadNeeded }, - reserved: { _ in newState = .holding }, - ready: { _ in newState = .downloadNeeded } - ) - self?.bookRegistry.addBook( - borrowedBook, - location: location, - state: newState, - fulfillmentId: nil, - readiumBookmarks: nil, - genericBookmarks: nil - ) + TPPOPDSFeed + .withURL( + (book.defaultAcquisition)?.hrefURL, + shouldResetCache: true, + useTokenIfAvailable: true + ) { [weak self] feed, error in + self?.bookRegistry.setProcessing(false, for: book.identifier) - // Emit explicit state update so SwiftUI lists refresh immediately - self?.bookRegistry.setState(newState, for: borrowedBook.identifier) + if let feed = feed, + let borrowedEntry = feed.entries.first as? TPPOPDSEntry, + let borrowedBook = TPPBook(entry: borrowedEntry) + { + let location = self?.bookRegistry.location(forIdentifier: borrowedBook.identifier) - if shouldAttemptDownload && newState == .downloadNeeded { - self?.startDownloadIfAvailable(book: borrowedBook) + // Determine correct registry state based on availability + var newState: TPPBookState = .downloadNeeded + borrowedBook.defaultAcquisition?.availability.matchUnavailable { + _ in newState = .holding + } limited: { + _ in newState = .downloadNeeded + } + unlimited: { + _ in newState = .downloadNeeded + } reserved: { + _ in newState = .holding + } ready: { + _ in newState = .downloadNeeded + } + + self?.bookRegistry.addBook( + borrowedBook, + location: location, + state: newState, + fulfillmentId: nil, + readiumBookmarks: nil, + genericBookmarks: nil + ) + + // Emit explicit state update so SwiftUI lists refresh immediately + self?.bookRegistry.setState(newState, for: borrowedBook.identifier) + + if shouldAttemptDownload && newState == .downloadNeeded { + self?.startDownloadIfAvailable(book: borrowedBook) + } + + } else { + self?.process(error: error as? [String: Any], for: book) } - } else { - self?.process(error: error as? [String: Any], for: book) - } - - DispatchQueue.main.async { - borrowCompletion?() + DispatchQueue.main.async { + borrowCompletion?() + } } - } } - + private func startDownloadIfAvailable(book: TPPBook) { let downloadAction = { [weak self] in self?.startDownload(for: book) } - + book.defaultAcquisition?.availability.matchUnavailable( nil, limited: { _ in downloadAction() }, unlimited: { _ in downloadAction() }, reserved: nil, - ready: { _ in downloadAction() }) + ready: { _ in downloadAction() } + ) } - + private var hasAttemptedAuthentication = false - + private func process(error: [String: Any]?, for book: TPPBook) { guard let errorType = error?["type"] as? String else { showGenericBorrowFailedAlert(for: book) return } - + let alertTitle = DisplayStrings.borrowFailed - + switch errorType { case TPPProblemDocument.TypeLoanAlreadyExists: let alertMessage = DisplayStrings.loanAlreadyExistsAlertMessage let alert = TPPAlertUtils.alert(title: alertTitle, message: alertMessage) DispatchQueue.main.async { - TPPAlertUtils.presentFromViewControllerOrNil(alertController: alert, viewController: nil, animated: true, completion: nil) + TPPAlertUtils.presentFromViewControllerOrNil( + alertController: alert, + viewController: nil, + animated: true, + completion: nil + ) } - + case TPPProblemDocument.TypeInvalidCredentials: guard !hasAttemptedAuthentication else { showAlert(for: book, with: error, alertTitle: alertTitle) return } - + hasAttemptedAuthentication = true NSLog("Invalid credentials problem when borrowing a book, present sign in VC") - + reauthenticator.authenticateIfNeeded(userAccount, usingExistingCredentials: false) { [weak self] in guard let self = self else { NSLog("❌ Self is nil after authentication, skipping startDownload") return } - + DispatchQueue.main.async { self.startDownload(for: book) } } - + default: showAlert(for: book, with: error, alertTitle: alertTitle) } } - + private func showAlert(for book: TPPBook, with error: [String: Any]?, alertTitle: String) { let alertMessage = String(format: DisplayStrings.borrowFailedMessage, book.title) let alert = TPPAlertUtils.alert(title: alertTitle, message: alertMessage) - + if let error = error { - TPPAlertUtils.setProblemDocument(controller: alert, document: TPPProblemDocument.fromDictionary(error), append: false) + TPPAlertUtils.setProblemDocument( + controller: alert, + document: TPPProblemDocument.fromDictionary(error), + append: false + ) } - + DispatchQueue.main.async { - TPPAlertUtils.presentFromViewControllerOrNil(alertController: alert, viewController: nil, animated: true, completion: nil) + TPPAlertUtils.presentFromViewControllerOrNil( + alertController: alert, + viewController: nil, + animated: true, + completion: nil + ) } } - + private func showGenericBorrowFailedAlert(for book: TPPBook) { let formattedMessage = String(format: DisplayStrings.borrowFailedMessage, book.title) let alert = TPPAlertUtils.alert(title: DisplayStrings.borrowFailed, message: formattedMessage) DispatchQueue.main.async { - TPPAlertUtils.presentFromViewControllerOrNil(alertController: alert, viewController: nil, animated: true, completion: nil) + TPPAlertUtils.presentFromViewControllerOrNil( + alertController: alert, + viewController: nil, + animated: true, + completion: nil + ) } } - + @objc func startDownload(for book: TPPBook, withRequest initedRequest: URLRequest? = nil) { var state = bookRegistry.state(for: book.identifier) let location = bookRegistry.location(forIdentifier: book.identifier) // If this account requires auth and there are no stored credentials, prompt for sign-in now. let loginRequired = (userAccount.authDefinition?.needsAuth ?? false) && !userAccount.hasCredentials() - + switch state { case .unregistered: state = processUnregisteredState( @@ -217,7 +252,7 @@ import OverdriveProcessor NSLog("Ignoring nonsensical download request.") return } - + if activeDownloadCount() >= maxConcurrentDownloads { enqueuePending(book) return @@ -229,32 +264,50 @@ import OverdriveProcessor processDownloadWithCredentials(for: book, withState: state, andRequest: initedRequest) } } - - private func processUnregisteredState(for book: TPPBook, location: TPPBookLocation?, loginRequired: Bool?) -> TPPBookState { - if (book.defaultAcquisitionIfBorrow == nil && (book.defaultAcquisitionIfOpenAccess != nil || !(loginRequired ?? false))) { - bookRegistry.addBook(book, location: location, state: .downloadNeeded, fulfillmentId: nil, readiumBookmarks: nil, genericBookmarks: nil) + + private func processUnregisteredState( + for book: TPPBook, + location: TPPBookLocation?, + loginRequired: Bool? + ) -> TPPBookState { + if book + .defaultAcquisitionIfBorrow == nil && (book.defaultAcquisitionIfOpenAccess != nil || !(loginRequired ?? false)) + { + bookRegistry.addBook( + book, + location: location, + state: .downloadNeeded, + fulfillmentId: nil, + readiumBookmarks: nil, + genericBookmarks: nil + ) return .downloadNeeded } return .unregistered } - + private func requestCredentialsAndStartDownload(for book: TPPBook) { -#if FEATURE_DRM_CONNECTOR + #if FEATURE_DRM_CONNECTOR if AdobeCertificate.defaultCertificate?.hasExpired ?? false { // ADEPT crashes the app with expired certificate. - TPPAlertUtils.presentFromViewControllerOrNil(alertController: TPPAlertUtils.expiredAdobeDRMAlert(), viewController: nil, animated: true, completion: nil) + TPPAlertUtils.presentFromViewControllerOrNil( + alertController: TPPAlertUtils.expiredAdobeDRMAlert(), + viewController: nil, + animated: true, + completion: nil + ) } else { TPPAccountSignInViewController.requestCredentials { [weak self] in self?.startDownload(for: book) } } -#else + #else TPPAccountSignInViewController.requestCredentials { [weak self] in self?.startDownload(for: book) } -#endif + #endif } - + private func processDownloadWithCredentials( for book: TPPBook, withState state: TPPBookState, @@ -263,33 +316,49 @@ import OverdriveProcessor if state == .unregistered || state == .holding { startBorrow(for: book, attemptDownload: true, borrowCompletion: nil) } else { -#if FEATURE_OVERDRIVE + #if FEATURE_OVERDRIVE if book.distributor == OverdriveDistributorKey && book.defaultBookContentType == .audiobook { processOverdriveDownload(for: book, withState: state) return } -#endif + #endif processRegularDownload(for: book, withState: state, andRequest: initedRequest) } } - -#if FEATURE_OVERDRIVE + + #if FEATURE_OVERDRIVE private func processOverdriveDownload(for book: TPPBook, withState state: TPPBookState) { - guard let url = book.defaultAcquisition?.hrefURL else { return } - + guard let url = book.defaultAcquisition?.hrefURL else { + return + } + let completion: ([AnyHashable: Any]?, Error?) -> Void = { [weak self] responseHeaders, error in - self?.handleOverdriveResponse(for: book, url: url, withState: state, responseHeaders: responseHeaders, error: error) + self?.handleOverdriveResponse( + for: book, + url: url, + withState: state, + responseHeaders: responseHeaders, + error: error + ) } - + if let token = userAccount.authToken { - OverdriveAPIExecutor.shared.fulfillBook(urlString: url.absoluteString, authType: .token(token), completion: completion) + OverdriveAPIExecutor.shared.fulfillBook( + urlString: url.absoluteString, + authType: .token(token), + completion: completion + ) } else if let username = userAccount.username, let pin = userAccount.PIN { - OverdriveAPIExecutor.shared.fulfillBook(urlString: url.absoluteString, authType: .basic(username: username, pin: pin), completion: completion) + OverdriveAPIExecutor.shared.fulfillBook( + urlString: url.absoluteString, + authType: .basic(username: username, pin: pin), + completion: completion + ) } } -#endif - -#if FEATURE_OVERDRIVE + #endif + + #if FEATURE_OVERDRIVE private func handleOverdriveResponse( for book: TPPBook, url: URL?, @@ -303,29 +372,33 @@ import OverdriveProcessor let acquisitionURLKey = "acquisitionURL" let bookKey = "book" let bookRegistryStateKey = "bookRegistryState" - + if let error = error { let summary = "Overdrive audiobook fulfillment error" - + TPPErrorLogger.logError(error, summary: summary, metadata: [ responseHeadersKey: responseHeaders ?? nA, acquisitionURLKey: url?.absoluteString ?? nA, bookKey: book.loggableDictionary, bookRegistryStateKey: TPPBookStateHelper.stringValue(from: state) ]) - self.failDownloadWithAlert(for: book) + failDownloadWithAlert(for: book) return } - + let normalizedHeaders = responseHeaders?.mapKeys { String(describing: $0).lowercased() } let scopeKey = "x-overdrive-scope" let patronAuthorizationKey = "x-overdrive-patron-authorization" let locationKey = "location" - + guard let scope = normalizedHeaders?[scopeKey] as? String, let patronAuthorization = normalizedHeaders?[patronAuthorizationKey] as? String, let requestURLString = normalizedHeaders?[locationKey] as? String, - let request = OverdriveAPIExecutor.shared.getManifestRequest(urlString: requestURLString, token: patronAuthorization, scope: scope) + let request = OverdriveAPIExecutor.shared.getManifestRequest( + urlString: requestURLString, + token: patronAuthorization, + scope: scope + ) else { TPPErrorLogger.logError(withCode: .overdriveFulfillResponseParseFail, summary: summaryWrongHeaders, metadata: [ responseHeadersKey: responseHeaders ?? nA, @@ -333,15 +406,19 @@ import OverdriveProcessor bookKey: book.loggableDictionary, bookRegistryStateKey: TPPBookStateHelper.stringValue(from: state) ]) - self.failDownloadWithAlert(for: book) + failDownloadWithAlert(for: book) return } - - self.addDownloadTask(with: request, book: book) + + addDownloadTask(with: request, book: book) } -#endif - - private func processRegularDownload(for book: TPPBook, withState state: TPPBookState, andRequest initedRequest: URLRequest?) { + #endif + + private func processRegularDownload( + for book: TPPBook, + withState state: TPPBookState, + andRequest initedRequest: URLRequest? + ) { let request: URLRequest if let initedRequest = initedRequest { request = initedRequest @@ -351,12 +428,12 @@ import OverdriveProcessor logInvalidURLRequest(for: book, withState: state, url: nil, request: nil) return } - + guard let _ = request.url else { logInvalidURLRequest(for: book, withState: state, url: book.defaultAcquisition?.hrefURL, request: request) return } - + // Ensure we are within disk budget before proceeding MemoryPressureMonitor.shared.reclaimDiskSpaceIfNeeded(minimumFreeMegabytes: 512) enforceContentDiskBudgetIfNeeded(adding: 0) @@ -368,35 +445,41 @@ import OverdriveProcessor addDownloadTask(with: request, book: book) } } - - private func logInvalidURLRequest(for book: TPPBook, withState state: TPPBookState, url: URL?, request: URLRequest?) { + + private func logInvalidURLRequest(for book: TPPBook, withState _: TPPBookState, url _: URL?, request: URLRequest?) { bookRegistry.setState(.SAMLStarted, for: book.identifier) - guard let someCookies = self.userAccount.cookies, var mutableRequest = request else { return } - + guard let someCookies = userAccount.cookies, var mutableRequest = request else { + return + } + DispatchQueue.main.async { [weak self] in - guard let self = self else { return } - + guard let self = self else { + return + } + mutableRequest.cachePolicy = .reloadIgnoringCacheData - + let loginCancelHandler: () -> Void = { [weak self] in self?.bookRegistry.setState(.downloadNeeded, for: book.identifier) self?.cancelDownload(for: book.identifier) } - - let bookFoundHandler: (_ request: URLRequest?, _ cookies: [HTTPCookie]) -> Void = { [weak self] request, cookies in + + let bookFoundHandler: (_ request: URLRequest?, _ cookies: [HTTPCookie]) -> Void = { [weak self] _, cookies in self?.userAccount.setCookies(cookies) self?.startDownload(for: book, withRequest: mutableRequest) } - - let problemFoundHandler: (_ problemDocument: TPPProblemDocument?) -> Void = { [weak self] problemDocument in - guard let self = self else { return } - self.bookRegistry.setState(.downloadNeeded, for: book.identifier) - - self.reauthenticator.authenticateIfNeeded(self.userAccount, usingExistingCredentials: false) { [weak self] in + + let problemFoundHandler: (_ problemDocument: TPPProblemDocument?) -> Void = { [weak self] _ in + guard let self = self else { + return + } + bookRegistry.setState(.downloadNeeded, for: book.identifier) + + reauthenticator.authenticateIfNeeded(userAccount, usingExistingCredentials: false) { [weak self] in self?.startDownload(for: book) } } - + let model = TPPCookiesWebViewModel( cookies: someCookies, request: mutableRequest, @@ -410,56 +493,64 @@ import OverdriveProcessor cookiesVC.loadViewIfNeeded() } } - + private func handleSAMLStartedState(for book: TPPBook, withRequest request: URLRequest, cookies: [HTTPCookie]) { bookRegistry.setState(.SAMLStarted, for: book.identifier) - + DispatchQueue.main.async { [weak self] in var mutableRequest = request mutableRequest.cachePolicy = .reloadIgnoringCacheData - - let model = TPPCookiesWebViewModel(cookies: cookies, request: mutableRequest, loginCompletionHandler: nil, loginCancelHandler: { - self?.handleLoginCancellation(for: book) - }, bookFoundHandler: { request, cookies in - self?.handleBookFound(for: book, withRequest: request, cookies: cookies) - }, problemFoundHandler: { problemDocument in - self?.handleProblem(for: book, problemDocument: problemDocument) - }, autoPresentIfNeeded: true) - + + let model = TPPCookiesWebViewModel( + cookies: cookies, + request: mutableRequest, + loginCompletionHandler: nil, + loginCancelHandler: { + self?.handleLoginCancellation(for: book) + }, + bookFoundHandler: { request, cookies in + self?.handleBookFound(for: book, withRequest: request, cookies: cookies) + }, + problemFoundHandler: { problemDocument in + self?.handleProblem(for: book, problemDocument: problemDocument) + }, + autoPresentIfNeeded: true + ) + let cookiesVC = TPPCookiesWebViewController(model: model) cookiesVC.loadViewIfNeeded() } } - + private func handleLoginCancellation(for book: TPPBook) { bookRegistry.setState(.downloadNeeded, for: book.identifier) cancelDownload(for: book.identifier) } - + private func handleBookFound(for book: TPPBook, withRequest request: URLRequest?, cookies: [HTTPCookie]) { userAccount.setCookies(cookies) if let request = request { startDownload(for: book, withRequest: request) } } - - private func handleProblem(for book: TPPBook, problemDocument: TPPProblemDocument?) { + + private func handleProblem(for book: TPPBook, problemDocument _: TPPProblemDocument?) { bookRegistry.setState(.downloadNeeded, for: book.identifier) reauthenticator.authenticateIfNeeded(userAccount, usingExistingCredentials: false) { [weak self] in self?.startDownload(for: book) } } - + private func clearAndSetCookies() { - let cookieStorage = self.session.configuration.httpCookieStorage + let cookieStorage = session.configuration.httpCookieStorage cookieStorage?.cookies?.forEach { cookie in cookieStorage?.deleteCookie(cookie) } - self.userAccount.cookies?.forEach { cookie in + userAccount.cookies?.forEach { cookie in cookieStorage?.setCookie(cookie) } } - + @objc func cancelDownload(for identifier: String) { guard let info = downloadInfo(forBookIdentifier: identifier) else { let state = bookRegistry.state(for: identifier) @@ -467,19 +558,19 @@ import OverdriveProcessor NSLog("Ignoring nonsensical cancellation request.") return } - + bookRegistry.setState(.downloadNeeded, for: identifier) return } - -#if FEATURE_DRM_CONNECTOR + + #if FEATURE_DRM_CONNECTOR if info.rightsManagement == .adobe { NYPLADEPT.sharedInstance().cancelFulfillment(withTag: identifier) return } -#endif - - info.downloadTask.cancel { [weak self] resumeData in + #endif + + info.downloadTask.cancel { [weak self] _ in self?.bookRegistry.setState(.downloadNeeded, for: identifier) self?.broadcastUpdate() } @@ -490,11 +581,12 @@ extension MyBooksDownloadCenter { func deleteLocalContent(for identifier: String, account: String? = nil) { let current_account: String? = account ?? AccountsManager.shared.currentAccountId guard let book = bookRegistry.book(forIdentifier: identifier), - let bookURL = fileUrl(for: identifier, account: current_account) else { + let bookURL = fileUrl(for: identifier, account: current_account) + else { Log.warn(#file, "Could not find book to delete local content \(identifier)") return } - + do { switch book.defaultBookContentType { case .epub, .pdf: @@ -503,38 +595,44 @@ extension MyBooksDownloadCenter { } else { Log.info(#file, "Content file already missing (nothing to delete): \(bookURL.lastPathComponent)") } -#if LCP + #if LCP if book.defaultBookContentType == .pdf { try LCPPDFs.deletePdfContent(url: bookURL) } -#endif + #endif case .audiobook: try deleteLocalAudiobookContent(forAudiobook: book, at: bookURL) case .unsupported: Log.warn(#file, "Unsupported content type for deletion.") } } catch { - Log.error(#file, "Failed to remove local content for book with identifier \(identifier): \(error.localizedDescription)") + Log.error( + #file, + "Failed to remove local content for book with identifier \(identifier): \(error.localizedDescription)" + ) } } - + private func deleteLocalAudiobookContent(forAudiobook book: TPPBook, at bookURL: URL) throws { -#if LCP + #if LCP let isLcpAudiobook = LCPAudiobooks.canOpenBook(book) -#else + #else let isLcpAudiobook = false -#endif - + #endif + // LCP Audiobooks are a single binary file, without an easily loaded manifest. // So they skip this logic that deleted the local audio files, used by other // audiobook types. // TODO: Update LCP so we don't have to special case it here. - if (!isLcpAudiobook) { + if !isLcpAudiobook { let manifestData = try Data(contentsOf: bookURL) let manifest = try Manifest.customDecoder().decode(Manifest.self, from: manifestData) - AudiobookFactory.audiobookClass(for: manifest).deleteLocalContent(manifest: manifest, bookIdentifier: book.identifier) + AudiobookFactory.audiobookClass(for: manifest).deleteLocalContent( + manifest: manifest, + bookIdentifier: book.identifier + ) } - + if FileManager.default.fileExists(atPath: bookURL.path) { try FileManager.default.removeItem(at: bookURL) } else { @@ -542,31 +640,34 @@ extension MyBooksDownloadCenter { } Log.info(#file, "Successfully deleted audiobook manifest & content \(book.identifier)") } - + @objc func returnBook(withIdentifier identifier: String, completion: (() -> Void)? = nil) { guard let book = bookRegistry.book(forIdentifier: identifier) else { completion?() return } - + let state = bookRegistry.state(for: identifier) let downloaded = (state == .downloadSuccessful) || (state == .used) - + // Process Adobe Return -#if FEATURE_DRM_CONNECTOR + #if FEATURE_DRM_CONNECTOR if let fulfillmentId = bookRegistry.fulfillmentId(forIdentifier: identifier), - userAccount.authDefinition?.needsAuth == true { + userAccount.authDefinition?.needsAuth == true + { NSLog("Return attempt for book. userID: %@", userAccount.userID ?? "") - NYPLADEPT.sharedInstance().returnLoan(fulfillmentId, - userID: userAccount.userID, - deviceID: userAccount.deviceID) { success, error in + NYPLADEPT.sharedInstance().returnLoan( + fulfillmentId, + userID: userAccount.userID, + deviceID: userAccount.deviceID + ) { success, _ in if !success { NSLog("Failed to return loan via NYPLAdept.") } } } -#endif - + #endif + if book.revokeURL == nil { if downloaded { deleteLocalContent(for: identifier) @@ -579,10 +680,10 @@ extension MyBooksDownloadCenter { DispatchQueue.main.async { completion?() } } else { bookRegistry.setProcessing(true, for: book.identifier) - + TPPOPDSFeed.withURL(book.revokeURL, shouldResetCache: false, useTokenIfAvailable: true) { feed, error in self.bookRegistry.setProcessing(false, for: book.identifier) - + if let feed = feed, feed.entries.count == 1, let entry = feed.entries[0] as? TPPOPDSEntry { if downloaded { self.deleteLocalContent(for: identifier) @@ -611,19 +712,28 @@ extension MyBooksDownloadCenter { DispatchQueue.main.async { completion?() } } else if errorType == TPPProblemDocument.TypeInvalidCredentials { NSLog("Invalid credentials problem when returning a book, present sign in VC") - self.reauthenticator.authenticateIfNeeded(self.userAccount, usingExistingCredentials: false) { [weak self] in - self?.returnBook(withIdentifier: identifier, completion: completion) - } + self.reauthenticator + .authenticateIfNeeded(self.userAccount, usingExistingCredentials: false) { [weak self] in + self?.returnBook(withIdentifier: identifier, completion: completion) + } } } else { DispatchQueue.main.async { - let formattedMessage = String(format: NSLocalizedString("The return of %@ could not be completed.", comment: ""), book.title) + let formattedMessage = String( + format: NSLocalizedString("The return of %@ could not be completed.", comment: ""), + book.title + ) let alert = TPPAlertUtils.alert(title: "ReturnFailed", message: formattedMessage) if let error = error as? Decoder, let document = try? TPPProblemDocument(from: error) { TPPAlertUtils.setProblemDocument(controller: alert, document: document, append: true) } DispatchQueue.main.async { - TPPAlertUtils.presentFromViewControllerOrNil(alertController: alert, viewController: nil, animated: true, completion: nil) + TPPAlertUtils.presentFromViewControllerOrNil( + alertController: alert, + viewController: nil, + animated: true, + completion: nil + ) } } DispatchQueue.main.async { completion?() } @@ -634,18 +744,20 @@ extension MyBooksDownloadCenter { } } +// MARK: URLSessionDownloadDelegate + extension MyBooksDownloadCenter: URLSessionDownloadDelegate { func urlSession( - _ session: URLSession, - downloadTask: URLSessionDownloadTask, - didResumeAtOffset fileOffset: Int64, - expectedTotalBytes: Int64 + _: URLSession, + downloadTask _: URLSessionDownloadTask, + didResumeAtOffset _: Int64, + expectedTotalBytes _: Int64 ) { NSLog("Ignoring unexpected resumption.") } - + func urlSession( - _ session: URLSession, + _: URLSession, downloadTask: URLSessionDownloadTask, didWriteData bytesWritten: Int64, totalBytesWritten: Int64, @@ -655,33 +767,33 @@ extension MyBooksDownloadCenter: URLSessionDownloadDelegate { guard let book = taskIdentifierToBook[key] else { return } - + if bytesWritten == totalBytesWritten { guard let mimeType = downloadTask.response?.mimeType else { Log.error(#file, "No MIME type in response for book: \(book.identifier)") return } - + Log.info(#file, "Download MIME type detected for \(book.identifier): \(mimeType)") - + switch mimeType { case ContentTypeAdobeAdept: bookIdentifierToDownloadInfo[book.identifier] = - downloadInfo(forBookIdentifier: book.identifier)?.withRightsManagement(.adobe) + downloadInfo(forBookIdentifier: book.identifier)?.withRightsManagement(.adobe) case ContentTypeReadiumLCP: bookIdentifierToDownloadInfo[book.identifier] = - downloadInfo(forBookIdentifier: book.identifier)?.withRightsManagement(.lcp) + downloadInfo(forBookIdentifier: book.identifier)?.withRightsManagement(.lcp) case ContentTypeEpubZip: bookIdentifierToDownloadInfo[book.identifier] = - downloadInfo(forBookIdentifier: book.identifier)?.withRightsManagement(.none) + downloadInfo(forBookIdentifier: book.identifier)?.withRightsManagement(.none) case ContentTypeBearerToken: bookIdentifierToDownloadInfo[book.identifier] = - downloadInfo(forBookIdentifier: book.identifier)?.withRightsManagement(.simplifiedBearerTokenJSON) -#if FEATURE_OVERDRIVE + downloadInfo(forBookIdentifier: book.identifier)?.withRightsManagement(.simplifiedBearerTokenJSON) + #if FEATURE_OVERDRIVE case "application/json": bookIdentifierToDownloadInfo[book.identifier] = - downloadInfo(forBookIdentifier: book.identifier)?.withRightsManagement(.overdriveManifestJSON) -#endif + downloadInfo(forBookIdentifier: book.identifier)?.withRightsManagement(.overdriveManifestJSON) + #endif default: if TPPOPDSAcquisitionPath.supportedTypes().contains(mimeType) { NSLog("Presuming no DRM for unrecognized MIME type \"\(mimeType)\".") @@ -695,9 +807,11 @@ extension MyBooksDownloadCenter: URLSessionDownloadDelegate { } } } - + let rightsManagement = downloadInfo(forBookIdentifier: book.identifier)?.rightsManagement ?? .none - if rightsManagement != .adobe && rightsManagement != .simplifiedBearerTokenJSON && rightsManagement != .overdriveManifestJSON { + if rightsManagement != .adobe && rightsManagement != .simplifiedBearerTokenJSON && rightsManagement != + .overdriveManifestJSON + { if totalBytesExpectedToWrite > 0 { let progress = Double(totalBytesWritten) / Double(totalBytesExpectedToWrite) bookIdentifierToDownloadInfo[book.identifier] = @@ -708,68 +822,98 @@ extension MyBooksDownloadCenter: URLSessionDownloadDelegate { } } } - + func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didFinishDownloadingTo location: URL) { guard let book = taskIdentifierToBook[downloadTask.taskIdentifier] else { return } - + taskIdentifierToRedirectAttempts.removeValue(forKey: downloadTask.taskIdentifier) - + var failureRequiringAlert = false var failureError = downloadTask.error var problemDoc: TPPProblemDocument? let rights = downloadInfo(forBookIdentifier: book.identifier)?.rightsManagement ?? .unknown - + Log.info(#file, "Download completed for \(book.identifier) with rights: \(rights)") - + if let response = downloadTask.response, response.isProblemDocument() { do { let problemDocData = try Data(contentsOf: location) problemDoc = try TPPProblemDocument.fromData(problemDocData) - } catch let error { - TPPErrorLogger.logProblemDocumentParseError(error as NSError, problemDocumentData: nil, url: location, summary: "Error parsing problem doc downloading \(String(describing: book.distributor)) book", metadata: ["book": book.loggableShortString]) + } catch { + TPPErrorLogger.logProblemDocumentParseError( + error as NSError, + problemDocumentData: nil, + url: location, + summary: "Error parsing problem doc downloading \(String(describing: book.distributor)) book", + metadata: ["book": book.loggableShortString] + ) } - + try? FileManager.default.removeItem(at: location) failureRequiringAlert = true } - + if !book.canCompleteDownload(withContentType: downloadTask.response?.mimeType ?? "") { try? FileManager.default.removeItem(at: location) failureRequiringAlert = true } - + if failureRequiringAlert { - logBookDownloadFailure(book, reason: "Download Error", downloadTask: downloadTask, metadata: ["problemDocument": problemDoc?.dictionaryValue ?? "N/A"]) + logBookDownloadFailure( + book, + reason: "Download Error", + downloadTask: downloadTask, + metadata: ["problemDocument": problemDoc?.dictionaryValue ?? "N/A"] + ) } else { TPPProblemDocumentCacheManager.sharedInstance().clearCachedDoc(book.identifier) - + switch rights { case .unknown: - Log.error(#file, "❌ Rights management is unknown for book: \(book.identifier) - LCP fulfillment will NOT be called") + Log.error( + #file, + "❌ Rights management is unknown for book: \(book.identifier) - LCP fulfillment will NOT be called" + ) logBookDownloadFailure(book, reason: "Unknown rights management", downloadTask: downloadTask, metadata: nil) failureRequiringAlert = true case .adobe: -#if FEATURE_DRM_CONNECTOR + #if FEATURE_DRM_CONNECTOR if let acsmData = try? Data(contentsOf: location), let acsmString = String(data: acsmData, encoding: .utf8), - acsmString.contains(">application/pdf") { + acsmString.contains(">application/pdf") + { let msg = NSLocalizedString("\(book.title) is an Adobe PDF, which is not supported.", comment: "") - failureError = NSError(domain: TPPErrorLogger.clientDomain, code: TPPErrorCode.ignore.rawValue, userInfo: [NSLocalizedDescriptionKey: msg]) - logBookDownloadFailure(book, reason: "Received PDF for AdobeDRM rights", downloadTask: downloadTask, metadata: nil) + failureError = NSError( + domain: TPPErrorLogger.clientDomain, + code: TPPErrorCode.ignore.rawValue, + userInfo: [NSLocalizedDescriptionKey: msg] + ) + logBookDownloadFailure( + book, + reason: "Received PDF for AdobeDRM rights", + downloadTask: downloadTask, + metadata: nil + ) failureRequiringAlert = true } else if let acsmData = try? Data(contentsOf: location) { NSLog("Download finished. Fulfilling with userID: \(userAccount.userID ?? "")") - NYPLADEPT.sharedInstance().fulfill(withACSMData: acsmData, tag: book.identifier, userID: userAccount.userID, deviceID: userAccount.deviceID) + NYPLADEPT.sharedInstance().fulfill( + withACSMData: acsmData, + tag: book.identifier, + userID: userAccount.userID, + deviceID: userAccount.deviceID + ) } -#endif + #endif case .lcp: fulfillLCPLicense(fileUrl: location, forBook: book, downloadTask: downloadTask) case .simplifiedBearerTokenJSON: if let data = try? Data(contentsOf: location) { if let dictionary = TPPJSONObjectFromData(data) as? [String: Any], - let simplifiedBearerToken = MyBooksSimplifiedBearerToken.simplifiedBearerToken(with: dictionary) { + let simplifiedBearerToken = MyBooksSimplifiedBearerToken.simplifiedBearerToken(with: dictionary) + { var mutableRequest = URLRequest(url: simplifiedBearerToken.location, applyingCustomUserAgent: true) mutableRequest.setValue("Bearer \(simplifiedBearerToken.accessToken)", forHTTPHeaderField: "Authorization") let task = session.downloadTask(with: mutableRequest as URLRequest) @@ -783,11 +927,21 @@ extension MyBooksDownloadCenter: URLSessionDownloadDelegate { taskIdentifierToBook[task.taskIdentifier] = book task.resume() } else { - logBookDownloadFailure(book, reason: "No Simplified Bearer Token in deserialized data", downloadTask: downloadTask, metadata: nil) + logBookDownloadFailure( + book, + reason: "No Simplified Bearer Token in deserialized data", + downloadTask: downloadTask, + metadata: nil + ) failDownloadWithAlert(for: book) } } else { - logBookDownloadFailure(book, reason: "No Simplified Bearer Token data available on disk", downloadTask: downloadTask, metadata: nil) + logBookDownloadFailure( + book, + reason: "No Simplified Bearer Token data available on disk", + downloadTask: downloadTask, + metadata: nil + ) failDownloadWithAlert(for: book) } case .overdriveManifestJSON: @@ -796,12 +950,14 @@ extension MyBooksDownloadCenter: URLSessionDownloadDelegate { failureRequiringAlert = !moveFile(at: location, toDestinationForBook: book, forDownloadTask: downloadTask) } } - + if failureRequiringAlert { DispatchQueue.main.async { let hasCredentials = self.userAccount.hasCredentials() let loginRequired = self.userAccount.authDefinition?.needsAuth ?? false - if downloadTask.response?.indicatesAuthenticationNeedsRefresh(with: problemDoc) == true || (!hasCredentials && loginRequired) { + if downloadTask.response? + .indicatesAuthenticationNeedsRefresh(with: problemDoc) == true || (!hasCredentials && loginRequired) + { self.reauthenticator.authenticateIfNeeded( self.userAccount, usingExistingCredentials: hasCredentials, @@ -812,17 +968,17 @@ extension MyBooksDownloadCenter: URLSessionDownloadDelegate { } bookRegistry.setState(.downloadFailed, for: book.identifier) } - + broadcastUpdate() // Attempt to start next pending downloads schedulePendingStartsIfPossible() } - + @objc func downloadInfo(forBookIdentifier bookIdentifier: String) -> MyBooksDownloadInfo? { guard let downloadInfo = bookIdentifierToDownloadInfo[bookIdentifier] else { return nil } - + if downloadInfo is MyBooksDownloadInfo { return downloadInfo } else { @@ -831,13 +987,13 @@ extension MyBooksDownloadCenter: URLSessionDownloadDelegate { return nil } } - + func broadcastUpdate() { DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { self.broadcastUpdateNow() } } - + private func broadcastUpdateNow() { NotificationCenter.default.post( name: Notification.Name.TPPMyBooksDownloadCenterDidChange, @@ -852,76 +1008,78 @@ extension MyBooksDownloadCenter: URLSessionDownloadDelegate { extension MyBooksDownloadCenter: URLSessionTaskDelegate { func urlSession( - _ session: URLSession, - task: URLSessionTask, + _: URLSession, + task _: URLSessionTask, didReceive challenge: URLAuthenticationChallenge, - completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) { - let handler = TPPBasicAuth(credentialsProvider: userAccount) - handler.handleChallenge(challenge, completion: completionHandler) - } - + completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void + ) { + let handler = TPPBasicAuth(credentialsProvider: userAccount) + handler.handleChallenge(challenge, completion: completionHandler) + } + func urlSession( - _ session: URLSession, + _: URLSession, task: URLSessionTask, - willPerformHTTPRedirection response: HTTPURLResponse, + willPerformHTTPRedirection _: HTTPURLResponse, newRequest request: URLRequest, completionHandler: @escaping (URLRequest?) -> Void ) { let maxRedirectAttempts: UInt = 10 - - var redirectAttempts = self.taskIdentifierToRedirectAttempts[task.taskIdentifier] ?? 0 - + + var redirectAttempts = taskIdentifierToRedirectAttempts[task.taskIdentifier] ?? 0 + if redirectAttempts >= maxRedirectAttempts { completionHandler(nil) return } - + redirectAttempts += 1 - self.taskIdentifierToRedirectAttempts[task.taskIdentifier] = redirectAttempts - + taskIdentifierToRedirectAttempts[task.taskIdentifier] = redirectAttempts + let authorizationKey = "Authorization" - + // Since any "Authorization" header will be dropped on redirection for security // reasons, we need to again manually set the header for the redirected request // if we originally manually set the header to a bearer token. There's no way // to use URLSession's standard challenge handling approach for bearer tokens. if let originalAuthorization = task.originalRequest?.allHTTPHeaderFields?[authorizationKey], - originalAuthorization.hasPrefix("Bearer") { + originalAuthorization.hasPrefix("Bearer") + { // Do not pass on the bearer token to other domains. if task.originalRequest?.url?.host != request.url?.host { completionHandler(request) return } - + // Prevent redirection from HTTPS to a non-HTTPS URL. if task.originalRequest?.url?.scheme == "https" && request.url?.scheme != "https" { completionHandler(nil) return } - + var mutableAllHTTPHeaderFields = request.allHTTPHeaderFields ?? [:] mutableAllHTTPHeaderFields[authorizationKey] = originalAuthorization - + var mutableRequest = URLRequest(url: request.url!, applyingCustomUserAgent: true) mutableRequest.allHTTPHeaderFields = mutableAllHTTPHeaderFields - + completionHandler(mutableRequest) } else { completionHandler(request) } } - + func urlSession( - _ session: URLSession, + _: URLSession, task: URLSessionTask, didCompleteWithError error: Error? ) { - guard let book = self.taskIdentifierToBook[task.taskIdentifier] else { + guard let book = taskIdentifierToBook[task.taskIdentifier] else { return } - - self.taskIdentifierToRedirectAttempts.removeValue(forKey: task.taskIdentifier) - + + taskIdentifierToRedirectAttempts.removeValue(forKey: task.taskIdentifier) + if let error = error as NSError?, error.code != NSURLErrorCancelled { logBookDownloadFailure(book, reason: "networking error", downloadTask: task, metadata: ["urlSessionError": error]) failDownloadWithAlert(for: book) @@ -930,25 +1088,29 @@ extension MyBooksDownloadCenter: URLSessionTaskDelegate { // Attempt to start next pending downloads schedulePendingStartsIfPossible() } - - private func addDownloadTask(with request: URLRequest, book: TPPBook) { + + private func addDownloadTask(with request: URLRequest, book: TPPBook) { var modifiableRequest = request - let task = self.session.downloadTask(with: modifiableRequest.applyCustomUserAgent()) - - self.bookIdentifierToDownloadInfo[book.identifier] = - MyBooksDownloadInfo(downloadProgress: 0.0, - downloadTask: task, - rightsManagement: .unknown) - - self.taskIdentifierToBook[task.taskIdentifier] = book + let task = session.downloadTask(with: modifiableRequest.applyCustomUserAgent()) + + bookIdentifierToDownloadInfo[book.identifier] = + MyBooksDownloadInfo( + downloadProgress: 0.0, + downloadTask: task, + rightsManagement: .unknown + ) + + taskIdentifierToBook[task.taskIdentifier] = book task.resume() - bookRegistry.addBook(book, - location: bookRegistry.location(forIdentifier: book.identifier), - state: .downloading, - fulfillmentId: nil, - readiumBookmarks: nil, - genericBookmarks: nil) - + bookRegistry.addBook( + book, + location: bookRegistry.location(forIdentifier: book.identifier), + state: .downloading, + fulfillmentId: nil, + readiumBookmarks: nil, + genericBookmarks: nil + ) + DispatchQueue.main.async { NotificationCenter.default.post(name: .TPPMyBooksDownloadCenterDidChange, object: self) } @@ -959,9 +1121,10 @@ extension MyBooksDownloadCenter: URLSessionTaskDelegate { } // MARK: - Download Throttling and Disk Budget + extension MyBooksDownloadCenter { private func activeDownloadCount() -> Int { - return bookIdentifierToDownloadInfo.values.compactMap { info -> URLSessionTask? in + bookIdentifierToDownloadInfo.values.compactMap { info -> URLSessionTask? in return info.downloadTask }.filter { $0.state == .running }.count } @@ -974,7 +1137,9 @@ extension MyBooksDownloadCenter { private func schedulePendingStartsIfPossible() { let capacity = maxConcurrentDownloads - activeDownloadCount() - guard capacity > 0 else { return } + guard capacity > 0 else { + return + } let toStart = Array(pendingStartQueue.prefix(capacity)) if !toStart.isEmpty { pendingStartQueue.removeFirst(min(capacity, pendingStartQueue.count)) @@ -987,19 +1152,25 @@ extension MyBooksDownloadCenter { @objc func enforceContentDiskBudgetIfNeeded(adding bytesToAdd: Int64) { let smallDevice = UIScreen.main.nativeBounds.height <= 1334 // iPhone 6/7/8 size and below // Relax budgets: give small devices ~1.2GB, others ~2.5GB before eviction - let budgetBytes: Int64 = smallDevice ? (1_200 * 1024 * 1024) : (2_500 * 1024 * 1024) + let budgetBytes: Int64 = smallDevice ? (1200 * 1024 * 1024) : (2500 * 1024 * 1024) let currentUsage = contentDirectoryUsageBytes() var neededFree = (currentUsage + bytesToAdd) - budgetBytes - guard neededFree > 0 else { return } + guard neededFree > 0 else { + return + } let files = listContentFilesSortedByLRU() let fm = FileManager.default for url in files { - if neededFree <= 0 { break } + if neededFree <= 0 { + break + } // Never delete LCP license/content files during eviction let ext = url.pathExtension.lowercased() - if ext == "lcpl" || ext == "lcpa" { continue } + if ext == "lcpl" || ext == "lcpa" { + continue + } if let size = (try? url.resourceValues(forKeys: [.fileSizeKey]))?.fileSize { try? fm.removeItem(at: url) neededFree -= Int64(size) @@ -1008,20 +1179,38 @@ extension MyBooksDownloadCenter { } private func contentDirectoryUsageBytes() -> Int64 { - guard let dir = contentDirectoryURL(AccountsManager.shared.currentAccountId) else { return 0 } + guard let dir = contentDirectoryURL(AccountsManager.shared.currentAccountId) else { + return 0 + } let fm = FileManager.default - guard let contents = try? fm.contentsOfDirectory(at: dir, includingPropertiesForKeys: [.fileSizeKey], options: [.skipsHiddenFiles]) else { return 0 } + guard let contents = try? fm.contentsOfDirectory( + at: dir, + includingPropertiesForKeys: [.fileSizeKey], + options: [.skipsHiddenFiles] + ) else { + return 0 + } var total: Int64 = 0 for url in contents { - if let size = (try? url.resourceValues(forKeys: [.fileSizeKey]))?.fileSize { total += Int64(size) } + if let size = (try? url.resourceValues(forKeys: [.fileSizeKey]))?.fileSize { + total += Int64(size) + } } return total } private func listContentFilesSortedByLRU() -> [URL] { - guard let dir = contentDirectoryURL(AccountsManager.shared.currentAccountId) else { return [] } + guard let dir = contentDirectoryURL(AccountsManager.shared.currentAccountId) else { + return [] + } let fm = FileManager.default - guard let contents = try? fm.contentsOfDirectory(at: dir, includingPropertiesForKeys: [.contentAccessDateKey, .contentModificationDateKey], options: [.skipsHiddenFiles]) else { return [] } + guard let contents = try? fm.contentsOfDirectory( + at: dir, + includingPropertiesForKeys: [.contentAccessDateKey, .contentModificationDateKey], + options: [.skipsHiddenFiles] + ) else { + return [] + } return contents.sorted { a, b in let ra = try? a.resourceValues(forKeys: [.contentAccessDateKey, .contentModificationDateKey]) let rb = try? b.resourceValues(forKeys: [.contentAccessDateKey, .contentModificationDateKey]) @@ -1036,19 +1225,24 @@ extension MyBooksDownloadCenter { // Public helpers for memory monitor @objc func limitActiveDownloads(max: Int) { maxConcurrentDownloads = max - let running = bookIdentifierToDownloadInfo.values.compactMap { $0.downloadTask }.filter { $0.state == .running } - let suspended = bookIdentifierToDownloadInfo.values.compactMap { $0.downloadTask }.filter { $0.state == .suspended } + let running = bookIdentifierToDownloadInfo.values.compactMap(\.downloadTask).filter { $0.state == .running } + let suspended = bookIdentifierToDownloadInfo.values.compactMap(\.downloadTask).filter { $0.state == .suspended } if running.count > maxConcurrentDownloads { let nonAudiobookTasks = running.filter { task in - guard let book = taskIdentifierToBook[task.taskIdentifier] else { return true } + guard let book = taskIdentifierToBook[task.taskIdentifier] else { + return true + } return book.defaultBookContentType != .audiobook } - - let tasksToSuspend = nonAudiobookTasks.dropFirst(Swift.max(0, maxConcurrentDownloads - (running.count - nonAudiobookTasks.count))) - for task in tasksToSuspend { + + let tasksToSuspend = nonAudiobookTasks.dropFirst(Swift.max( + 0, + maxConcurrentDownloads - (running.count - nonAudiobookTasks.count) + )) + for task in tasksToSuspend { Log.info(#file, "Suspending non-audiobook download to respect limits") - task.suspend() + task.suspend() } } else if running.count < maxConcurrentDownloads { let toResume = min(maxConcurrentDownloads - running.count, suspended.count) @@ -1062,18 +1256,19 @@ extension MyBooksDownloadCenter { @objc func pauseAllDownloads() { bookIdentifierToDownloadInfo.values.forEach { info in if let book = taskIdentifierToBook[info.downloadTask.taskIdentifier], - book.defaultBookContentType == .audiobook { + book.defaultBookContentType == .audiobook + { Log.info(#file, "Preserving audiobook download/streaming for: \(book.title)") return } info.downloadTask.suspend() } } - + @objc func resumeIntelligentDownloads() { limitActiveDownloads(max: maxConcurrentDownloads) } - + func setupNetworkMonitoring() { NotificationCenter.default.addObserver( self, @@ -1083,17 +1278,22 @@ extension MyBooksDownloadCenter { ) Log.info(#file, "Network monitoring setup for download optimization") } - + @objc private func networkConditionsChanged() { let currentLimit = maxConcurrentDownloads limitActiveDownloads(max: currentLimit) } - - private func logBookDownloadFailure(_ book: TPPBook, reason: String, downloadTask: URLSessionTask, metadata: [String: Any]?) { + + private func logBookDownloadFailure( + _ book: TPPBook, + reason: String, + downloadTask: URLSessionTask, + metadata: [String: Any]? + ) { let rights = downloadInfo(forBookIdentifier: book.identifier)?.rightsManagementString ?? "" let bookType = TPPBookContentTypeConverter.stringValue(of: book.defaultBookContentType) let context = "\(String(describing: book.distributor)) \(bookType) download fail: \(reason)" - + var dict: [String: Any] = metadata ?? [:] dict["book"] = book.loggableDictionary dict["rightsManagement"] = rights @@ -1101,15 +1301,15 @@ extension MyBooksDownloadCenter { dict["taskCurrentRequest"] = downloadTask.currentRequest?.loggableString dict["response"] = downloadTask.response ?? "N/A" dict["downloadError"] = downloadTask.error ?? "N/A" - + TPPErrorLogger.logError(withCode: .downloadFail, summary: context, metadata: dict) } - + func fulfillLCPLicense(fileUrl: URL, forBook book: TPPBook, downloadTask: URLSessionDownloadTask) { -#if LCP + #if LCP let lcpService = LCPLibraryService() let licenseUrl = fileUrl.deletingPathExtension().appendingPathExtension(lcpService.licenseExtension) - + do { _ = try FileManager.default.replaceItemAt(licenseUrl, withItemAt: fileUrl) } catch { @@ -1121,15 +1321,20 @@ extension MyBooksDownloadCenter { failDownloadWithAlert(for: book, withMessage: error.localizedDescription) return } - + let lcpProgress: (Double) -> Void = { [weak self] progressValue in - guard let self = self else { return } - self.bookIdentifierToDownloadInfo[book.identifier] = self.downloadInfo(forBookIdentifier: book.identifier)?.withDownloadProgress(progressValue) - self.broadcastUpdate() + guard let self = self else { + return + } + bookIdentifierToDownloadInfo[book.identifier] = downloadInfo(forBookIdentifier: book.identifier)? + .withDownloadProgress(progressValue) + broadcastUpdate() } - + let lcpCompletion: (URL?, Error?) -> Void = { [weak self] localUrl, error in - guard let self = self else { return } + guard let self = self else { + return + } if let error = error { let summary = "\(String(describing: book.distributor)) LCP license fulfillment error" TPPErrorLogger.logError(error, summary: summary, metadata: [ @@ -1138,24 +1343,24 @@ extension MyBooksDownloadCenter { "localURL": localUrl?.absoluteString ?? "N/A" ]) let errorMessage = "Fulfilment Error: \(error.localizedDescription)" - self.failDownloadWithAlert(for: book, withMessage: errorMessage) + failDownloadWithAlert(for: book, withMessage: errorMessage) return } guard let localUrl = localUrl, let license = TPPLCPLicense(url: licenseUrl) else { let errorMessage = "Error with LCP license fulfillment: \(localUrl?.absoluteString ?? "")" - self.failDownloadWithAlert(for: book, withMessage: errorMessage) + failDownloadWithAlert(for: book, withMessage: errorMessage) return } - self.bookRegistry.setFulfillmentId(license.identifier, for: book.identifier) - - if !self.replaceBook(book, withFileAtURL: localUrl, forDownloadTask: downloadTask) { + bookRegistry.setFulfillmentId(license.identifier, for: book.identifier) + + if !replaceBook(book, withFileAtURL: localUrl, forDownloadTask: downloadTask) { if book.defaultBookContentType == .audiobook { Log.warn(#file, "Content storage failed for audiobook, but streaming still available") } else { let errorMessage = "Error replacing content file with file \(localUrl.absoluteString)" - self.failDownloadWithAlert(for: book, withMessage: errorMessage) + failDownloadWithAlert(for: book, withMessage: errorMessage) return } } else { @@ -1163,54 +1368,59 @@ extension MyBooksDownloadCenter { Log.info(#file, "Audiobook content stored successfully, offline playback now available") } } - + Task { if book.defaultBookContentType == .pdf, - let bookURL = self.fileUrl(for: book.identifier) { + let bookURL = self.fileUrl(for: book.identifier) + { self.bookRegistry.setState(.downloading, for: book.identifier) - let _ = try? await LCPPDFs(url: bookURL)?.extract(url: bookURL) + _ = try? await LCPPDFs(url: bookURL)?.extract(url: bookURL) self.bookRegistry.setState(.downloadSuccessful, for: book.identifier) } } } - + let fulfillmentDownloadTask = lcpService.fulfill(licenseUrl, progress: lcpProgress, completion: lcpCompletion) - + if book.defaultBookContentType == .audiobook { Log.info(#file, "LCP audiobook license fulfilled, ready for streaming: \(book.identifier)") - self.copyLicenseForStreaming(book: book, sourceLicenseUrl: licenseUrl) - self.bookRegistry.setState(.downloadSuccessful, for: book.identifier) - + copyLicenseForStreaming(book: book, sourceLicenseUrl: licenseUrl) + bookRegistry.setState(.downloadSuccessful, for: book.identifier) + DispatchQueue.main.async { self.broadcastUpdate() } } - + if let fulfillmentDownloadTask = fulfillmentDownloadTask { - self.bookIdentifierToDownloadInfo[book.identifier] = MyBooksDownloadInfo(downloadProgress: 0.0, downloadTask: fulfillmentDownloadTask, rightsManagement: .none) + bookIdentifierToDownloadInfo[book.identifier] = MyBooksDownloadInfo( + downloadProgress: 0.0, + downloadTask: fulfillmentDownloadTask, + rightsManagement: .none + ) } -#endif + #endif } - + /// Copies the LCP license file to the content directory for streaming support /// while preserving the existing fulfillment flow private func copyLicenseForStreaming(book: TPPBook, sourceLicenseUrl: URL) { -#if LCP + #if LCP Log.info(#file, "🎵 Starting license copy for streaming: \(book.identifier)") - - guard let finalContentURL = self.fileUrl(for: book.identifier) else { + + guard let finalContentURL = fileUrl(for: book.identifier) else { Log.error(#file, "🎵 ❌ Unable to determine final content URL for streaming license copy") return } - + let streamingLicenseUrl = finalContentURL.deletingPathExtension().appendingPathExtension("lcpl") Log.info(#file, "🎵 Copying license FROM: \(sourceLicenseUrl.path)") Log.info(#file, "🎵 Copying license TO: \(streamingLicenseUrl.path)") - + do { try? FileManager.default.removeItem(at: streamingLicenseUrl) try FileManager.default.copyItem(at: sourceLicenseUrl, to: streamingLicenseUrl) - + let fileExists = FileManager.default.fileExists(atPath: streamingLicenseUrl.path) } catch { TPPErrorLogger.logError(error, summary: "Failed to copy LCP license for streaming", metadata: [ @@ -1219,138 +1429,174 @@ extension MyBooksDownloadCenter { "targetLicenseUrl": streamingLicenseUrl.absoluteString ]) } -#endif + #endif } - + func failDownloadWithAlert(for book: TPPBook, withMessage message: String? = nil) { let location = bookRegistry.location(forIdentifier: book.identifier) - - bookRegistry.addBook(book, - location: location, - state: .downloadFailed, - fulfillmentId: nil, - readiumBookmarks: nil, - genericBookmarks: nil) - + + bookRegistry.addBook( + book, + location: location, + state: .downloadFailed, + fulfillmentId: nil, + readiumBookmarks: nil, + genericBookmarks: nil + ) + DispatchQueue.main.async { let errorMessage = message ?? "No error message" - let formattedMessage = String.localizedStringWithFormat(NSLocalizedString("The download for %@ could not be completed.", comment: ""), book.title) + let formattedMessage = String.localizedStringWithFormat( + NSLocalizedString("The download for %@ could not be completed.", comment: ""), + book.title + ) let finalMessage = "\(formattedMessage)\n\(errorMessage)" let alert = TPPAlertUtils.alert(title: "DownloadFailed", message: finalMessage) DispatchQueue.main.async { - TPPAlertUtils.presentFromViewControllerOrNil(alertController: alert, viewController: nil, animated: true, completion: nil) + TPPAlertUtils.presentFromViewControllerOrNil( + alertController: alert, + viewController: nil, + animated: true, + completion: nil + ) } } - + broadcastUpdate() } - + func alertForProblemDocument(_ problemDoc: TPPProblemDocument?, error: Error?, book: TPPBook) { let msg = String(format: NSLocalizedString("The download for %@ could not be completed.", comment: ""), book.title) let alert = TPPAlertUtils.alert(title: "DownloadFailed", message: msg) - + if let problemDoc = problemDoc { TPPProblemDocumentCacheManager.sharedInstance().cacheProblemDocument(problemDoc, key: book.identifier) TPPAlertUtils.setProblemDocument(controller: alert, document: problemDoc, append: true) - + if problemDoc.type == TPPProblemDocument.TypeNoActiveLoan { bookRegistry.removeBook(forIdentifier: book.identifier) } } else if let error = error { alert.message = String(format: "%@\n\nError: %@", msg, error.localizedDescription) } - + DispatchQueue.main.async { - TPPAlertUtils.presentFromViewControllerOrNil(alertController: alert, viewController: nil, animated: true, completion: nil) + TPPAlertUtils.presentFromViewControllerOrNil( + alertController: alert, + viewController: nil, + animated: true, + completion: nil + ) } } - - func moveFile(at sourceLocation: URL, toDestinationForBook book: TPPBook, forDownloadTask downloadTask: URLSessionDownloadTask) -> Bool { + + func moveFile( + at sourceLocation: URL, + toDestinationForBook book: TPPBook, + forDownloadTask downloadTask: URLSessionDownloadTask + ) -> Bool { var removeError: Error? var moveError: Error? - - guard let finalFileURL = fileUrl(for: book.identifier) else { return false } - + + guard let finalFileURL = fileUrl(for: book.identifier) else { + return false + } + do { try FileManager.default.removeItem(at: finalFileURL) } catch { removeError = error } - + var success = false - + do { try FileManager.default.moveItem(at: sourceLocation, to: finalFileURL) success = true } catch { moveError = error } - + if success { bookRegistry.setState(.downloadSuccessful, for: book.identifier) } else if let moveError = moveError { - logBookDownloadFailure(book, reason: "Couldn't move book to final disk location", downloadTask: downloadTask, metadata: [ - "moveError": moveError, - "removeError": removeError?.localizedDescription ?? "N/A", - "sourceLocation": sourceLocation.absoluteString, - "finalFileURL": finalFileURL.absoluteString - ]) + logBookDownloadFailure( + book, + reason: "Couldn't move book to final disk location", + downloadTask: downloadTask, + metadata: [ + "moveError": moveError, + "removeError": removeError?.localizedDescription ?? "N/A", + "sourceLocation": sourceLocation.absoluteString, + "finalFileURL": finalFileURL.absoluteString + ] + ) } - + return success } - - private func replaceBook(_ book: TPPBook, withFileAtURL sourceLocation: URL, forDownloadTask downloadTask: URLSessionDownloadTask) -> Bool { - guard let destURL = fileUrl(for: book.identifier) else { return false } + + private func replaceBook( + _ book: TPPBook, + withFileAtURL sourceLocation: URL, + forDownloadTask downloadTask: URLSessionDownloadTask + ) -> Bool { + guard let destURL = fileUrl(for: book.identifier) else { + return false + } do { - let _ = try FileManager.default.replaceItemAt(destURL, withItemAt: sourceLocation, options: .usingNewMetadataOnly) + _ = try FileManager.default.replaceItemAt(destURL, withItemAt: sourceLocation, options: .usingNewMetadataOnly) // Note: For LCP audiobooks, state is set in fulfillLCPLicense after license is ready // For non-LCP audiobooks and other content types, set state here after content is successfully stored -#if LCP + #if LCP let isLCPAudiobook = book.defaultBookContentType == .audiobook && LCPAudiobooks.canOpenBook(book) if !isLCPAudiobook { bookRegistry.setState(.downloadSuccessful, for: book.identifier) } -#else + #else bookRegistry.setState(.downloadSuccessful, for: book.identifier) -#endif + #endif return true } catch { - logBookDownloadFailure(book, - reason: "Couldn't replace downloaded book", - downloadTask: downloadTask, - metadata: [ - "replaceError": error, - "destinationFileURL": destURL as Any, - "sourceFileURL": sourceLocation as Any - ]) - } - + logBookDownloadFailure( + book, + reason: "Couldn't replace downloaded book", + downloadTask: downloadTask, + metadata: [ + "replaceError": error, + "destinationFileURL": destURL as Any, + "sourceFileURL": sourceLocation as Any + ] + ) + } + return false } - + @objc func fileUrl(for identifier: String) -> URL? { - return fileUrl(for: identifier, account: AccountsManager.shared.currentAccountId) + fileUrl(for: identifier, account: AccountsManager.shared.currentAccountId) } - + func fileUrl(for identifier: String, account: String?) -> URL? { guard let book = bookRegistry.book(forIdentifier: identifier) else { return nil } - + let pathExtension = pathExtension(for: book) - let contentDirectoryURL = self.contentDirectoryURL(account) + let contentDirectoryURL = contentDirectoryURL(account) let hashedIdentifier = identifier.sha256() - + return contentDirectoryURL?.appendingPathComponent(hashedIdentifier).appendingPathExtension(pathExtension) } - + func contentDirectoryURL(_ account: String?) -> URL? { - guard let directoryURL = TPPBookContentMetadataFilesHelper.directory(for: account ?? "")?.appendingPathComponent("content") else { + guard let directoryURL = TPPBookContentMetadataFilesHelper.directory(for: account ?? "")? + .appendingPathComponent("content") + else { NSLog("[contentDirectoryURL] nil directory.") return nil } - + var isDirectory: ObjCBool = false if !FileManager.default.fileExists(atPath: directoryURL.path, isDirectory: &isDirectory) { do { @@ -1360,23 +1606,22 @@ extension MyBooksDownloadCenter { return nil } } - + return directoryURL } - - + func pathExtension(for book: TPPBook?) -> String { -#if LCP + #if LCP if let book = book { if LCPAudiobooks.canOpenBook(book) { return "lcpa" } - + if LCPPDFs.canOpenBook(book) { return "zip" } } -#endif + #endif return "epub" } } @@ -1385,7 +1630,7 @@ extension MyBooksDownloadCenter: TPPBookDownloadsDeleting { func reset(_ libraryID: String!) { reset(account: libraryID) } - + func reset(account: String) { if AccountsManager.shared.currentAccountId == account { reset() @@ -1400,22 +1645,22 @@ extension MyBooksDownloadCenter: TPPBookDownloadsDeleting { } } } - + func reset() { guard let currentAccountId = AccountsManager.shared.currentAccountId else { return } - + deleteAudiobooks(forAccount: currentAccountId) - + for info in bookIdentifierToDownloadInfo.values { info.downloadTask.cancel(byProducingResumeData: { _ in }) } - + bookIdentifierToDownloadInfo.removeAll() taskIdentifierToBook.removeAll() bookIdentifierOfBookToRemove = nil - + do { if let url = contentDirectoryURL(currentAccountId) { try FileManager.default.removeItem(at: url) @@ -1423,10 +1668,10 @@ extension MyBooksDownloadCenter: TPPBookDownloadsDeleting { } catch { // Handle error, if needed } - + broadcastUpdate() } - + func deleteAudiobooks(forAccount account: String) { bookRegistry.with(account: account) { registry in let books = registry.allBooks @@ -1441,15 +1686,25 @@ extension MyBooksDownloadCenter: TPPBookDownloadsDeleting { // Purge cached audio fragments (e.g., streaming or decrypted chunks) from the Caches directory. // If `force` is false, purges only when there are no active audiobooks in the registry. func purgeAllAudiobookCaches(force: Bool = false) { - if !force && hasActiveAudiobooks() { return } + if !force && hasActiveAudiobooks() { + return + } let fm = FileManager.default - guard let cachesDir = fm.urls(for: .cachesDirectory, in: .userDomainMask).first else { return } + guard let cachesDir = fm.urls(for: .cachesDirectory, in: .userDomainMask).first else { + return + } let audioExtensions: Set = ["mp3", "m4a", "mp4", "aac", "oga", "wav"] - if let contents = try? fm.contentsOfDirectory(at: cachesDir, includingPropertiesForKeys: [.isDirectoryKey, .fileSizeKey], options: [.skipsHiddenFiles]) { + if let contents = try? fm.contentsOfDirectory( + at: cachesDir, + includingPropertiesForKeys: [.isDirectoryKey, .fileSizeKey], + options: [.skipsHiddenFiles] + ) { for url in contents { do { let rv = try url.resourceValues(forKeys: [.isDirectoryKey]) - if rv.isDirectory == true { continue } + if rv.isDirectory == true { + continue + } if audioExtensions.contains(url.pathExtension.lowercased()) { try? fm.removeItem(at: url) } @@ -1461,7 +1716,7 @@ extension MyBooksDownloadCenter: TPPBookDownloadsDeleting { } private func hasActiveAudiobooks() -> Bool { - let matchingStates: [TPPBookState] = [ .downloadNeeded, .downloading, .downloadSuccessful, .used ] + let matchingStates: [TPPBookState] = [.downloadNeeded, .downloading, .downloadSuccessful, .used] var hasActive = false let accountId = AccountsManager.shared.currentAccountId ?? "" bookRegistry.with(account: accountId) { registry in @@ -1470,103 +1725,130 @@ extension MyBooksDownloadCenter: TPPBookDownloadsDeleting { } return hasActive } - + @objc func downloadProgress(for bookIdentifier: String) -> Double { - Double(self.downloadInfo(forBookIdentifier: bookIdentifier)?.downloadProgress ?? 0.0) + Double(downloadInfo(forBookIdentifier: bookIdentifier)?.downloadProgress ?? 0.0) } } #if FEATURE_DRM_CONNECTOR extension MyBooksDownloadCenter: NYPLADEPTDelegate { - - func adept(_ adept: NYPLADEPT, didFinishDownload: Bool, to adeptToURL: URL?, fulfillmentID: String?, isReturnable: Bool, rightsData: Data, tag: String, error adeptError: Error?) { + func adept( + _: NYPLADEPT, + didFinishDownload: Bool, + to adeptToURL: URL?, + fulfillmentID: String?, + isReturnable: Bool, + rightsData: Data, + tag: String, + error adeptError: Error? + ) { guard let book = bookRegistry.book(forIdentifier: tag), - let rights = String(data: rightsData, encoding: .utf8) else { return } - + let rights = String(data: rightsData, encoding: .utf8) + else { + return + } + var didSucceedCopying = false - + if didFinishDownload { - guard let fileURL = fileUrl(for: book.identifier) else { return } + guard let fileURL = fileUrl(for: book.identifier) else { + return + } let fileManager = FileManager.default - + do { try fileManager.removeItem(at: fileURL) } catch { print("Remove item error: \(error)") } - + guard let destURL = fileUrl(for: book.identifier), let adeptToURL = adeptToURL else { - TPPErrorLogger.logError(withCode: .adobeDRMFulfillmentFail, summary: "Adobe DRM error: destination file URL unavailable", metadata: [ - "adeptError": adeptError ?? "N/A", - "fileURLToRemove": adeptToURL ?? "N/A", - "book": book.loggableDictionary, - "AdobeFulfilmmentID": fulfillmentID ?? "N/A", - "AdobeRights": rights, - "AdobeTag": tag - ]) - self.failDownloadWithAlert(for: book) + TPPErrorLogger.logError( + withCode: .adobeDRMFulfillmentFail, + summary: "Adobe DRM error: destination file URL unavailable", + metadata: [ + "adeptError": adeptError ?? "N/A", + "fileURLToRemove": adeptToURL ?? "N/A", + "book": book.loggableDictionary, + "AdobeFulfilmmentID": fulfillmentID ?? "N/A", + "AdobeRights": rights, + "AdobeTag": tag + ] + ) + failDownloadWithAlert(for: book) return } - + do { try fileManager.copyItem(at: adeptToURL, to: destURL) didSucceedCopying = true } catch { - TPPErrorLogger.logError(withCode: .adobeDRMFulfillmentFail, summary: "Adobe DRM error: failure copying file", metadata: [ + TPPErrorLogger.logError( + withCode: .adobeDRMFulfillmentFail, + summary: "Adobe DRM error: failure copying file", + metadata: [ + "adeptError": adeptError ?? "N/A", + "copyError": error, + "fromURL": adeptToURL, + "destURL": destURL, + "book": book.loggableDictionary, + "AdobeFulfilmmentID": fulfillmentID ?? "N/A", + "AdobeRights": rights, + "AdobeTag": tag + ] + ) + } + } else { + TPPErrorLogger.logError( + withCode: .adobeDRMFulfillmentFail, + summary: "Adobe DRM error: did not finish download", + metadata: [ "adeptError": adeptError ?? "N/A", - "copyError": error, - "fromURL": adeptToURL, - "destURL": destURL, + "adeptToURL": adeptToURL ?? "N/A", "book": book.loggableDictionary, "AdobeFulfilmmentID": fulfillmentID ?? "N/A", "AdobeRights": rights, "AdobeTag": tag - ]) - } - } else { - TPPErrorLogger.logError(withCode: .adobeDRMFulfillmentFail, summary: "Adobe DRM error: did not finish download", metadata: [ - "adeptError": adeptError ?? "N/A", - "adeptToURL": adeptToURL ?? "N/A", - "book": book.loggableDictionary, - "AdobeFulfilmmentID": fulfillmentID ?? "N/A", - "AdobeRights": rights, - "AdobeTag": tag - ]) + ] + ) } - + if !didFinishDownload || !didSucceedCopying { - self.failDownloadWithAlert(for: book) + failDownloadWithAlert(for: book) + return + } + + guard let rightsFilePath = fileUrl(for: book.identifier)?.path.appending("_rights.xml") else { return } - - guard let rightsFilePath = fileUrl(for: book.identifier)?.path.appending("_rights.xml") else { return } do { try rightsData.write(to: URL(fileURLWithPath: rightsFilePath)) } catch { print("Failed to store rights data.") } - + if isReturnable, let fulfillmentID = fulfillmentID { bookRegistry.setFulfillmentId(fulfillmentID, for: book.identifier) } - + bookRegistry.setState(.downloadSuccessful, for: book.identifier) - - self.broadcastUpdate() + + broadcastUpdate() } - - func adept(_ adept: NYPLADEPT, didUpdateProgress progress: Double, tag: String) { - self.bookIdentifierToDownloadInfo[tag] = self.downloadInfo(forBookIdentifier: tag)?.withDownloadProgress(progress) - self.broadcastUpdate() + + func adept(_: NYPLADEPT, didUpdateProgress progress: Double, tag: String) { + bookIdentifierToDownloadInfo[tag] = downloadInfo(forBookIdentifier: tag)?.withDownloadProgress(progress) + broadcastUpdate() } - - func adept(_ adept: NYPLADEPT, didCancelDownloadWithTag tag: String) { + + func adept(_: NYPLADEPT, didCancelDownloadWithTag tag: String) { bookRegistry.setState(.downloadNeeded, for: tag) - self.broadcastUpdate() + broadcastUpdate() } - + func didIgnoreFulfillmentWithNoAuthorizationPresent() { - self.reauthenticator.authenticateIfNeeded(userAccount, usingExistingCredentials: true, authenticationCompletion: nil) + reauthenticator.authenticateIfNeeded(userAccount, usingExistingCredentials: true, authenticationCompletion: nil) } } #endif diff --git a/Palace/MyBooks/MyBooksDownloadInfo.swift b/Palace/MyBooks/MyBooksDownloadInfo.swift index c14ae5d99..f28894d82 100644 --- a/Palace/MyBooks/MyBooksDownloadInfo.swift +++ b/Palace/MyBooks/MyBooksDownloadInfo.swift @@ -10,7 +10,6 @@ import Foundation import UIKit @objc class MyBooksDownloadInfo: NSObject { - @objc enum MyBooksDownloadRightsManagement: Int { case unknown case none @@ -19,41 +18,56 @@ import UIKit case overdriveManifestJSON case lcp } - + var downloadProgress: CGFloat var downloadTask: URLSessionDownloadTask @objc var rightsManagement: MyBooksDownloadRightsManagement var bearerToken: MyBooksSimplifiedBearerToken? - - init(downloadProgress: CGFloat, downloadTask: URLSessionDownloadTask, rightsManagement: MyBooksDownloadRightsManagement, bearerToken: MyBooksSimplifiedBearerToken? = nil) { + + init( + downloadProgress: CGFloat, + downloadTask: URLSessionDownloadTask, + rightsManagement: MyBooksDownloadRightsManagement, + bearerToken: MyBooksSimplifiedBearerToken? = nil + ) { self.downloadProgress = downloadProgress self.downloadTask = downloadTask self.rightsManagement = rightsManagement self.bearerToken = bearerToken } - + func withDownloadProgress(_ downloadProgress: CGFloat) -> MyBooksDownloadInfo { - return MyBooksDownloadInfo(downloadProgress: downloadProgress, downloadTask: self.downloadTask, rightsManagement: self.rightsManagement, bearerToken: self.bearerToken) + MyBooksDownloadInfo( + downloadProgress: downloadProgress, + downloadTask: downloadTask, + rightsManagement: rightsManagement, + bearerToken: bearerToken + ) } - + func withRightsManagement(_ rightsManagement: MyBooksDownloadRightsManagement) -> MyBooksDownloadInfo { - return MyBooksDownloadInfo(downloadProgress: self.downloadProgress, downloadTask: self.downloadTask, rightsManagement: rightsManagement, bearerToken: self.bearerToken) + MyBooksDownloadInfo( + downloadProgress: downloadProgress, + downloadTask: downloadTask, + rightsManagement: rightsManagement, + bearerToken: bearerToken + ) } - + var rightsManagementString: String { switch rightsManagement { case .unknown: - return "Unknown" + "Unknown" case .none: - return "None" + "None" case .adobe: - return "Adobe" + "Adobe" case .simplifiedBearerTokenJSON: - return "SimplifiedBearerTokenJSON" + "SimplifiedBearerTokenJSON" case .overdriveManifestJSON: - return "OverdriveManifestJSON" + "OverdriveManifestJSON" case .lcp: - return "TPPMyBooksDownloadRightsManagementLCP" + "TPPMyBooksDownloadRightsManagementLCP" } } } diff --git a/Palace/MyBooks/MyBooksSimplifiedBearerToken.swift b/Palace/MyBooks/MyBooksSimplifiedBearerToken.swift index 11321579d..5528af1b3 100644 --- a/Palace/MyBooks/MyBooksSimplifiedBearerToken.swift +++ b/Palace/MyBooks/MyBooksSimplifiedBearerToken.swift @@ -12,24 +12,25 @@ class MyBooksSimplifiedBearerToken { var accessToken: String var expiration: Date var location: URL - + init(accessToken: String, expiration: Date, location: URL) { self.accessToken = accessToken self.expiration = expiration self.location = location } - + static func simplifiedBearerToken(with dictionary: [String: Any]) -> MyBooksSimplifiedBearerToken? { guard let locationString = dictionary["location"] as? String, let location = URL(string: locationString), let accessToken = dictionary["access_token"] as? String, - let expirationNumber = dictionary["expires_in"] as? Int ?? dictionary["expiration"] as? Int else { + let expirationNumber = dictionary["expires_in"] as? Int ?? dictionary["expiration"] as? Int + else { return nil } - + let expirationSeconds = expirationNumber > 0 ? expirationNumber : Int(Date.distantFuture.timeIntervalSinceNow) let expiration = Date(timeIntervalSinceNow: TimeInterval(expirationSeconds)) - + return MyBooksSimplifiedBearerToken(accessToken: accessToken, expiration: expiration, location: location) } } diff --git a/Palace/MyBooks/TPPBook+DistributorChecks.swift b/Palace/MyBooks/TPPBook+DistributorChecks.swift index fe437ab51..0f7fa20d4 100644 --- a/Palace/MyBooks/TPPBook+DistributorChecks.swift +++ b/Palace/MyBooks/TPPBook+DistributorChecks.swift @@ -12,7 +12,6 @@ import OverdriveProcessor #endif extension TPPBook { - /// Determines if the download of this book should complete successfully /// given the received content type. /// @@ -44,7 +43,7 @@ extension TPPBook { } } #endif - + return false } } diff --git a/Palace/Network/BundledHTMLViewController.swift b/Palace/Network/BundledHTMLViewController.swift index 04e457a73..4888b0cb5 100644 --- a/Palace/Network/BundledHTMLViewController.swift +++ b/Palace/Network/BundledHTMLViewController.swift @@ -8,21 +8,21 @@ import WebKit let fileURL: URL let webView: WKWebView let webViewDelegate: WKNavigationDelegate - + required init(fileURL: URL, title: String) { self.fileURL = fileURL let config = WKWebViewConfiguration() config.dataDetectorTypes = WKDataDetectorTypes() config.allowsInlineMediaPlayback = true config.mediaTypesRequiringUserActionForPlayback = [] - self.webView = WKWebView(frame: .zero, configuration: config) - self.webViewDelegate = WebViewDelegate() + webView = WKWebView(frame: .zero, configuration: config) + webViewDelegate = WebViewDelegate() super.init(nibName: nil, bundle: nil) self.title = title } - + @available(*, unavailable) - required init?(coder aDecoder: NSCoder) { + required init?(coder _: NSCoder) { fatalError("init(coder:) has not been implemented") } @@ -30,27 +30,28 @@ import WebKit self.webView.navigationDelegate = nil self.webView.stopLoading() } - + override func viewDidLoad() { - self.webView.frame = self.view.bounds - self.webView.autoresizingMask = [.flexibleWidth, .flexibleHeight] - self.webView.backgroundColor = UIColor.white - self.webView.navigationDelegate = self.webViewDelegate - self.view.addSubview(self.webView) + webView.frame = view.bounds + webView.autoresizingMask = [.flexibleWidth, .flexibleHeight] + webView.backgroundColor = UIColor.white + webView.navigationDelegate = webViewDelegate + view.addSubview(webView) } - - override func viewWillAppear(_ animated: Bool) { - self.webView.load(URLRequest(url: self.fileURL, applyingCustomUserAgent: true)) + + override func viewWillAppear(_: Bool) { + webView.load(URLRequest(url: fileURL, applyingCustomUserAgent: true)) } - - fileprivate class WebViewDelegate: NSObject, WKNavigationDelegate - { - func webView(_ webView: WKWebView, - decidePolicyFor navigationAction: WKNavigationAction, - decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) - { + + fileprivate class WebViewDelegate: NSObject, WKNavigationDelegate { + func webView( + _: WKWebView, + decidePolicyFor navigationAction: WKNavigationAction, + decisionHandler: @escaping (WKNavigationActionPolicy) -> Void + ) { if navigationAction.navigationType == .linkActivated, - let url = navigationAction.request.url { + let url = navigationAction.request.url + { if !UIApplication.shared.canOpenURL(url) { decisionHandler(.cancel) } else { diff --git a/Palace/Network/Core/NetworkClient.swift b/Palace/Network/Core/NetworkClient.swift index cb9bd3861..3901bc00b 100644 --- a/Palace/Network/Core/NetworkClient.swift +++ b/Palace/Network/Core/NetworkClient.swift @@ -6,10 +6,14 @@ import Foundation +// MARK: - NetworkClient + public protocol NetworkClient { func send(_ request: NetworkRequest) async throws -> NetworkResponse } +// MARK: - NetworkRequest + public struct NetworkRequest { public var method: HTTPMethod public var url: URL @@ -24,13 +28,20 @@ public struct NetworkRequest { } } +// MARK: - NetworkResponse + public struct NetworkResponse { public let data: Data public let response: HTTPURLResponse } +// MARK: - HTTPMethod + public enum HTTPMethod: String { - case GET, POST, PUT, PATCH, DELETE, HEAD + case GET + case POST + case PUT + case PATCH + case DELETE + case HEAD } - - diff --git a/Palace/Network/Core/URLSessionNetworkClient.swift b/Palace/Network/Core/URLSessionNetworkClient.swift index dc4fd0b99..4094aeb7c 100644 --- a/Palace/Network/Core/URLSessionNetworkClient.swift +++ b/Palace/Network/Core/URLSessionNetworkClient.swift @@ -18,14 +18,16 @@ final class URLSessionNetworkClient: NetworkClient { var errorDescription: String? { switch self { - case .invalidURL: return "Invalid request URL." - case .invalidResponse: return "Invalid or missing HTTP response." + case .invalidURL: "Invalid request URL." + case .invalidResponse: "Invalid or missing HTTP response." } } } func send(_ request: NetworkRequest) async throws -> NetworkResponse { - guard let url = URL(string: request.url.absoluteString) else { throw NetworkError.invalidURL } + guard let url = URL(string: request.url.absoluteString) else { + throw NetworkError.invalidURL + } var urlRequest = executor.request(for: url) urlRequest.httpMethod = request.method.rawValue request.headers.forEach { key, value in @@ -40,7 +42,11 @@ final class URLSessionNetworkClient: NetworkClient { if let http = response as? HTTPURLResponse { continuation.resume(returning: (data, http)) } else { - let err = NSError(domain: NSURLErrorDomain, code: NSURLErrorUnknown, userInfo: [NSLocalizedDescriptionKey: "Invalid response"]) + let err = NSError( + domain: NSURLErrorDomain, + code: NSURLErrorUnknown, + userInfo: [NSLocalizedDescriptionKey: "Invalid response"] + ) continuation.resume(throwing: err) } case let .failure(error, _): @@ -51,22 +57,40 @@ final class URLSessionNetworkClient: NetworkClient { switch request.method { case .GET, .HEAD: _ = self.executor.GET(urlRequest.url!, useTokenIfAvailable: true) { data, response, error in - if let error { continuation.resume(throwing: error); return } - guard let data = data, let response = response as? HTTPURLResponse else { continuation.resume(throwing: NetworkError.invalidResponse); return } + if let error { + continuation.resume(throwing: error); return + } + guard let data = data, + let response = response as? HTTPURLResponse + else { + continuation.resume(throwing: NetworkError.invalidResponse); return + } continuation.resume(returning: (data, response)) } case .POST: _ = self.executor.POST(urlRequest, useTokenIfAvailable: true) { data, response, error in - if let error { continuation.resume(throwing: error) ; return } - guard let data = data, let response = response as? HTTPURLResponse else { continuation.resume(throwing: NetworkError.invalidResponse); return } + if let error { + continuation.resume(throwing: error); return + } + guard let data = data, + let response = response as? HTTPURLResponse + else { + continuation.resume(throwing: NetworkError.invalidResponse); return + } continuation.resume(returning: (data, response)) } case .PUT: _ = self.executor.PUT(request: urlRequest, useTokenIfAvailable: true) { data, response, error in - if let error { continuation.resume(throwing: error) ; return } - guard let data = data, let response = response as? HTTPURLResponse else { continuation.resume(throwing: NetworkError.invalidResponse); return } + if let error { + continuation.resume(throwing: error); return + } + guard let data = data, + let response = response as? HTTPURLResponse + else { + continuation.resume(throwing: NetworkError.invalidResponse); return + } continuation.resume(returning: (data, response)) } @@ -75,15 +99,27 @@ final class URLSessionNetworkClient: NetworkClient { var patched = urlRequest patched.httpMethod = "PATCH" _ = self.executor.addBearerAndExecute(patched) { data, response, error in - if let error { continuation.resume(throwing: error) ; return } - guard let data = data, let response = response as? HTTPURLResponse else { continuation.resume(throwing: NetworkError.invalidResponse); return } + if let error { + continuation.resume(throwing: error); return + } + guard let data = data, + let response = response as? HTTPURLResponse + else { + continuation.resume(throwing: NetworkError.invalidResponse); return + } continuation.resume(returning: (data, response)) } case .DELETE: _ = self.executor.DELETE(urlRequest, useTokenIfAvailable: true) { data, response, error in - if let error { continuation.resume(throwing: error) ; return } - guard let data = data, let response = response as? HTTPURLResponse else { continuation.resume(throwing: NetworkError.invalidResponse); return } + if let error { + continuation.resume(throwing: error); return + } + guard let data = data, + let response = response as? HTTPURLResponse + else { + continuation.resume(throwing: NetworkError.invalidResponse); return + } continuation.resume(returning: (data, response)) } } @@ -92,5 +128,3 @@ final class URLSessionNetworkClient: NetworkClient { return NetworkResponse(data: data, response: response) } } - - diff --git a/Palace/Network/Reachability.swift b/Palace/Network/Reachability.swift index 5c7160ea3..7d11aacfe 100644 --- a/Palace/Network/Reachability.swift +++ b/Palace/Network/Reachability.swift @@ -76,7 +76,7 @@ class Reachability: NSObject { if connectionMonitor.currentPath.status == .satisfied { return true } - + var zeroAddress = sockaddr_in() zeroAddress.sin_len = UInt8(MemoryLayout.size(ofValue: zeroAddress)) zeroAddress.sin_family = sa_family_t(AF_INET) @@ -99,20 +99,23 @@ class Reachability: NSObject { let reachable = flags.contains(.reachable) let needsConnection = flags.contains(.connectionRequired) let isConnected = (reachable && !needsConnection) - - Log.debug(#file, "Reachability check: reachable=\(reachable), needsConnection=\(needsConnection), result=\(isConnected)") - + + Log.debug( + #file, + "Reachability check: reachable=\(reachable), needsConnection=\(needsConnection), result=\(isConnected)" + ) + return isConnected } - + func getDetailedConnectivityStatus() -> (isConnected: Bool, connectionType: String, details: String) { let currentPath = connectionMonitor.currentPath - + switch currentPath.status { case .satisfied: var connectionType = "Unknown" var details = "Connected" - + if currentPath.usesInterfaceType(.wifi) { connectionType = "WiFi" details += " via WiFi" @@ -123,23 +126,23 @@ class Reachability: NSObject { connectionType = "Ethernet" details += " via Ethernet" } - + if currentPath.isExpensive { details += " (Expensive)" } - + if currentPath.isConstrained { details += " (Constrained)" } - + return (true, connectionType, details) - + case .unsatisfied: return (false, "None", "No network connection") - + case .requiresConnection: return (false, "Pending", "Connection required but not established") - + @unknown default: return (false, "Unknown", "Unknown network status") } diff --git a/Palace/Network/RemoteHTMLViewController.swift b/Palace/Network/RemoteHTMLViewController.swift index cd921c3a8..2b0ef8539 100644 --- a/Palace/Network/RemoteHTMLViewController.swift +++ b/Palace/Network/RemoteHTMLViewController.swift @@ -9,41 +9,41 @@ import WebKit let failureMessage: String var webView: WKWebView var activityView: UIActivityIndicatorView! - + required init(URL: URL, title: String, failureMessage: String) { - self.fileURL = URL + fileURL = URL self.failureMessage = failureMessage - self.webView = WKWebView() - + webView = WKWebView() + super.init(nibName: nil, bundle: nil) - + self.title = title } - + @available(*, unavailable) - required init?(coder aDecoder: NSCoder) { + required init?(coder _: NSCoder) { fatalError("init(coder:) has not been implemented") } - + override func viewDidLoad() { super.viewDidLoad() - webView.frame = self.view.frame + webView.frame = view.frame webView.navigationDelegate = self webView.backgroundColor = UIColor.white webView.allowsBackForwardNavigationGestures = true - view.addSubview(self.webView) + view.addSubview(webView) webView.autoPinEdgesToSuperviewEdges() - let request = URLRequest.init(url: fileURL, cachePolicy: .useProtocolCachePolicy, timeoutInterval: 10.0) + let request = URLRequest(url: fileURL, cachePolicy: .useProtocolCachePolicy, timeoutInterval: 10.0) webView.load(request) - + activityViewShouldShow(true) } - - func activityViewShouldShow(_ shouldShow: Bool) -> Void { + + func activityViewShouldShow(_ shouldShow: Bool) { if shouldShow == true { - activityView = UIActivityIndicatorView.init(style: .medium) + activityView = UIActivityIndicatorView(style: .medium) view.addSubview(activityView) activityView.autoCenterInSuperview() activityView.startAnimating() @@ -53,26 +53,31 @@ import WebKit } } - func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) { + func webView(_ webView: WKWebView, didFailProvisionalNavigation _: WKNavigation!, withError error: Error) { activityViewShouldShow(false) - let alert = UIAlertController.init(title: Strings.Error.connectionFailed, - message: error.localizedDescription, - preferredStyle: .alert) - let action1 = UIAlertAction.init(title: Strings.Generic.cancel, style: .destructive) { (cancelAction) in + let alert = UIAlertController( + title: Strings.Error.connectionFailed, + message: error.localizedDescription, + preferredStyle: .alert + ) + let action1 = UIAlertAction(title: Strings.Generic.cancel, style: .destructive) { _ in _ = self.navigationController?.popViewController(animated: true) } - let action2 = UIAlertAction.init(title: Strings.Generic.reload, style: .destructive) { (reloadAction) in + let action2 = UIAlertAction(title: Strings.Generic.reload, style: .destructive) { _ in var urlRequest = URLRequest(url: self.fileURL, cachePolicy: .useProtocolCachePolicy, timeoutInterval: 10.0) webView.load(urlRequest.applyCustomUserAgent()) } - + alert.addAction(action1) alert.addAction(action2) - self.present(alert, animated: true, completion: nil) + present(alert, animated: true, completion: nil) } - - func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) { + func webView( + _: WKWebView, + decidePolicyFor navigationAction: WKNavigationAction, + decisionHandler: @escaping (WKNavigationActionPolicy) -> Void + ) { guard navigationAction.navigationType == .linkActivated, let url = navigationAction.request.url else { decisionHandler(.allow) return @@ -83,14 +88,16 @@ import WebKit } decisionHandler(.cancel) } - - func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { + + func webView(_: WKWebView, didFinish _: WKNavigation!) { activityViewShouldShow(false) } - func webView(_ webView: WKWebView, - didFail navigation: WKNavigation!, - withError error: Error) { + func webView( + _: WKWebView, + didFail _: WKNavigation!, + withError _: Error + ) { activityViewShouldShow(false) } } diff --git a/Palace/Network/TPPCaching.swift b/Palace/Network/TPPCaching.swift index 8b2bd2be8..e99eaeef4 100644 --- a/Palace/Network/TPPCaching.swift +++ b/Palace/Network/TPPCaching.swift @@ -8,7 +8,7 @@ import Foundation -//------------------------------------------------------------------------------ +// ------------------------------------------------------------------------------ extension HTTPURLResponse { /** It appears that a `Cache-Control` header alone is not sufficient for having @@ -34,12 +34,12 @@ extension HTTPURLResponse { } if expiresHeader != nil { - if lastModifiedHeader != nil || eTagHeader != nil { + if lastModifiedHeader != nil || eTagHeader != nil { return true } } - return (lastModifiedHeader != nil && eTagHeader != nil) + return lastModifiedHeader != nil && eTagHeader != nil } /** @@ -83,26 +83,26 @@ extension HTTPURLResponse { } var cacheControlHeader: String? { - return header(named: "Cache-Control") + header(named: "Cache-Control") } var expiresHeader: String? { - return header(named: "Expires") + header(named: "Expires") } var lastModifiedHeader: String? { - return header(named: "Last-Modified") + header(named: "Last-Modified") } var eTagHeader: String? { - return header(named: "ETag") + header(named: "ETag") } /// Checks capitalization of given header key, including capitalized, /// lowercase and uppercase variations. /// - Parameter header: The name of a header to check. private func header(named header: String) -> String? { - let responseHeaders = self.allHeaderFields + let responseHeaders = allHeaderFields if let value = responseHeaders[header] as? String { return value @@ -126,14 +126,14 @@ extension HTTPURLResponse { /// that directive is present in `Cache-Control`, otherwise it's 3 hours. func modifyingCacheHeaders() -> HTTPURLResponse { // don't mess with failed responses - + guard (200...299).contains(statusCode) else { return self } - + // convert existing headers into a [String: String] dictionary we can use // later - let headerPairs: [(String, String)] = self.allHeaderFields.compactMap { + let headerPairs: [(String, String)] = allHeaderFields.compactMap { if let key = $0.key as? String, let val = $0.value as? String { return (key, val) } @@ -143,35 +143,39 @@ extension HTTPURLResponse { // use `max-age` value if present, otherwise use 3 hours for both // `max-age` and `Expires`. - if self.expiresHeader == nil { - let maxAge: TimeInterval = self.cacheControlMaxAge ?? 60 * 60 * 3 + if expiresHeader == nil { + let maxAge: TimeInterval = cacheControlMaxAge ?? 60 * 60 * 3 let in3HoursDate = Date().addingTimeInterval(maxAge) headers["Expires"] = in3HoursDate.rfc1123String } - if self.cacheControlHeader == nil { + if cacheControlHeader == nil { headers["Cache-Control"] = "public, max-age=10800" } // new response with added caching guard - let url = self.url, + let url = url, let newResponse = HTTPURLResponse( url: url, statusCode: statusCode, httpVersion: nil, - headerFields: headers) else { - Log.error(#file, """ - Unable to create new HTTPURLResponse with added cache-control \ - headers from original response: \(self) - """) - return self + headerFields: headers + ) + else { + Log.error(#file, """ + Unable to create new HTTPURLResponse with added cache-control \ + headers from original response: \(self) + """) + return self } return newResponse } } -//------------------------------------------------------------------------------ +// MARK: - NYPLCachingStrategy + +// ------------------------------------------------------------------------------ /// The possible strategies for caching for TPPNetworkExecutor / Responder. /// /// `ephemeral` caching corresponds to `NYPLSessionConfiguration::ephmeral`. @@ -187,9 +191,10 @@ extension HTTPURLResponse { case fallback } -//------------------------------------------------------------------------------ -class TPPCaching { +// MARK: - TPPCaching +// ------------------------------------------------------------------------------ +class TPPCaching { /// Makes a URLSessionConfiguration for standard HTTP requests with in-memory /// and disk caching enabled. /// - Parameter caching: The caching strategy to proces responses with. @@ -197,8 +202,10 @@ class TPPCaching { /// policy will always follow the one defined in the request protocol /// implementation. /// - Returns: A configuration with 8 max connections per host. - class func makeURLSessionConfiguration(caching: NYPLCachingStrategy, - requestTimeout: TimeInterval) -> URLSessionConfiguration { + class func makeURLSessionConfiguration( + caching: NYPLCachingStrategy, + requestTimeout: TimeInterval + ) -> URLSessionConfiguration { guard caching != .ephemeral else { return .ephemeral } @@ -240,14 +247,18 @@ class TPPCaching { private class func makeCache() -> URLCache { if #available(iOS 13.0, *) { - let cache = URLCache(memoryCapacity: maxMemoryCapacity, - diskCapacity: maxDiskCapacity, - directory: nil) + let cache = URLCache( + memoryCapacity: maxMemoryCapacity, + diskCapacity: maxDiskCapacity, + directory: nil + ) return cache - } - let cache = URLCache(memoryCapacity: maxMemoryCapacity, - diskCapacity: maxDiskCapacity, - diskPath: nil) - return cache } + let cache = URLCache( + memoryCapacity: maxMemoryCapacity, + diskCapacity: maxDiskCapacity, + diskPath: nil + ) + return cache + } } diff --git a/Palace/Network/TPPNetworkExecutor.swift b/Palace/Network/TPPNetworkExecutor.swift index 645f9011b..a1b58ba16 100644 --- a/Palace/Network/TPPNetworkExecutor.swift +++ b/Palace/Network/TPPNetworkExecutor.swift @@ -8,11 +8,15 @@ import Foundation +// MARK: - NYPLResult + enum NYPLResult { case success(SuccessInfo, URLResponse?) case failure(TPPUserFriendlyError, URLResponse?) } +// MARK: - TPPNetworkExecutor + @objc class TPPNetworkExecutor: NSObject { private let urlSession: URLSession private let refreshQueue = DispatchQueue(label: "com.palace.token-refresh-queue", qos: .userInitiated) @@ -21,60 +25,75 @@ enum NYPLResult { private let retryQueueLock = NSLock() private var activeTasks: [URLSessionTask] = [] private let activeTasksLock = NSLock() - + private let responder: TPPNetworkResponder - - @objc init(credentialsProvider: NYPLBasicAuthCredentialsProvider? = nil, - cachingStrategy: NYPLCachingStrategy, - delegateQueue: OperationQueue? = nil) { - self.responder = TPPNetworkResponder(credentialsProvider: credentialsProvider, - useFallbackCaching: cachingStrategy == .fallback) - + + @objc init( + credentialsProvider: NYPLBasicAuthCredentialsProvider? = nil, + cachingStrategy: NYPLCachingStrategy, + delegateQueue: OperationQueue? = nil + ) { + responder = TPPNetworkResponder( + credentialsProvider: credentialsProvider, + useFallbackCaching: cachingStrategy == .fallback + ) + let config = TPPCaching.makeURLSessionConfiguration( caching: cachingStrategy, - requestTimeout: TPPNetworkExecutor.defaultRequestTimeout) - self.urlSession = URLSession(configuration: config, - delegate: self.responder, - delegateQueue: delegateQueue) + requestTimeout: TPPNetworkExecutor.defaultRequestTimeout + ) + urlSession = URLSession( + configuration: config, + delegate: responder, + delegateQueue: delegateQueue + ) super.init() } /// Test-friendly initializer allowing a custom URLSessionConfiguration (e.g., with custom URLProtocol classes) - @objc init(credentialsProvider: NYPLBasicAuthCredentialsProvider? = nil, - cachingStrategy: NYPLCachingStrategy, - sessionConfiguration: URLSessionConfiguration, - delegateQueue: OperationQueue? = nil) { - self.responder = TPPNetworkResponder(credentialsProvider: credentialsProvider, - useFallbackCaching: cachingStrategy == .fallback) - self.urlSession = URLSession(configuration: sessionConfiguration, - delegate: self.responder, - delegateQueue: delegateQueue) + @objc init( + credentialsProvider: NYPLBasicAuthCredentialsProvider? = nil, + cachingStrategy: NYPLCachingStrategy, + sessionConfiguration: URLSessionConfiguration, + delegateQueue: OperationQueue? = nil + ) { + responder = TPPNetworkResponder( + credentialsProvider: credentialsProvider, + useFallbackCaching: cachingStrategy == .fallback + ) + urlSession = URLSession( + configuration: sessionConfiguration, + delegate: responder, + delegateQueue: delegateQueue + ) super.init() } - + deinit { urlSession.finishTasksAndInvalidate() } - + @objc static let shared = TPPNetworkExecutor(cachingStrategy: .fallback) - - func GET(_ reqURL: URL, - useTokenIfAvailable: Bool = true, - completion: @escaping (_ result: NYPLResult) -> Void) { + + func GET( + _ reqURL: URL, + useTokenIfAvailable: Bool = true, + completion: @escaping (_ result: NYPLResult) -> Void + ) { let req = request(for: reqURL, useTokenIfAvailable: useTokenIfAvailable) let task = executeRequest(req, enableTokenRefresh: useTokenIfAvailable, completion: completion) - + if let task = task { addTaskToActiveTasks(task) } } - + private func addTaskToActiveTasks(_ task: URLSessionTask) { activeTasksLock.lock() activeTasks.append(task) activeTasksLock.unlock() } - + private func removeTaskFromActiveTasks(_ task: URLSessionTask) { activeTasksLock.lock() if let index = activeTasks.firstIndex(of: task) { @@ -82,7 +101,7 @@ enum NYPLResult { } activeTasksLock.unlock() } - + @objc func pauseAllTasks() { activeTasksLock.lock() activeTasks.forEach { task in @@ -92,7 +111,8 @@ enum NYPLResult { url.absoluteString.contains(".m4a") || url.absoluteString.contains("audio") || url.absoluteString.contains("readium") || - url.absoluteString.contains("lcp") { + url.absoluteString.contains("lcp") + { Log.info(#file, "Preserving audiobook network task: \(url.absoluteString)") return } @@ -100,7 +120,7 @@ enum NYPLResult { } activeTasksLock.unlock() } - + @objc func resumeAllTasks() { activeTasksLock.lock() activeTasks.forEach { $0.resume() } @@ -108,9 +128,15 @@ enum NYPLResult { } } +// MARK: TPPRequestExecuting + extension TPPNetworkExecutor: TPPRequestExecuting { @discardableResult - func executeRequest(_ req: URLRequest, enableTokenRefresh: Bool, completion: @escaping (_: NYPLResult) -> Void) -> URLSessionDataTask? { + func executeRequest( + _ req: URLRequest, + enableTokenRefresh: Bool, + completion: @escaping (_: NYPLResult) -> Void + ) -> URLSessionDataTask? { let userAccount = TPPUserAccount.sharedAccount() if let authDefinition = userAccount.authDefinition, authDefinition.isSaml { @@ -132,8 +158,10 @@ extension TPPNetworkExecutor: TPPRequestExecuting { return performDataTask(with: req, completion: completion) } - private func performDataTask(with request: URLRequest, - completion: @escaping (_: NYPLResult) -> Void) -> URLSessionDataTask { + private func performDataTask( + with request: URLRequest, + completion: @escaping (_: NYPLResult) -> Void + ) -> URLSessionDataTask { let task = urlSession.dataTask(with: request) responder.addCompletion(completion, taskID: task.taskIdentifier) task.resume() @@ -143,7 +171,7 @@ extension TPPNetworkExecutor: TPPRequestExecuting { extension TPPNetworkExecutor { private func createErrorForRetryFailure() -> NSError { - return NSError( + NSError( domain: TPPErrorLogger.clientDomain, code: TPPErrorCode.invalidCredentials.rawValue, userInfo: [NSLocalizedDescriptionKey: "Unauthorized HTTP after token refresh attempt"] @@ -153,8 +181,10 @@ extension TPPNetworkExecutor { extension TPPNetworkExecutor { @objc func request(for url: URL, useTokenIfAvailable: Bool = true) -> URLRequest { - var urlRequest = URLRequest(url: url, - cachePolicy: urlSession.configuration.requestCachePolicy) + var urlRequest = URLRequest( + url: url, + cachePolicy: urlSession.configuration.requestCachePolicy + ) urlRequest.applyCustomUserAgent() if let authToken = TPPUserAccount.sharedAccount().authToken, useTokenIfAvailable { let headers = [ @@ -166,7 +196,7 @@ extension TPPNetworkExecutor { urlRequest.setValue("", forHTTPHeaderField: "Accept-Language") return urlRequest } - + @objc func clearCache() { urlSession.configuration.urlCache?.removeAllCachedResponses() } @@ -174,14 +204,13 @@ extension TPPNetworkExecutor { extension TPPNetworkExecutor { @objc class func bearerAuthorized(request: URLRequest) -> URLRequest { - let headers: [String: String] - if let authToken = TPPUserAccount.sharedAccount().authToken, !authToken.isEmpty { - headers = [ + let headers: [String: String] = if let authToken = TPPUserAccount.sharedAccount().authToken, !authToken.isEmpty { + [ "Authorization": "Bearer \(authToken)", "Content-Type": "application/json" ] } else { - headers = [ + [ "Authorization": "", "Content-Type": "application/json" ] @@ -194,8 +223,12 @@ extension TPPNetworkExecutor { return request } - @objc func download(_ reqURL: URL, - completion: @escaping (_ result: Data?, _ response: URLResponse?, _ error: Error?) -> Void) -> URLSessionDownloadTask { + @objc func download( + _ reqURL: URL, + completion: @escaping (_ result: Data?, _ response: URLResponse?, _ error: Error?) -> Void + ) + -> URLSessionDownloadTask + { let req = request(for: reqURL) let completionWrapper: (_ result: NYPLResult) -> Void = { result in switch result { @@ -210,9 +243,11 @@ extension TPPNetworkExecutor { return task } - - @objc func addBearerAndExecute(_ request: URLRequest, - completion: @escaping (_ result: Data?, _ response: URLResponse?, _ error: Error?) -> Void) -> URLSessionDataTask? { + + @objc func addBearerAndExecute( + _ request: URLRequest, + completion: @escaping (_ result: Data?, _ response: URLResponse?, _ error: Error?) -> Void + ) -> URLSessionDataTask? { let req = TPPNetworkExecutor.bearerAuthorized(request: request) let completionWrapper: (_ result: NYPLResult) -> Void = { result in switch result { @@ -223,26 +258,44 @@ extension TPPNetworkExecutor { return executeRequest(req, enableTokenRefresh: false, completion: completionWrapper) } - @objc func GET(_ reqURL: URL, - cachePolicy: NSURLRequest.CachePolicy = .useProtocolCachePolicy, - useTokenIfAvailable: Bool = true, - completion: @escaping (_ result: Data?, _ response: URLResponse?, _ error: Error?) -> Void) -> URLSessionDataTask? { - GET(request: request(for: reqURL), cachePolicy: cachePolicy, useTokenIfAvailable: useTokenIfAvailable, completion: completion) + @objc func GET( + _ reqURL: URL, + cachePolicy: NSURLRequest.CachePolicy = .useProtocolCachePolicy, + useTokenIfAvailable: Bool = true, + completion: @escaping (_ result: Data?, _ response: URLResponse?, _ error: Error?) -> Void + ) + -> URLSessionDataTask? + { + GET( + request: request(for: reqURL), + cachePolicy: cachePolicy, + useTokenIfAvailable: useTokenIfAvailable, + completion: completion + ) } - - @objc func GET(request: URLRequest, - cachePolicy: NSURLRequest.CachePolicy = .useProtocolCachePolicy, - useTokenIfAvailable: Bool, - completion: @escaping (_ result: Data?, _ response: URLResponse?, _ error: Error?) -> Void) -> URLSessionDataTask? { - if (request.httpMethod != "GET") { + + @objc func GET( + request: URLRequest, + cachePolicy: NSURLRequest.CachePolicy = .useProtocolCachePolicy, + useTokenIfAvailable: Bool, + completion: @escaping (_ result: Data?, _ response: URLResponse?, _ error: Error?) -> Void + ) + -> URLSessionDataTask? + { + if request.httpMethod != "GET" { var newRequest = request newRequest.httpMethod = "GET" - return GET(request: newRequest, cachePolicy: cachePolicy, useTokenIfAvailable: useTokenIfAvailable, completion: completion) + return GET( + request: newRequest, + cachePolicy: cachePolicy, + useTokenIfAvailable: useTokenIfAvailable, + completion: completion + ) } - + var updatedReq = request updatedReq.cachePolicy = cachePolicy - + let completionWrapper: (_ result: NYPLResult) -> Void = { result in switch result { case let .success(data, response): completion(data, response, nil) @@ -251,22 +304,30 @@ extension TPPNetworkExecutor { } return executeRequest(updatedReq, enableTokenRefresh: useTokenIfAvailable, completion: completionWrapper) } - - @objc func PUT(_ reqURL: URL, - useTokenIfAvailable: Bool, - completion: @escaping (_ result: Data?, _ response: URLResponse?, _ error: Error?) -> Void) -> URLSessionDataTask? { + + @objc func PUT( + _ reqURL: URL, + useTokenIfAvailable: Bool, + completion: @escaping (_ result: Data?, _ response: URLResponse?, _ error: Error?) -> Void + ) + -> URLSessionDataTask? + { PUT(request: request(for: reqURL), useTokenIfAvailable: useTokenIfAvailable, completion: completion) } - - @objc func PUT(request: URLRequest, - useTokenIfAvailable: Bool, - completion: @escaping (_ result: Data?, _ response: URLResponse?, _ error: Error?) -> Void) -> URLSessionDataTask? { - if (request.httpMethod != "PUT") { + + @objc func PUT( + request: URLRequest, + useTokenIfAvailable: Bool, + completion: @escaping (_ result: Data?, _ response: URLResponse?, _ error: Error?) -> Void + ) + -> URLSessionDataTask? + { + if request.httpMethod != "PUT" { var newRequest = request newRequest.httpMethod = "PUT" return PUT(request: newRequest, useTokenIfAvailable: useTokenIfAvailable, completion: completion) } - + let completionWrapper: (_ result: NYPLResult) -> Void = { result in switch result { case let .success(data, response): completion(data, response, nil) @@ -275,18 +336,22 @@ extension TPPNetworkExecutor { } return executeRequest(request, enableTokenRefresh: useTokenIfAvailable, completion: completionWrapper) } - + @discardableResult @objc - func POST(_ request: URLRequest, - useTokenIfAvailable: Bool, - completion: ((_ result: Data?, _ response: URLResponse?, _ error: Error?) -> Void)?) -> URLSessionDataTask? { - if (request.httpMethod != "POST") { + func POST( + _ request: URLRequest, + useTokenIfAvailable: Bool, + completion: ((_ result: Data?, _ response: URLResponse?, _ error: Error?) -> Void)? + ) + -> URLSessionDataTask? + { + if request.httpMethod != "POST" { var newRequest = request newRequest.httpMethod = "POST" return POST(newRequest, useTokenIfAvailable: useTokenIfAvailable, completion: completion) } - + let completionWrapper: (_ result: NYPLResult) -> Void = { result in switch result { case let .success(data, response): completion?(data, response, nil) @@ -295,18 +360,22 @@ extension TPPNetworkExecutor { } return executeRequest(request, enableTokenRefresh: useTokenIfAvailable, completion: completionWrapper) } - + @discardableResult @objc - func DELETE(_ request: URLRequest, - useTokenIfAvailable: Bool, - completion: ((_ result: Data?, _ response: URLResponse?, _ error: Error?) -> Void)?) -> URLSessionDataTask? { - if (request.httpMethod != "DELETE") { + func DELETE( + _ request: URLRequest, + useTokenIfAvailable: Bool, + completion: ((_ result: Data?, _ response: URLResponse?, _ error: Error?) -> Void)? + ) + -> URLSessionDataTask? + { + if request.httpMethod != "DELETE" { var newRequest = request newRequest.httpMethod = "DELETE" return DELETE(newRequest, useTokenIfAvailable: useTokenIfAvailable, completion: completion) } - + let completionWrapper: (_ result: NYPLResult) -> Void = { result in switch result { case let .success(data, response): completion?(data, response, nil) @@ -315,64 +384,78 @@ extension TPPNetworkExecutor { } return executeRequest(request, enableTokenRefresh: false, completion: completionWrapper) } - + func refreshTokenAndResume(task: URLSessionTask?, completion: ((_ result: NYPLResult) -> Void)? = nil) { refreshQueue.async { [weak self] in - guard let self = self else { return } - guard !self.isRefreshing else { return } - - self.isRefreshing = true - + guard let self = self else { + return + } + guard !isRefreshing else { + return + } + + isRefreshing = true + guard let username = TPPUserAccount.sharedAccount().username, let password = TPPUserAccount.sharedAccount().pin, - let tokenURL = TPPUserAccount.sharedAccount().authDefinition?.tokenURL else { + let tokenURL = TPPUserAccount.sharedAccount().authDefinition?.tokenURL + else { Log.info(#file, "Failed to refresh token due to missing credentials!") - self.isRefreshing = false - let error = NSError(domain: TPPErrorLogger.clientDomain, code: TPPErrorCode.invalidCredentials.rawValue, userInfo: [NSLocalizedDescriptionKey: "Unauthorized HTTP"]) + isRefreshing = false + let error = NSError( + domain: TPPErrorLogger.clientDomain, + code: TPPErrorCode.invalidCredentials.rawValue, + userInfo: [NSLocalizedDescriptionKey: "Unauthorized HTTP"] + ) completion?(NYPLResult.failure(error, nil)) return } - + if let task { - self.retryQueueLock.lock() - self.retryQueue.append(task) + retryQueueLock.lock() + retryQueue.append(task) if let completion { responder.addCompletion(completion, taskID: task.taskIdentifier) } - self.retryQueueLock.unlock() + retryQueueLock.unlock() } - - self.executeTokenRefresh(username: username, password: password, tokenURL: tokenURL) { result in + + executeTokenRefresh(username: username, password: password, tokenURL: tokenURL) { result in defer { self.isRefreshing = false } - + switch result { case .success: var newTasks = [URLSessionTask]() - + self.retryQueueLock.lock() self.retryQueue.forEach { oldTask in guard let originalRequest = oldTask.originalRequest, - let originalURL = originalRequest.url else { + let originalURL = originalRequest.url + else { return } - + var mutableRequest = self.request(for: originalURL) mutableRequest.hasRetried = true let newTask = self.urlSession.dataTask(with: mutableRequest) self.responder.updateCompletionId(oldTask.taskIdentifier, newId: newTask.taskIdentifier) newTasks.append(newTask) - + oldTask.cancel() } - + self.retryQueue.removeAll() self.retryQueueLock.unlock() - + newTasks.forEach { $0.resume() } - - case .failure(let error): + + case let .failure(error): Log.info(#file, "Failed to refresh token with error: \(error)") - let error = NSError(domain: TPPErrorLogger.clientDomain, code: TPPErrorCode.invalidCredentials.rawValue, userInfo: [NSLocalizedDescriptionKey: "\(error.localizedDescription)"]) + let error = NSError( + domain: TPPErrorLogger.clientDomain, + code: TPPErrorCode.invalidCredentials.rawValue, + userInfo: [NSLocalizedDescriptionKey: "\(error.localizedDescription)"] + ) completion?(NYPLResult.failure(error, nil)) } } @@ -388,7 +471,7 @@ extension TPPNetworkExecutor { retryQueueLock.lock() continue } - self.executeRequest(request, enableTokenRefresh: true) { _ in + executeRequest(request, enableTokenRefresh: true) { _ in Log.info(#file, "Task Successfully resumed after token refresh") } retryQueueLock.lock() @@ -396,14 +479,18 @@ extension TPPNetworkExecutor { retryQueueLock.unlock() } - - func executeTokenRefresh(username: String, password: String, tokenURL: URL, completion: @escaping (Result) -> Void) { + func executeTokenRefresh( + username: String, + password: String, + tokenURL: URL, + completion: @escaping (Result) -> Void + ) { Task { let tokenRequest = TokenRequest(url: tokenURL, username: username, password: password) let result = await tokenRequest.execute() - + switch result { - case .success(let tokenResponse): + case let .success(tokenResponse): TPPUserAccount.sharedAccount().setAuthToken( tokenResponse.accessToken, barcode: username, @@ -411,7 +498,7 @@ extension TPPNetworkExecutor { expirationDate: tokenResponse.expirationDate ) completion(.success(tokenResponse)) - case .failure(let error): + case let .failure(error): completion(.failure(error)) } } @@ -419,13 +506,13 @@ extension TPPNetworkExecutor { } private extension URLRequest { - struct AssociatedKeys { + enum AssociatedKeys { static var hasRetriedKey = "hasRetriedKey" } - + var hasRetried: Bool { get { - return objc_getAssociatedObject(self, &AssociatedKeys.hasRetriedKey) as? Bool ?? false + objc_getAssociatedObject(self, &AssociatedKeys.hasRetriedKey) as? Bool ?? false } set { objc_setAssociatedObject(self, &AssociatedKeys.hasRetriedKey, newValue, .OBJC_ASSOCIATION_RETAIN) @@ -435,7 +522,7 @@ private extension URLRequest { extension TPPNetworkExecutor { func GET(_ reqURL: URL, useTokenIfAvailable: Bool = true) async throws -> (Data, URLResponse?) { - return try await withCheckedThrowingContinuation { continuation in + try await withCheckedThrowingContinuation { continuation in var didResume = false GET(reqURL, useTokenIfAvailable: useTokenIfAvailable) { result in @@ -453,7 +540,9 @@ extension TPPNetworkExecutor { } DispatchQueue.global().asyncAfter(deadline: .now() + 10.0) { - guard !didResume else { return } + guard !didResume else { + return + } didResume = true let timeoutError = NSError(domain: NSURLErrorDomain, code: NSURLErrorTimedOut, userInfo: nil) continuation.resume(throwing: timeoutError) diff --git a/Palace/Network/TPPNetworkQueue.swift b/Palace/Network/TPPNetworkQueue.swift index 900f9ad9c..d0b9415f3 100644 --- a/Palace/Network/TPPNetworkQueue.swift +++ b/Palace/Network/TPPNetworkQueue.swift @@ -5,17 +5,46 @@ import SQLite Recommended pattern by SQLite docs userVersion access allows us to migrate schemas going forward */ -extension Connection { - public var userVersion: Int { - get { return Int(try! scalar("PRAGMA user_version") as! Int64) } - set { try! run("PRAGMA user_version = \(newValue)") } +public extension Connection { + var userVersion: Int { + get { + do { + let result = try scalar("PRAGMA user_version") + if let version = result as? Int64 { + return Int(version) + } else { + Log.error(#file, "Failed to cast user_version to Int64, got: \(type(of: result))") + return 0 + } + } catch { + Log.error(#file, "Failed to get user_version: \(error)") + return 0 + } + } + set { + do { + try run("PRAGMA user_version = \(newValue)") + } catch { + Log.error(#file, "Failed to set user_version to \(newValue): \(error)") + } + } } } +// MARK: - HTTPMethodType + enum HTTPMethodType: String { - case GET, POST, HEAD, PUT, DELETE, OPTIONS, CONNECT + case GET + case POST + case HEAD + case PUT + case DELETE + case OPTIONS + case CONNECT } +// MARK: - NetworkQueue + /** The NetworkQueue is insantiated once on app startup and listens for a valid network notification from a reachability class. It then @@ -27,38 +56,40 @@ final class NetworkQueue: NSObject { static let sharedInstance = NetworkQueue() // For Objective-C classes - @objc class func shared() -> NetworkQueue - { - return NetworkQueue.sharedInstance + @objc class func shared() -> NetworkQueue { + NetworkQueue.sharedInstance } deinit { NotificationCenter.default.removeObserver(self) } - static let StatusCodes = [NSURLErrorTimedOut, - NSURLErrorCannotFindHost, - NSURLErrorCannotConnectToHost, - NSURLErrorNetworkConnectionLost, - NSURLErrorNotConnectedToInternet, - NSURLErrorInternationalRoamingOff, - NSURLErrorCallIsActive, - NSURLErrorDataNotAllowed, - NSURLErrorSecureConnectionFailed] + static let StatusCodes = [ + NSURLErrorTimedOut, + NSURLErrorCannotFindHost, + NSURLErrorCannotConnectToHost, + NSURLErrorNetworkConnectionLost, + NSURLErrorNotConnectedToInternet, + NSURLErrorInternationalRoamingOff, + NSURLErrorCallIsActive, + NSURLErrorDataNotAllowed, + NSURLErrorSecureConnectionFailed + ] let MaxRetriesInQueue = 5 let serialQueue = DispatchQueue(label: Bundle.main.bundleIdentifier! - + "." - + String(describing: NetworkQueue.self)) + + "." + + String(describing: NetworkQueue.self) + ) private static let DBVersion = 1 private static let TableName = "offline_queue" private var retryRequestCount = 0 private let path = NSSearchPathForDirectoriesInDomains(.applicationSupportDirectory, .userDomainMask, true).first! - + private let sqlTable = Table(NetworkQueue.TableName) - + private let sqlID = Expression("id") private let sqlLibraryID = Expression("library_identifier") private let sqlUpdateID = Expression("update_identifier") @@ -69,49 +100,62 @@ final class NetworkQueue: NSObject { private let sqlRetries = Expression("retry_count") private let sqlDateCreated = Expression("date_created") - - // MARK: - Public Functions @objc func addObserverForOfflineQueue() { - NotificationCenter.default.addObserver(self, selector: #selector(retryQueue), name: .TPPReachabilityChanged, object: nil) + NotificationCenter.default.addObserver( + self, + selector: #selector(retryQueue), + name: .TPPReachabilityChanged, + object: nil + ) } - func addRequest(_ libraryID: String, - _ updateID: String?, - _ requestUrl: URL, - _ method: HTTPMethodType, - _ parameters: Data?, - _ headers: [String : String]?) -> Void - { - self.serialQueue.async { - + func addRequest( + _ libraryID: String, + _ updateID: String?, + _ requestUrl: URL, + _ method: HTTPMethodType, + _ parameters: Data?, + _ headers: [String: String]? + ) { + serialQueue.async { // Serialize Data let urlString = requestUrl.absoluteString let methodString = method.rawValue let dateCreated = NSKeyedArchiver.archivedData(withRootObject: Date()) - - let headerData: Data? - if headers != nil { - headerData = NSKeyedArchiver.archivedData(withRootObject: headers!) + + let headerData: Data? = if headers != nil { + NSKeyedArchiver.archivedData(withRootObject: headers!) } else { - headerData = nil + nil } - - guard let db = self.startDatabaseConnection() else { return } - + + guard let db = self.startDatabaseConnection() else { + return + } + // Update (not insert) if uniqueID and libraryID match existing row in table let query = self.sqlTable.filter(self.sqlLibraryID == libraryID && self.sqlUpdateID == updateID) .filter(self.sqlUpdateID != nil) - + do { - //Try to update row + // Try to update row let result = try db.run(query.update(self.sqlParameters <- parameters, self.sqlHeader <- headerData)) if result > 0 { Log.debug(#file, "SQLite: Row Updated") } else { - //Insert new row - try db.run(self.sqlTable.insert(self.sqlLibraryID <- libraryID, self.sqlUpdateID <- updateID, self.sqlUrl <- urlString, self.sqlMethod <- methodString, self.sqlParameters <- parameters, self.sqlHeader <- headerData, self.sqlRetries <- 0, self.sqlDateCreated <- dateCreated)) + // Insert new row + try db.run(self.sqlTable.insert( + self.sqlLibraryID <- libraryID, + self.sqlUpdateID <- updateID, + self.sqlUrl <- urlString, + self.sqlMethod <- methodString, + self.sqlParameters <- parameters, + self.sqlHeader <- headerData, + self.sqlRetries <- 0, + self.sqlDateCreated <- dateCreated + )) Log.debug(#file, "SQLite: Row Added") } } catch { @@ -120,15 +164,27 @@ final class NetworkQueue: NSObject { } } - func migrate() - { - self.serialQueue.async { + func migrate() { + serialQueue.async { guard let db = self.startDatabaseConnection() else { Log.error(#file, "Failed to start database connection for a retry attempt.") return } - - let tableCount = Int(try! db.scalar("SELECT count(*) FROM sqlite_master WHERE type = 'table' AND name = '\(NetworkQueue.TableName)'") as! Int64) + + let tableCount: Int + do { + let result = try db + .scalar("SELECT count(*) FROM sqlite_master WHERE type = 'table' AND name = '\(NetworkQueue.TableName)'") + if let count = result as? Int64 { + tableCount = Int(count) + } else { + Log.error(#file, "Failed to cast table count to Int64, got: \(type(of: result))") + tableCount = 0 + } + } catch { + Log.error(#file, "Failed to check table existence: \(error)") + tableCount = 0 + } if tableCount < 1 { self.createTable(db: db) db.userVersion = NetworkQueue.DBVersion @@ -160,10 +216,9 @@ final class NetworkQueue: NSObject { // MARK: - Private Functions - private func createTable(db: Connection) - { + private func createTable(db: Connection) { do { - try db.run(self.sqlTable.create(ifNotExists: true) { t in + try db.run(sqlTable.create(ifNotExists: true) { t in t.column(self.sqlID, primaryKey: true) t.column(self.sqlLibraryID) t.column(self.sqlUpdateID) @@ -179,10 +234,8 @@ final class NetworkQueue: NSObject { } } - @objc private func retryQueue() - { - self.serialQueue.async { - + @objc private func retryQueue() { + serialQueue.async { if self.retryRequestCount > 0 { Log.debug(#file, "Retry requests are still in progress. Cancelling this attempt.") return @@ -210,8 +263,7 @@ final class NetworkQueue: NSObject { } } - private func retry(_ db: Connection, requestRow: Row) - { + private func retry(_ db: Connection, requestRow: Row) { do { let ID = Int(requestRow[sqlID]) let newValue = Int(requestRow[sqlRetries]) + 1 @@ -219,21 +271,22 @@ final class NetworkQueue: NSObject { } catch { Log.error(#file, "SQLite Error incrementing retry count") } - + // Re-attempt network request var urlRequest = URLRequest(url: URL(string: requestRow[sqlUrl])!) urlRequest.httpMethod = requestRow[sqlMethod] urlRequest.httpBody = requestRow[sqlParameters] urlRequest.applyCustomUserAgent() - + if let headerData = requestRow[sqlHeader], - let headers = NSKeyedUnarchiver.unarchiveObject(with: headerData) as? [String:String] { + let headers = NSKeyedUnarchiver.unarchiveObject(with: headerData) as? [String: String] + { for (headerKey, headerValue) in headers { urlRequest.setValue(headerValue, forHTTPHeaderField: headerKey) } } - - let task = URLSession.shared.dataTask(with: urlRequest) { (data, response, error) in + + let task = URLSession.shared.dataTask(with: urlRequest) { _, response, _ in self.serialQueue.async { if let response = response as? HTTPURLResponse { if response.statusCode == 200 { @@ -247,8 +300,7 @@ final class NetworkQueue: NSObject { task.resume() } - private func deleteRow(_ db: Connection, id: Int) - { + private func deleteRow(_ db: Connection, id: Int) { let rowToDelete = sqlTable.filter(sqlID == id) if let _ = try? db.run(rowToDelete.delete()) { Log.info(#file, "SQLite: deleted row from queue") @@ -256,9 +308,8 @@ final class NetworkQueue: NSObject { Log.error(#file, "SQLite Error: Could not delete row") } } - - private func startDatabaseConnection() -> Connection? - { + + private func startDatabaseConnection() -> Connection? { let db: Connection do { db = try Connection("\(path)/simplified.db") diff --git a/Palace/Network/TPPNetworkResponder.swift b/Palace/Network/TPPNetworkResponder.swift index 3e16dff57..38eecd332 100644 --- a/Palace/Network/TPPNetworkResponder.swift +++ b/Palace/Network/TPPNetworkResponder.swift @@ -8,56 +8,64 @@ import Foundation -fileprivate struct TPPNetworkTaskInfo { +// MARK: - TPPNetworkTaskInfo + +private struct TPPNetworkTaskInfo { var progressData: Data var startDate: Date - var completion: ((NYPLResult) -> Void) + var completion: (NYPLResult) -> Void - //---------------------------------------------------------------------------- - init(completion: (@escaping (NYPLResult) -> Void)) { - self.progressData = Data() - self.startDate = Date() + // ---------------------------------------------------------------------------- + init(completion: @escaping (NYPLResult) -> Void) { + progressData = Data() + startDate = Date() self.completion = completion } } +// MARK: - TPPNetworkResponder + /// This class responds to URLSession events related to the tasks being /// issued on the URLSession, keeping a tally of the related completion /// handlers in a thread-safe way. class TPPNetworkResponder: NSObject { typealias TaskID = Int - + private var tokenRefreshAttempts: Int = 0 private var taskInfo: [TaskID: TPPNetworkTaskInfo] private let useFallbackCaching: Bool private let credentialsProvider: NYPLBasicAuthCredentialsProvider? - + private let taskInfoQueue = DispatchQueue( label: "com.thepalaceproject.networkResponder.taskInfo" ) - //---------------------------------------------------------------------------- + // ---------------------------------------------------------------------------- /// - Parameter shouldEnableFallbackCaching: If set to `true`, the executor /// will attempt to cache responses even when these lack a sufficient set of /// caching headers. The default is `false`. /// - Parameter credentialsProvider: The object providing the credentials /// to respond to an authentication challenge. - init(credentialsProvider: NYPLBasicAuthCredentialsProvider? = nil, - useFallbackCaching: Bool = false) { - self.taskInfo = [Int: TPPNetworkTaskInfo]() + init( + credentialsProvider: NYPLBasicAuthCredentialsProvider? = nil, + useFallbackCaching: Bool = false + ) { + taskInfo = [Int: TPPNetworkTaskInfo]() self.useFallbackCaching = useFallbackCaching self.credentialsProvider = credentialsProvider super.init() } - //---------------------------------------------------------------------------- - func addCompletion(_ completion: @escaping (NYPLResult) -> Void, - taskID: TaskID) { + // ---------------------------------------------------------------------------- + func addCompletion( + _ completion: @escaping (NYPLResult) -> Void, + taskID: TaskID + ) { taskInfoQueue.async { - self.taskInfo[taskID] = TPPNetworkTaskInfo(completion: completion) - } + self.taskInfo[taskID] = TPPNetworkTaskInfo(completion: completion) + } } - + func updateCompletionId(_ oldId: TaskID, newId: TaskID) { taskInfoQueue.async { self.taskInfo[newId] = self.taskInfo[oldId] @@ -65,72 +73,83 @@ class TPPNetworkResponder: NSObject { } } -// MARK: - URLSessionDelegate -// MARK: - URLSessionDelegate +// MARK: URLSessionDelegate + extension TPPNetworkResponder: URLSessionDelegate { - func urlSession(_ session: URLSession, didBecomeInvalidWithError error: Error?) { + func urlSession(_: URLSession, didBecomeInvalidWithError error: Error?) { taskInfoQueue.async { let pending = self.taskInfo self.taskInfo.removeAll() - - let cancelError = NSError(domain: NSURLErrorDomain, - code: NSURLErrorCancelled, - userInfo: nil) + + let cancelError = NSError( + domain: NSURLErrorDomain, + code: NSURLErrorCancelled, + userInfo: nil + ) for (_, info) in pending { info.completion(.failure(cancelError, nil)) } - + if let err = error { TPPErrorLogger.logError(err, summary: "URLSession invalidated with error") } else { - TPPErrorLogger.logError(withCode: .invalidURLSession, - summary: "URLSession invalidated without error") + TPPErrorLogger.logError( + withCode: .invalidURLSession, + summary: "URLSession invalidated without error" + ) } } } } -// MARK: - URLSessionDataDelegate +// MARK: URLSessionDataDelegate + extension TPPNetworkResponder: URLSessionDataDelegate { - - //---------------------------------------------------------------------------- - func urlSession(_ session: URLSession, - dataTask: URLSessionDataTask, - didReceive data: Data) { - taskInfoQueue.async { [ weak self] in - var info = self?.taskInfo[dataTask.taskIdentifier] - info?.progressData.append(data) - if let updated = info { - self?.taskInfo[dataTask.taskIdentifier] = updated - } - } + // ---------------------------------------------------------------------------- + func urlSession( + _: URLSession, + dataTask: URLSessionDataTask, + didReceive data: Data + ) { + taskInfoQueue.async { [weak self] in + var info = self?.taskInfo[dataTask.taskIdentifier] + info?.progressData.append(data) + if let updated = info { + self?.taskInfo[dataTask.taskIdentifier] = updated + } + } } - - //---------------------------------------------------------------------------- - func urlSession(_ session: URLSession, - dataTask: URLSessionDataTask, - willCacheResponse proposedResponse: CachedURLResponse, - completionHandler: @escaping (CachedURLResponse?) -> Void) { - + + // ---------------------------------------------------------------------------- + func urlSession( + _: URLSession, + dataTask _: URLSessionDataTask, + willCacheResponse proposedResponse: CachedURLResponse, + completionHandler: @escaping (CachedURLResponse?) -> Void + ) { guard let httpResponse = proposedResponse.response as? HTTPURLResponse else { completionHandler(proposedResponse) return } - + if httpResponse.hasSufficientCachingHeaders || !useFallbackCaching { completionHandler(proposedResponse) } else { let newResponse = httpResponse.modifyingCacheHeaders() - completionHandler(CachedURLResponse(response: newResponse, - data: proposedResponse.data)) + completionHandler(CachedURLResponse( + response: newResponse, + data: proposedResponse.data + )) } } - //---------------------------------------------------------------------------- + // ---------------------------------------------------------------------------- - func urlSession(_ session: URLSession, - task: URLSessionTask, - didCompleteWithError networkError: Error?) { + func urlSession( + _: URLSession, + task: URLSessionTask, + didCompleteWithError networkError: Error? + ) { let taskID = task.taskIdentifier var logMetadata: [String: Any] = [ "currentRequest": task.currentRequest?.loggableString ?? "N/A", @@ -156,7 +175,8 @@ extension TPPNetworkResponder: URLSessionDataDelegate { if let nsErr = networkError as NSError?, nsErr.domain == NSURLErrorDomain, - nsErr.code == NSURLErrorCancelled { + nsErr.code == NSURLErrorCancelled + { Log.info(#file, "Task \(taskID) cancelled: \(nsErr.localizedDescription)") return } @@ -168,13 +188,14 @@ extension TPPNetworkResponder: URLSessionDataDelegate { let result: NYPLResult if let http = task.response as? HTTPURLResponse { if http.statusCode == 401, - handleExpiredTokenIfNeeded(for: http, with: task) { + handleExpiredTokenIfNeeded(for: http, with: task) + { return } if !http.isSuccess() { let err: TPPUserFriendlyError let data = info.progressData - + if !data.isEmpty { err = task.parseAndLogError( fromProblemDocumentData: data, @@ -188,39 +209,39 @@ extension TPPNetworkResponder: URLSessionDataDelegate { userInfo: logMetadata ) } - + result = .failure(err, task.response) - } - - else if let netErr = networkError { + } else if let netErr = networkError { let ue = netErr as TPPUserFriendlyError result = .failure(ue, task.response) - TPPErrorLogger.logNetworkError(netErr, - summary: "Network task completed with error", - request: task.originalRequest, - response: task.response, - metadata: logMetadata) - } - else { + TPPErrorLogger.logNetworkError( + netErr, + summary: "Network task completed with error", + request: task.originalRequest, + response: task.response, + metadata: logMetadata + ) + } else { result = .success(info.progressData, task.response) } } else { - let err = NSError(domain: "Api call with failure HTTP status", - code: TPPErrorCode.invalidOrNoHTTPResponse.rawValue, - userInfo: logMetadata) + let err = NSError( + domain: "Api call with failure HTTP status", + code: TPPErrorCode.invalidOrNoHTTPResponse.rawValue, + userInfo: logMetadata + ) result = .failure(err, task.response) } - + info.completion(result) } - + private func logTaskCompletion(taskID: Int, startDate: Date, metadata: inout [String: Any]) { let elapsed = Date().timeIntervalSince(startDate) metadata["elapsedTime"] = elapsed Log.info(#file, "Task \(taskID) completed (\(metadata["currentRequest"] ?? "nil")), elapsed time: \(elapsed) sec") } - private func handleNoTaskInfo(for task: URLSessionTask, with networkError: Error?, logMetadata: inout [String: Any]) { logMetadata["NYPLNetworkResponder context"] = "No task info available for task \(task.taskIdentifier). Completion closure could not be called." TPPErrorLogger.logNetworkError( @@ -229,63 +250,85 @@ extension TPPNetworkResponder: URLSessionDataDelegate { summary: "Network layer error: task info unavailable", request: task.originalRequest, response: task.response, - metadata: logMetadata) + metadata: logMetadata + ) } - - private func handleHTTPResponse(_ httpResponse: HTTPURLResponse, for task: URLSessionTask, currentTaskInfo: TPPNetworkTaskInfo, logMetadata: inout [String: Any]) -> Bool { + + private func handleHTTPResponse( + _ httpResponse: HTTPURLResponse, + for task: URLSessionTask, + currentTaskInfo: TPPNetworkTaskInfo, + logMetadata: inout [String: Any] + ) -> Bool { guard httpResponse.isSuccess() else { logMetadata["response"] = httpResponse - var err: NSError = NSError() + var err = NSError() var code: TPPErrorCode = .responseFail var summary: String = Strings.Error.connectionFailed logMetadata[NSLocalizedDescriptionKey] = Strings.Error.unknownRequestError - + if httpResponse.statusCode == 401 { if (TPPUserAccount.sharedAccount().authDefinition?.isToken ?? false) && tokenRefreshAttempts < 2 { tokenRefreshAttempts += 1 return handleExpiredTokenIfNeeded(for: httpResponse, with: task) } - + logMetadata[NSLocalizedDescriptionKey] = Strings.Error.invalidCredentialsErrorMessage code = TPPErrorCode.invalidCredentials summary = Strings.Error.invalidCredentialsErrorMessage } - - err = NSError(domain: "Api call with failure HTTP status", - code: code.rawValue, - userInfo: logMetadata) - + + err = NSError( + domain: "Api call with failure HTTP status", + code: code.rawValue, + userInfo: logMetadata + ) + currentTaskInfo.completion(.failure(err, task.response)) - TPPErrorLogger.logNetworkError(code: code, - summary: summary, - request: task.originalRequest, - metadata: logMetadata) + TPPErrorLogger.logNetworkError( + code: code, + summary: summary, + request: task.originalRequest, + metadata: logMetadata + ) return false } - + return true } - - private func handleProblemDocument(for task: URLSessionTask, with responseData: Data, currentTaskInfo: TPPNetworkTaskInfo, networkError: Error?, logMetadata: [String: Any]) { - let errorWithProblemDoc = task.parseAndLogError(fromProblemDocumentData: responseData, - networkError: networkError, - logMetadata: logMetadata) + private func handleProblemDocument( + for task: URLSessionTask, + with responseData: Data, + currentTaskInfo: TPPNetworkTaskInfo, + networkError: Error?, + logMetadata: [String: Any] + ) { + let errorWithProblemDoc = task.parseAndLogError( + fromProblemDocumentData: responseData, + networkError: networkError, + logMetadata: logMetadata + ) currentTaskInfo.completion(.failure(errorWithProblemDoc, task.response)) } - - private func handleNetworkError(_ networkError: Error, for task: URLSessionTask, currentTaskInfo: TPPNetworkTaskInfo, logMetadata: [String: Any]) { + + private func handleNetworkError( + _ networkError: Error, + for task: URLSessionTask, + currentTaskInfo: TPPNetworkTaskInfo, + logMetadata: [String: Any] + ) { currentTaskInfo.completion(.failure(networkError as TPPUserFriendlyError, task.response)) TPPErrorLogger.logNetworkError( networkError, summary: "Network task completed with error", request: task.originalRequest, response: task.response, - metadata: logMetadata) + metadata: logMetadata + ) } } - private func handleExpiredTokenIfNeeded(for response: HTTPURLResponse, with task: URLSessionTask) -> Bool { if response.statusCode == 401 && TPPUserAccount.sharedAccount().hasCredentials() { TPPNetworkExecutor.shared.refreshTokenAndResume(task: task) @@ -294,14 +337,16 @@ private func handleExpiredTokenIfNeeded(for response: HTTPURLResponse, with task return false } -//------------------------------------------------------------------------------ +// ------------------------------------------------------------------------------ // MARK: - URLSessionTask extensions -extension URLSessionTask { - //---------------------------------------------------------------------------- - fileprivate func parseAndLogError(fromProblemDocumentData responseData: Data, - networkError: Error?, - logMetadata: [String: Any]) -> TPPUserFriendlyError { +private extension URLSessionTask { + // ---------------------------------------------------------------------------- + func parseAndLogError( + fromProblemDocumentData responseData: Data, + networkError: Error?, + logMetadata: [String: Any] + ) -> TPPUserFriendlyError { let parseError: Error? let code: TPPErrorCode let returnedError: TPPUserFriendlyError @@ -313,7 +358,7 @@ extension URLSessionTask { parseError = nil code = TPPErrorCode.problemDocAvailable logMetadata["problemDocument"] = problemDoc.dictionaryValue - } catch (let caughtParseError) { + } catch let caughtParseError { parseError = caughtParseError code = TPPErrorCode.parseProblemDocFail let responseString = String(data: responseData, encoding: .utf8) ?? "N/A" @@ -329,18 +374,20 @@ extension URLSessionTask { logMetadata["urlSessionError"] = networkError } - TPPErrorLogger.logNetworkError(parseError, - code: code, - summary: "Network request failed: Problem Document available", - request: originalRequest, - response: response, - metadata: logMetadata) + TPPErrorLogger.logNetworkError( + parseError, + code: code, + summary: "Network request failed: Problem Document available", + request: originalRequest, + response: response, + metadata: logMetadata + ) return returnedError } - //---------------------------------------------------------------------------- - fileprivate func error(fromProblemDocument problemDoc: TPPProblemDocument) -> NSError { + // ---------------------------------------------------------------------------- + func error(fromProblemDocument problemDoc: TPPProblemDocument) -> NSError { var userInfo = [String: Any]() if let currentRequest = currentRequest { userInfo["taskCurrentRequest"] = currentRequest @@ -356,38 +403,48 @@ extension URLSessionTask { problemDoc, domain: "Api call failure: problem document available", code: TPPErrorCode.apiCall.rawValue, - userInfo: userInfo) + userInfo: userInfo + ) return err } } -//---------------------------------------------------------------------------- -// MARK: - URLSessionTaskDelegate +// MARK: - TPPNetworkResponder + URLSessionTaskDelegate + +// ---------------------------------------------------------------------------- extension TPPNetworkResponder: URLSessionTaskDelegate { - func urlSession(_ session: URLSession, - task: URLSessionTask, - didReceive challenge: URLAuthenticationChallenge, - completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) - { + func urlSession( + _: URLSession, + task _: URLSessionTask, + didReceive challenge: URLAuthenticationChallenge, + completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void + ) { let credsProvider = credentialsProvider ?? TPPUserAccount.sharedAccount() let authChallenger = TPPBasicAuth(credentialsProvider: credsProvider) authChallenger.handleChallenge(challenge, completion: completionHandler) } - + func refreshToken() async throws { guard let tokenURL = TPPUserAccount.sharedAccount().authDefinition?.tokenURL, let username = TPPUserAccount.sharedAccount().username, let password = TPPUserAccount.sharedAccount().pin - else { return } - + else { + return + } + let tokenRequest = TokenRequest(url: tokenURL, username: username, password: password) let result = await tokenRequest.execute() - + switch result { - case .success(let tokenResponse): - TPPUserAccount.sharedAccount().setAuthToken(tokenResponse.accessToken, barcode: username, pin: password, expirationDate: tokenResponse.expirationDate) - case .failure(let error): + case let .success(tokenResponse): + TPPUserAccount.sharedAccount().setAuthToken( + tokenResponse.accessToken, + barcode: username, + pin: password, + expirationDate: tokenResponse.expirationDate + ) + case let .failure(error): throw error } } diff --git a/Palace/Network/TPPRequestExecuting.swift b/Palace/Network/TPPRequestExecuting.swift index e1cd3f892..85ca12ec5 100644 --- a/Palace/Network/TPPRequestExecuting.swift +++ b/Palace/Network/TPPRequestExecuting.swift @@ -10,6 +10,8 @@ import Foundation let TPPDefaultRequestTimeout: TimeInterval = 30.0 +// MARK: - TPPRequestExecuting + protocol TPPRequestExecuting { /// Execute a given request. /// - Parameters: @@ -18,29 +20,32 @@ protocol TPPRequestExecuting { /// the network or from the cache. /// - Returns: The task issueing the given request. @discardableResult - func executeRequest(_ req: URLRequest, - enableTokenRefresh: Bool, - completion: @escaping (_: NYPLResult) -> Void) -> URLSessionDataTask? + func executeRequest( + _ req: URLRequest, + enableTokenRefresh: Bool, + completion: @escaping (_: NYPLResult) -> Void + ) -> URLSessionDataTask? - var requestTimeout: TimeInterval {get} + var requestTimeout: TimeInterval { get } - static var defaultRequestTimeout: TimeInterval {get} + static var defaultRequestTimeout: TimeInterval { get } } extension TPPRequestExecuting { var requestTimeout: TimeInterval { - return Self.defaultRequestTimeout + Self.defaultRequestTimeout } static var defaultRequestTimeout: TimeInterval { - return TPPDefaultRequestTimeout + TPPDefaultRequestTimeout } @discardableResult - func executeRequest(_ req: URLRequest, - useTokenIfAvailable: Bool = true, - completion: @escaping (_: NYPLResult) -> Void) -> URLSessionDataTask { + func executeRequest( + _: URLRequest, + useTokenIfAvailable _: Bool = true, + completion _: @escaping (_: NYPLResult) -> Void + ) -> URLSessionDataTask { URLSessionDataTask() } } - diff --git a/Palace/Network/TPPUserFriendlyError.swift b/Palace/Network/TPPUserFriendlyError.swift index cef5611ee..a3e0f8ef2 100644 --- a/Palace/Network/TPPUserFriendlyError.swift +++ b/Palace/Network/TPPUserFriendlyError.swift @@ -8,6 +8,8 @@ import Foundation +// MARK: - TPPUserFriendlyError + /// A protocol describing an error that MAY offer user friendly /// messaging to the user. protocol TPPUserFriendlyError: Error { @@ -24,26 +26,28 @@ protocol TPPUserFriendlyError: Error { // is also ok because user friendly strings are in general never guaranteed // to be there, even when we obtain a problem document. extension TPPUserFriendlyError { - var userFriendlyTitle: String? { return nil } - var userFriendlyMessage: String? { return nil } + var userFriendlyTitle: String? { nil } + var userFriendlyMessage: String? { nil } } +// MARK: - NSError + TPPUserFriendlyError + extension NSError: TPPUserFriendlyError { private static let problemDocumentKey = "problemDocument" @objc var problemDocument: TPPProblemDocument? { - return userInfo[NSError.problemDocumentKey] as? TPPProblemDocument + userInfo[NSError.problemDocumentKey] as? TPPProblemDocument } /// Feeds off of the `problemDocument` computed property @objc var userFriendlyTitle: String? { - return problemDocument?.title + problemDocument?.title } /// Feeds off of the `problemDocument` computed property or the localized /// error description. @objc var userFriendlyMessage: String? { - return (problemDocument?.detail ?? userInfo[NSLocalizedDescriptionKey]) as? String + (problemDocument?.detail ?? userInfo[NSLocalizedDescriptionKey]) as? String } /// Builds an NSError using the given problem document for its user-friendly @@ -55,10 +59,12 @@ extension NSError: TPPUserFriendlyError { /// - userInfo: The user friendly messaging will be appended to this /// dictionary. /// - Returns: A new NSError with the ProblemDocument `title` and `detail`. - static func makeFromProblemDocument(_ problemDoc: TPPProblemDocument, - domain: String, - code: Int, - userInfo: [String: Any]?) -> NSError { + static func makeFromProblemDocument( + _ problemDoc: TPPProblemDocument, + domain: String, + code: Int, + userInfo: [String: Any]? + ) -> NSError { var userInfo = userInfo ?? [String: Any]() userInfo[NSError.problemDocumentKey] = problemDoc return NSError(domain: domain, code: code, userInfo: userInfo) diff --git a/Palace/Notifications/NotificationService.swift b/Palace/Notifications/NotificationService.swift index 1967daadb..ff2ec3f03 100644 --- a/Palace/Notifications/NotificationService.swift +++ b/Palace/Notifications/NotificationService.swift @@ -6,12 +6,11 @@ // Copyright © 2022 The Palace Project. All rights reserved. // -import UserNotifications import FirebaseCore import FirebaseMessaging +import UserNotifications class NotificationService: NSObject, UNUserNotificationCenterDelegate, MessagingDelegate { - /// Token data structure /// /// Based on API documentation @@ -19,46 +18,51 @@ class NotificationService: NSObject, UNUserNotificationCenterDelegate, Messaging struct TokenData: Codable { let device_token: String let token_type: String - + init(token: String) { - self.device_token = token - self.token_type = "FCMiOS" + device_token = token + token_type = "FCMiOS" } - + var data: Data? { try? JSONEncoder().encode(self) } } private let notificationCenter = UNUserNotificationCenter.current() - + static let shared = NotificationService() - + override init() { super.init() - + // Update library token when the user changes library account. - NotificationCenter.default.addObserver(forName: NSNotification.Name.TPPCurrentAccountDidChange, object: nil, queue: nil) { _ in + NotificationCenter.default.addObserver( + forName: NSNotification.Name.TPPCurrentAccountDidChange, + object: nil, + queue: nil + ) { _ in self.updateToken() } // Update library token when the user signes in (but has already added the library) - NotificationCenter.default.addObserver(forName: NSNotification.Name.TPPIsSigningIn, object: nil, queue: nil) { notification in - if let isSigningIn = notification.object as? Bool, !isSigningIn { - self.updateToken() + NotificationCenter.default + .addObserver(forName: NSNotification.Name.TPPIsSigningIn, object: nil, queue: nil) { notification in + if let isSigningIn = notification.object as? Bool, !isSigningIn { + self.updateToken() + } } - } } - + @objc static func sharedService() -> NotificationService { - return shared + shared } - + @objc /// Runs configuration function, registers the app for remote notifications. func setupPushNotifications(completion: ((_ granted: Bool) -> Void)? = nil) { notificationCenter.delegate = self - notificationCenter.requestAuthorization(options: [.alert, .badge, .sound]) { granted, error in + notificationCenter.requestAuthorization(options: [.alert, .badge, .sound]) { granted, _ in if granted { DispatchQueue.main.async { UIApplication.shared.registerForRemoteNotifications() @@ -68,7 +72,7 @@ class NotificationService: NSObject, UNUserNotificationCenterDelegate, Messaging } Messaging.messaging().delegate = self } - + func getNotificationStatus(completion: @escaping (_ areEnabled: Bool) -> Void) { notificationCenter.getNotificationSettings { notificationSettings in switch notificationSettings.authorizationStatus { @@ -77,7 +81,7 @@ class NotificationService: NSObject, UNUserNotificationCenterDelegate, Messaging } } } - + /// Check if token exists on the server /// - Parameters: /// - token: FCM token value @@ -94,7 +98,7 @@ class NotificationService: NSObject, UNUserNotificationCenterDelegate, Messaging return } let request = URLRequest(url: requestUrl, applyingCustomUserAgent: true) - _ = TPPNetworkExecutor.shared.addBearerAndExecute(request) { result, response, error in + _ = TPPNetworkExecutor.shared.addBearerAndExecute(request) { _, response, error in let status = (response as? HTTPURLResponse)?.statusCode // Token exists if status code is 200, doesn't exist if 404. switch status { @@ -104,7 +108,7 @@ class NotificationService: NSObject, UNUserNotificationCenterDelegate, Messaging } } } - + /// Save token to the server /// - Parameter token: FCM token value private func saveToken(_ token: String, endpointUrl: URL) { @@ -114,20 +118,21 @@ class NotificationService: NSObject, UNUserNotificationCenterDelegate, Messaging var request = URLRequest(url: endpointUrl, applyingCustomUserAgent: true) request.httpMethod = "PUT" request.httpBody = requestBody - _ = TPPNetworkExecutor.shared.addBearerAndExecute(request) { result, response, error in + _ = TPPNetworkExecutor.shared.addBearerAndExecute(request) { _, response, error in if let error = error { - TPPErrorLogger.logError(error, - summary: "Couldn't upload token data", - metadata: [ - "requestURL": endpointUrl, - "tokenData": String(data: requestBody, encoding: .utf8) ?? "", - "statusCode": (response as? HTTPURLResponse)?.statusCode ?? 0 - ] + TPPErrorLogger.logError( + error, + summary: "Couldn't upload token data", + metadata: [ + "requestURL": endpointUrl, + "tokenData": String(data: requestBody, encoding: .utf8) ?? "", + "statusCode": (response as? HTTPURLResponse)?.statusCode ?? 0 + ] ) } } } - + /// Sends FCM to the backend /// /// Update token when user account changes @@ -154,7 +159,7 @@ class NotificationService: NSObject, UNUserNotificationCenterDelegate, Messaging } } } - + private func deleteToken(_ token: String, endpointUrl: URL) { guard let requestBody = TokenData(token: token).data else { return @@ -162,15 +167,16 @@ class NotificationService: NSObject, UNUserNotificationCenterDelegate, Messaging var request = URLRequest(url: endpointUrl, applyingCustomUserAgent: true) request.httpMethod = "DELETE" request.httpBody = requestBody - _ = TPPNetworkExecutor.shared.addBearerAndExecute(request) { result, response, error in + _ = TPPNetworkExecutor.shared.addBearerAndExecute(request) { _, response, error in if let error = error { - TPPErrorLogger.logError(error, - summary: "Couldn't delete token data", - metadata: [ - "requestURL": endpointUrl, - "tokenData": String(data: requestBody, encoding: .utf8) ?? "", - "statusCode": (response as? HTTPURLResponse)?.statusCode ?? 0 - ] + TPPErrorLogger.logError( + error, + summary: "Couldn't delete token data", + metadata: [ + "requestURL": endpointUrl, + "tokenData": String(data: requestBody, encoding: .utf8) ?? "", + "statusCode": (response as? HTTPURLResponse)?.statusCode ?? 0 + ] ) } } @@ -190,27 +196,34 @@ class NotificationService: NSObject, UNUserNotificationCenterDelegate, Messaging } } } - + // MARK: - Messaging Delegate - + /// Notofies that the token is updated - public func messaging(_ messaging: Messaging, didReceiveRegistrationToken fcmToken: String?) { + public func messaging(_: Messaging, didReceiveRegistrationToken _: String?) { updateToken() } - // MARK: - Notification Center Delegate Methods - + /// Called when the app is in foreground - func userNotificationCenter(_ center: UNUserNotificationCenter, willPresent notification: UNNotification, withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) { + func userNotificationCenter( + _: UNUserNotificationCenter, + willPresent _: UNNotification, + withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void + ) { // Shows notification banner on screen completionHandler([.banner, .badge, .sound]) // Update loans TPPBookRegistry.shared.sync() } - + /// Called when the user responded to the notification by opening the application, dismissing the notification or choosing a UNNotificationAction - func userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse, withCompletionHandler completionHandler: @escaping () -> Void) { + func userNotificationCenter( + _: UNUserNotificationCenter, + didReceive _: UNNotificationResponse, + withCompletionHandler completionHandler: @escaping () -> Void + ) { completionHandler() // Update loans TPPBookRegistry.shared.sync() diff --git a/Palace/OPDS2/OPDS2AuthenticationDocument.swift b/Palace/OPDS2/OPDS2AuthenticationDocument.swift index 36bb5a4de..4aaf9f3bc 100644 --- a/Palace/OPDS2/OPDS2AuthenticationDocument.swift +++ b/Palace/OPDS2/OPDS2AuthenticationDocument.swift @@ -8,21 +8,27 @@ import Foundation +// MARK: - OPDS2LinkRel + enum OPDS2LinkRel: String { case passwordReset = "http://librarysimplified.org/terms/rel/patron-password-reset" } +// MARK: - Announcement + struct Announcement: Codable { let id: String let content: String } +// MARK: - OPDS2AuthenticationDocument + struct OPDS2AuthenticationDocument: Codable { struct Features: Codable { let disabled: [String]? let enabled: [String]? } - + struct Authentication: Codable { struct Inputs: Codable { struct Input: Codable { @@ -30,23 +36,23 @@ struct OPDS2AuthenticationDocument: Codable { let maximumLength: UInt? let keyboard: String // TODO: Use enum instead (or not; it could break if new values are added) } - + let login: Input let password: Input } - + struct Labels: Codable { let login: String let password: String } - + let inputs: Inputs? let labels: Labels? let type: String let description: String? let links: [OPDS2Link]? } - + let features: Features? let links: [OPDS2Link]? let title: String diff --git a/Palace/OPDS2/OPDS2CatalogsFeed.swift b/Palace/OPDS2/OPDS2CatalogsFeed.swift index 3a5e27dee..c4b1cb015 100644 --- a/Palace/OPDS2/OPDS2CatalogsFeed.swift +++ b/Palace/OPDS2/OPDS2CatalogsFeed.swift @@ -13,27 +13,27 @@ struct OPDS2CatalogsFeed: Codable { let adobe_vendor_id: String? let title: String } - + let catalogs: [OPDS2Publication] let links: [OPDS2Link] let metadata: Metadata - + static func fromData(_ data: Data) throws -> OPDS2CatalogsFeed { enum DateError: String, Error { case invalidDate } - + let jsonDecoder = JSONDecoder() - + let formatter = DateFormatter() formatter.calendar = Calendar(identifier: .iso8601) formatter.locale = Locale(identifier: "en_US_POSIX") formatter.timeZone = TimeZone(secondsFromGMT: 0) - - jsonDecoder.dateDecodingStrategy = .custom({ (decoder) -> Date in + + jsonDecoder.dateDecodingStrategy = .custom { decoder -> Date in let container = try decoder.singleValueContainer() let dateStr = try container.decode(String.self) - + formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSSXXXXX" if let date = formatter.date(from: dateStr) { return date @@ -43,8 +43,8 @@ struct OPDS2CatalogsFeed: Codable { return date } throw DateError.invalidDate - }) - + } + return try jsonDecoder.decode(OPDS2CatalogsFeed.self, from: data) } } diff --git a/Palace/OPDS2/OPDS2Link.swift b/Palace/OPDS2/OPDS2Link.swift index aaca343c4..f118cd244 100644 --- a/Palace/OPDS2/OPDS2Link.swift +++ b/Palace/OPDS2/OPDS2Link.swift @@ -8,6 +8,8 @@ import Foundation +// MARK: - OPDS2Link + struct OPDS2Link: Codable { let href: String let type: String? @@ -18,6 +20,8 @@ struct OPDS2Link: Codable { let descriptions: [OPDS2InternationalVariable]? } +// MARK: - OPDS2InternationalVariable + struct OPDS2InternationalVariable: Codable { let language: String let value: String diff --git a/Palace/OPDS2/OPDS2LinkArray.swift b/Palace/OPDS2/OPDS2LinkArray.swift index 35318f8e3..78a656aae 100644 --- a/Palace/OPDS2/OPDS2LinkArray.swift +++ b/Palace/OPDS2/OPDS2LinkArray.swift @@ -13,9 +13,9 @@ extension Array where Element == OPDS2Link { /// - Parameter rel: `rel` attribute value /// - Returns: Links with the specified `rel` attribute value func all(rel: OPDS2LinkRel) -> [Element] { - self.filter { $0.rel == rel.rawValue } + filter { $0.rel == rel.rawValue } } - + /// Returns the first link with the specified `rel` attribute /// - Parameter rel: `rel` attribute value /// - Returns: The first link with the specified `rel` attribute; `nil` if none found. diff --git a/Palace/OPDS2/OPDS2Publication.swift b/Palace/OPDS2/OPDS2Publication.swift index 9e8f66f98..8538df9de 100644 --- a/Palace/OPDS2/OPDS2Publication.swift +++ b/Palace/OPDS2/OPDS2Publication.swift @@ -8,6 +8,8 @@ import Foundation +// MARK: - OPDS2Publication + struct OPDS2Publication: Codable { struct Metadata: Codable { let updated: Date @@ -15,7 +17,7 @@ struct OPDS2Publication: Codable { let id: String let title: String } - + let links: [OPDS2Link] let metadata: Metadata let images: [OPDS2Link]? @@ -30,7 +32,7 @@ extension OPDS2Publication { } return URL(string: image.href) } - + var thumbnailURL: URL? { guard let thumbnail = images?.first(where: { $0.type == imageType && ($0.rel ?? "").contains("thumbnail") }) else { return nil @@ -45,4 +47,3 @@ extension OPDS2Publication { return URL(string: cover.href) } } - diff --git a/Palace/OnboardingScreens/OnboardingCoordinator.swift b/Palace/OnboardingScreens/OnboardingCoordinator.swift index 3a0df6e63..f694d4da8 100644 --- a/Palace/OnboardingScreens/OnboardingCoordinator.swift +++ b/Palace/OnboardingScreens/OnboardingCoordinator.swift @@ -1,15 +1,19 @@ -import UIKit import SwiftUI +import UIKit final class OnboardingCoordinator { static let shared = OnboardingCoordinator() private init() {} func startIfNeeded(from appDelegate: TPPAppDelegate) { - guard shouldRunOnboarding else { return } + guard shouldRunOnboarding else { + return + } presentOnboarding(from: appDelegate) { [weak self, weak appDelegate] in - guard let self, let appDelegate else { return } - self.presentAccountList(from: appDelegate) + guard let self, let appDelegate else { + return + } + presentAccountList(from: appDelegate) TPPSettings.shared.userHasSeenWelcomeScreen = true } } @@ -22,13 +26,17 @@ final class OnboardingCoordinator { private func presentOnboarding(from appDelegate: TPPAppDelegate, completion: @escaping () -> Void) { let vc = TPPOnboardingViewController.makeSwiftUIView(dismissHandler: completion) - guard let top = appDelegate.topViewController() else { return } + guard let top = appDelegate.topViewController() else { + return + } top.present(vc, animated: true) } private func presentAccountList(from appDelegate: TPPAppDelegate) { let presentList: () -> Void = { [weak appDelegate] in - guard let top = appDelegate?.topViewController() else { return } + guard let top = appDelegate?.topViewController() else { + return + } let accountList = TPPAccountList { account in MyBooksViewModel().authenticateAndLoad(account: account) } @@ -46,5 +54,3 @@ final class OnboardingCoordinator { } } } - - diff --git a/Palace/OnboardingScreens/TPPOnboardingView.swift b/Palace/OnboardingScreens/TPPOnboardingView.swift index af0cbc078..608f422ee 100644 --- a/Palace/OnboardingScreens/TPPOnboardingView.swift +++ b/Palace/OnboardingScreens/TPPOnboardingView.swift @@ -9,15 +9,14 @@ import SwiftUI struct TPPOnboardingView: View { - // 2 x pan distance to switch between slides // (relative to screen width) private var activationDistance: CGFloat = 0.8 - + private var onboardingImageNames = ["Onboarding-1", "Onboarding-2", "Onboarding-3"] @GestureState private var translation: CGFloat = 0 - + @State private var currentIndex = 0 { didSet { // Dismiss the view after the user swipes past the last slide. @@ -26,14 +25,14 @@ struct TPPOnboardingView: View { } } } - + // dismiss handler - var dismissView: (() -> Void) - + var dismissView: () -> Void + init(dismissHandler: @escaping (() -> Void)) { - self.dismissView = dismissHandler + dismissView = dismissHandler } - + var body: some View { ZStack(alignment: .top) { onboardingSlides() @@ -43,7 +42,7 @@ struct TPPOnboardingView: View { .edgesIgnoringSafeArea(.all) .statusBar(hidden: true) } - + @ViewBuilder private func onboardingSlides() -> some View { GeometryReader { geometry in @@ -62,7 +61,7 @@ struct TPPOnboardingView: View { .animation(.interactiveSpring(), value: currentIndex) .gesture( DragGesture() - .updating($translation) { value, state, _translation in + .updating($translation) { value, state, _ in state = value.translation.width } .onEnded { value in @@ -78,7 +77,7 @@ struct TPPOnboardingView: View { Color(UIColor(named: "OnboardingBackground") ?? .systemBackground) ) } - + @ViewBuilder private func pagerDots() -> some View { VStack { @@ -87,7 +86,7 @@ struct TPPOnboardingView: View { .padding() } } - + @ViewBuilder private func closeButton() -> some View { HStack { diff --git a/Palace/OnboardingScreens/TPPOnboardingViewController.swift b/Palace/OnboardingScreens/TPPOnboardingViewController.swift index d86ef890d..87b831d38 100644 --- a/Palace/OnboardingScreens/TPPOnboardingViewController.swift +++ b/Palace/OnboardingScreens/TPPOnboardingViewController.swift @@ -6,8 +6,8 @@ // Copyright © 2021 The Palace Project. All rights reserved. // -import UIKit import SwiftUI +import UIKit class TPPOnboardingViewController: NSObject { @objc static func makeSwiftUIView(dismissHandler: @escaping (() -> Void)) -> UIViewController { @@ -16,4 +16,3 @@ class TPPOnboardingViewController: NSObject { return controller } } - diff --git a/Palace/OnboardingScreens/TPPPagerDotsView.swift b/Palace/OnboardingScreens/TPPPagerDotsView.swift index 562ef3eaa..2fe118c86 100644 --- a/Palace/OnboardingScreens/TPPPagerDotsView.swift +++ b/Palace/OnboardingScreens/TPPPagerDotsView.swift @@ -8,6 +8,8 @@ import SwiftUI +// MARK: - TPPPagerDotsView + struct TPPPagerDotsView: View { /// Number of dots to show var count: Int @@ -24,8 +26,10 @@ struct TPPPagerDotsView: View { } } +// MARK: - TPPPagerDotsView_Previews + struct TPPPagerDotsView_Previews: PreviewProvider { - static var previews: some View { - TPPPagerDotsView(count: 5, currentIndex: .constant(2)) - } + static var previews: some View { + TPPPagerDotsView(count: 5, currentIndex: .constant(2)) + } } diff --git a/Palace/PDF/Extensions/Binding+onChange.swift b/Palace/PDF/Extensions/Binding+onChange.swift index 9fded7c3e..135f78557 100644 --- a/Palace/PDF/Extensions/Binding+onChange.swift +++ b/Palace/PDF/Extensions/Binding+onChange.swift @@ -14,12 +14,13 @@ extension Binding { /// - Returns: Binding with the provided handler /// /// This is a workaround for iOS versions prior to 14, where SwiftUI doesn't have `.onChange` modifier - func onChange(_ handler: @escaping (Value) -> Void) -> Binding { - return Binding( - get: { self.wrappedValue }, - set: { newValue in - self.wrappedValue = newValue - handler(newValue) - }) - } + func onChange(_ handler: @escaping (Value) -> Void) -> Binding { + Binding( + get: { self.wrappedValue }, + set: { newValue in + self.wrappedValue = newValue + handler(newValue) + } + ) + } } diff --git a/Palace/PDF/Extensions/CGPDFPage+previews.swift b/Palace/PDF/Extensions/CGPDFPage+previews.swift index 335bf5abf..cf734287e 100644 --- a/Palace/PDF/Extensions/CGPDFPage+previews.swift +++ b/Palace/PDF/Extensions/CGPDFPage+previews.swift @@ -20,34 +20,34 @@ extension CGPDFPage { pageRect.origin = .zero pageRect.size = CGSize(width: pageRect.size.width * pdfScale, height: pageRect.size.height * pdfScale) } - - UIGraphicsBeginImageContext(pageRect.size); - + + UIGraphicsBeginImageContext(pageRect.size) + guard let context = UIGraphicsGetCurrentContext() else { return nil } - + context.setFillColor(UIColor.white.cgColor) context.fill(pageRect) context.saveGState() - + context.translateBy(x: 0, y: pageRect.size.height) context.scaleBy(x: 1, y: -1) context.scaleBy(x: pdfScale, y: pdfScale) context.drawPDFPage(self) context.restoreGState() - + let image = UIGraphicsGetImageFromCurrentImageContext() - + UIGraphicsEndImageContext() return image } - + /// Thumbnail image of the page var thumbnail: UIImage? { image(of: .pdfThumbnailSize, for: .mediaBox) } - + /// Preview image of the page var preview: UIImage? { image(of: .pdfPreviewSize, for: .mediaBox) diff --git a/Palace/PDF/Extensions/CGSize.swift b/Palace/PDF/Extensions/CGSize.swift index fa99c4685..df864209c 100644 --- a/Palace/PDF/Extensions/CGSize.swift +++ b/Palace/PDF/Extensions/CGSize.swift @@ -13,6 +13,7 @@ extension CGSize { static var pdfThumbnailSize: CGSize { CGSize(width: 30, height: 30) } + /// Preview image size for PDF viewer static var pdfPreviewSize: CGSize { CGSize(width: 300, height: 300) diff --git a/Palace/PDF/Extensions/DispatchQueue.swift b/Palace/PDF/Extensions/DispatchQueue.swift index fd607cdb3..c3855eae1 100644 --- a/Palace/PDF/Extensions/DispatchQueue.swift +++ b/Palace/PDF/Extensions/DispatchQueue.swift @@ -13,9 +13,9 @@ extension DispatchQueue { static var pdfThumbnailRenderingQueue: DispatchQueue { DispatchQueue(label: "org.thepalaceproject.palace.thumbnailRenderingQueue", qos: .userInitiated) } + /// Dispatch queue for image rendering static var pdfImageRenderingQueue: DispatchQueue { DispatchQueue(label: "org.thepalaceproject.palace.imageRenderingQueue", qos: .userInitiated) } - } diff --git a/Palace/PDF/Extensions/TPPBookLocation+pageNumber.swift b/Palace/PDF/Extensions/TPPBookLocation+pageNumber.swift index ac1ab9579..0c0d3a037 100644 --- a/Palace/PDF/Extensions/TPPBookLocation+pageNumber.swift +++ b/Palace/PDF/Extensions/TPPBookLocation+pageNumber.swift @@ -9,11 +9,11 @@ import Foundation extension TPPBookLocation { - /// Page number in `TPPBookLocation` object var pageNumber: Int? { guard let locationData = locationString.data(using: .utf8), - let locationPage = try? JSONDecoder().decode(TPPPDFPage.self, from: locationData) else { + let locationPage = try? JSONDecoder().decode(TPPPDFPage.self, from: locationData) + else { return nil } return locationPage.pageNumber diff --git a/Palace/PDF/Extensions/TPPPDFPage+serialization.swift b/Palace/PDF/Extensions/TPPPDFPage+serialization.swift index 17f345a38..3623def0f 100644 --- a/Palace/PDF/Extensions/TPPPDFPage+serialization.swift +++ b/Palace/PDF/Extensions/TPPPDFPage+serialization.swift @@ -9,7 +9,6 @@ import Foundation extension TPPPDFPage { - /// Location string for `TPPBookLocation` object var locationString: String? { guard let jsonData = try? JSONEncoder().encode(self), @@ -19,11 +18,13 @@ extension TPPPDFPage { } return jsonString } - + /// Bookmark selector for reading position synchronization var bookmarkSelector: String? { let bookmark = TPPPDFPageBookmark(page: pageNumber) - guard let jsonData = try? JSONEncoder().encode(bookmark), let jsonString = String(data: jsonData, encoding: .utf8) else { + guard let jsonData = try? JSONEncoder().encode(bookmark), + let jsonString = String(data: jsonData, encoding: .utf8) + else { return nil } return jsonString diff --git a/Palace/PDF/Extensions/UIHostingController+Extensions.swift b/Palace/PDF/Extensions/UIHostingController+Extensions.swift index a51bb8686..62c0ebe0a 100644 --- a/Palace/PDF/Extensions/UIHostingController+Extensions.swift +++ b/Palace/PDF/Extensions/UIHostingController+Extensions.swift @@ -6,9 +6,9 @@ // Copyright © 2023 The Palace Project. All rights reserved. // +import Foundation import SwiftUI import UIKit -import Foundation extension UIHostingController { /// Initializes a hosting controller with the option to ignore safe area. @@ -21,9 +21,11 @@ extension UIHostingController { /// Dynamically subclasses the view to override safe area insets and keyboard handling. func disableSafeArea() { - guard let originalClass = object_getClass(view) else { return } + guard let originalClass = object_getClass(view) else { + return + } let subclassedName = "\(String(describing: originalClass))_IgnoreSafeArea" - + // If subclass doesn't exist, create it if let existingClass = NSClassFromString(subclassedName) { object_setClass(view, existingClass) @@ -31,27 +33,43 @@ extension UIHostingController { createAndRegisterSubclass(originalClass: originalClass, subclassedName: subclassedName) } } - + private func createAndRegisterSubclass(originalClass: AnyClass, subclassedName: String) { - guard let subclass = objc_allocateClassPair(originalClass, subclassedName, 0) else { return } - + guard let subclass = objc_allocateClassPair(originalClass, subclassedName, 0) else { + return + } + overrideSafeAreaInsets(for: subclass) overrideKeyboardHandling(for: subclass) - + objc_registerClassPair(subclass) object_setClass(view, subclass) } - + private func overrideSafeAreaInsets(for subclass: AnyClass) { let safeAreaOverride: @convention(block) (AnyObject) -> UIEdgeInsets = { _ in .zero } - guard let method = class_getInstanceMethod(UIView.self, #selector(getter: UIView.safeAreaInsets)) else { return } - class_addMethod(subclass, #selector(getter: UIView.safeAreaInsets), imp_implementationWithBlock(safeAreaOverride), method_getTypeEncoding(method)) + guard let method = class_getInstanceMethod(UIView.self, #selector(getter: UIView.safeAreaInsets)) else { + return + } + class_addMethod( + subclass, + #selector(getter: UIView.safeAreaInsets), + imp_implementationWithBlock(safeAreaOverride), + method_getTypeEncoding(method) + ) } - + private func overrideKeyboardHandling(for subclass: AnyClass) { let keyboardOverride: @convention(block) (AnyObject, AnyObject) -> Void = { _, _ in } let keyboardSelector = NSSelectorFromString("keyboardWillShowWithNotification:") - guard let method = class_getInstanceMethod(subclass, keyboardSelector) else { return } - class_addMethod(subclass, keyboardSelector, imp_implementationWithBlock(keyboardOverride), method_getTypeEncoding(method)) + guard let method = class_getInstanceMethod(subclass, keyboardSelector) else { + return + } + class_addMethod( + subclass, + keyboardSelector, + imp_implementationWithBlock(keyboardOverride), + method_getTypeEncoding(method) + ) } } diff --git a/Palace/PDF/Extensions/UIImage.swift b/Palace/PDF/Extensions/UIImage.swift index 5c860743c..18de52264 100644 --- a/Palace/PDF/Extensions/UIImage.swift +++ b/Palace/PDF/Extensions/UIImage.swift @@ -9,7 +9,6 @@ import Foundation extension UIImage { - /// Create an image /// - Parameters: /// - color: Color of the image @@ -21,8 +20,9 @@ extension UIImage { UIRectFill(rect) let image = UIGraphicsGetImageFromCurrentImageContext() UIGraphicsEndImageContext() - guard let cgImage = image?.cgImage else { return nil } + guard let cgImage = image?.cgImage else { + return nil + } self.init(cgImage: cgImage) } - } diff --git a/Palace/PDF/Extensions/View.swift b/Palace/PDF/Extensions/View.swift index 751193a6f..f3854d2d1 100644 --- a/Palace/PDF/Extensions/View.swift +++ b/Palace/PDF/Extensions/View.swift @@ -8,9 +8,11 @@ import SwiftUI +// MARK: - SizePreferenceKey + struct SizePreferenceKey: PreferenceKey { static var defaultValue: CGSize = .zero - static func reduce(value: inout CGSize, nextValue: () -> CGSize) {} + static func reduce(value _: inout CGSize, nextValue _: () -> CGSize) {} } extension View { @@ -37,7 +39,7 @@ extension View { func visible(when: Bool) -> some View { opacity(when ? 1 : 0) } - + /// Minimal size for toolbar buttons func toolbarButtonSize() -> some View { frame(minWidth: 24, minHeight: 24) @@ -47,17 +49,19 @@ extension View { extension View { /// A convenience method for applying `TouchDownUpEventModifier.` func onTouchDownUp(pressed: @escaping ((Bool, DragGesture.Value) -> Void)) -> some View { - self.modifier(TouchDownUpEventModifier(pressed: pressed)) + modifier(TouchDownUpEventModifier(pressed: pressed)) } } +// MARK: - TouchDownUpEventModifier + struct TouchDownUpEventModifier: ViewModifier { /// Keep track of the current dragging state. To avoid using `onChange`, we won't use `GestureState` @State var dragged = false - + /// A closure to call when the dragging state changes. var pressed: (Bool, DragGesture.Value) -> Void - + func body(content: Content) -> some View { content .gesture( diff --git a/Palace/PDF/LCP/LCPPDFs.swift b/Palace/PDF/LCP/LCPPDFs.swift index 3c84f3e54..b2e74603e 100644 --- a/Palace/PDF/LCP/LCPPDFs.swift +++ b/Palace/PDF/LCP/LCPPDFs.swift @@ -9,18 +9,18 @@ #if LCP import Foundation +import ReadiumLCP import ReadiumShared import ReadiumStreamer -import ReadiumLCP import ReadiumZIPFoundation /// LCP PDF helper class @objc class LCPPDFs: NSObject { - struct PDFManifest: Codable { struct ReadingOrderItem: Codable { let href: String } + let readingOrder: [ReadingOrderItem] } @@ -30,7 +30,9 @@ import ReadiumZIPFoundation /// - Parameter book: pdf /// - Returns: `true` if the book is an LCP DRM protected PDF, `false` otherwise @objc static func canOpenBook(_ book: TPPBook) -> Bool { - guard let defualtAcquisition = book.defaultAcquisition else { return false } + guard let defualtAcquisition = book.defaultAcquisition else { + return false + } return book.defaultBookContentType == .pdf && defualtAcquisition.type == expectedAcquisitionType } @@ -44,11 +46,11 @@ import ReadiumZIPFoundation TPPErrorLogger.logError(nil, summary: "Uninitialized contentProtection in LCPPDFs") return nil } - self.pdfUrl = url + pdfUrl = url let httpClient = DefaultHTTPClient() - self.assetRetriever = AssetRetriever(httpClient: httpClient) - self.publicationOpener = PublicationOpener( + assetRetriever = AssetRetriever(httpClient: httpClient) + publicationOpener = PublicationOpener( parser: DefaultPublicationParser( httpClient: httpClient, assetRetriever: assetRetriever, @@ -62,34 +64,42 @@ import ReadiumZIPFoundation private func getPdfHref() async throws -> String { let manifestPath = "manifest.json" - guard let fileUrl = FileURL(url: self.pdfUrl) else { + guard let fileUrl = FileURL(url: pdfUrl) else { throw NSError(domain: "Palace.LCPPDFs", code: 0, userInfo: [NSLocalizedDescriptionKey: "Invalid file URL"]) } let assetResult = await assetRetriever.retrieve(url: fileUrl) switch assetResult { - case .success(let asset): + case let .success(asset): let result = await publicationOpener.open(asset: asset, allowUserInteraction: false, sender: nil) switch result { - case .success(let publication): + case let .success(publication): do { guard let resource = publication.getResource(at: manifestPath) else { - throw NSError(domain: "Palace.LCPPDFs", code: 0, userInfo: [NSLocalizedDescriptionKey: "Manifest resource not found"]) + throw NSError( + domain: "Palace.LCPPDFs", + code: 0, + userInfo: [NSLocalizedDescriptionKey: "Manifest resource not found"] + ) } let resourceResult = await resource.readAsJSONObject() switch resourceResult { - case .success(let jsonObject): + case let .success(jsonObject): let jsonData = try JSONSerialization.data(withJSONObject: jsonObject) let pdfManifest = try JSONDecoder().decode(PDFManifest.self, from: jsonData) guard let pdfHref = pdfManifest.readingOrder.first?.href else { - throw NSError(domain: "Palace.LCPPDFs", code: 0, userInfo: [NSLocalizedDescriptionKey: "Missing PDF href in manifest"]) + throw NSError( + domain: "Palace.LCPPDFs", + code: 0, + userInfo: [NSLocalizedDescriptionKey: "Missing PDF href in manifest"] + ) } return pdfHref - case .failure(let error): + case let .failure(error): throw error } } catch { @@ -100,14 +110,22 @@ import ReadiumZIPFoundation ]) } - case .failure(let error): + case let .failure(error): TPPErrorLogger.logError(error, summary: "Failed to open LCP PDF") - throw NSError(domain: "Palace.LCPPDFs", code: -1, userInfo: [NSLocalizedDescriptionKey: error.localizedDescription]) + throw NSError( + domain: "Palace.LCPPDFs", + code: -1, + userInfo: [NSLocalizedDescriptionKey: error.localizedDescription] + ) } - case .failure(let error): + case let .failure(error): TPPErrorLogger.logError(error, summary: "Failed to retrieve LCP PDF asset") - throw NSError(domain: "Palace.LCPPDFs", code: -1, userInfo: [NSLocalizedDescriptionKey: error.localizedDescription]) + throw NSError( + domain: "Palace.LCPPDFs", + code: -1, + userInfo: [NSLocalizedDescriptionKey: error.localizedDescription] + ) } } @@ -179,15 +197,15 @@ import ReadiumZIPFoundation private func decryptRawData(data encryptedData: Data, start: Int, end: Int) -> Data? { autoreleasepool { let aesBlockSize = 4096 // should be a multiple of 16; smaller and larger block sizes slow reading down - let paddingSize = 16 // AES padding size; lcpService cuts it off - let paddingData = Data(Array(repeating: 0, count: paddingSize)) - let blockStart = (start / aesBlockSize) * aesBlockSize // align to aesBlockSize - let blockEnd = (end / aesBlockSize + ( end % aesBlockSize == 0 ? 0 : 1 )) * aesBlockSize + let paddingSize = 16 // AES padding size; lcpService cuts it off + let paddingData = Data([UInt8](repeating: 0, count: paddingSize)) + let blockStart = (start / aesBlockSize) * aesBlockSize // align to aesBlockSize + let blockEnd = (end / aesBlockSize + (end % aesBlockSize == 0 ? 0 : 1)) * aesBlockSize let range = blockStart..() /// PDF document data. @@ -27,11 +28,11 @@ import UIKit var cover: UIImage? init(encryptedData: Data, decryptor: @escaping (_ data: Data, _ start: UInt, _ end: UInt) -> Data) { - self.data = encryptedData + data = encryptedData self.decryptor = decryptor let pdfDataProvider = TPPEncryptedPDFDataProvider(data: encryptedData, decryptor: decryptor) let dataProvider = pdfDataProvider.dataProvider().takeUnretainedValue() - self.document = CGPDFDocument(dataProvider) + document = CGPDFDocument(dataProvider) super.init() setPageCount() @@ -54,7 +55,6 @@ import UIKit func setCover() { Task { self.cover = try? await document?.cover() ?? UIImage() - } } @@ -72,7 +72,9 @@ import UIKit if self.thumbnailsCache.object(forKey: pageNumber) != nil { continue } - if let thumbnail = self.thumbnail(for: page), let thumbnailData = thumbnail.jpegData(compressionQuality: 0.5) { + if let thumbnail = self.thumbnail(for: page), + let thumbnailData = thumbnail.jpegData(compressionQuality: 0.5) + { DispatchQueue.main.async { self.thumbnailsCache.setObject(thumbnailData as NSData, forKey: pageNumber) } @@ -101,22 +103,26 @@ import UIKit for (i, textBlock) in lowercaseBlocks.enumerated() { if textBlock.contains(searchText) { // ! CGPDF first page index is 1, that's why we subtract 1 from pageNumber - result.append(TPPPDFLocation(title: textBlocks[i], subtitle: nil, pageLabel: nil, pageNumber: page.pageNumber - 1)) + result.append(TPPPDFLocation( + title: textBlocks[i], + subtitle: nil, + pageLabel: nil, + pageNumber: page.pageNumber - 1 + )) } } } return result - } - } extension TPPEncryptedPDFDocument { func encryptedData() -> Data { - self.data + data } + func decrypt(data: Data, start: UInt, end: UInt) -> Data { - return self.decryptor(data, start, end) + decryptor(data, start, end) } } @@ -139,7 +145,9 @@ extension TPPEncryptedPDFDocument { /// This function caches thumbnail image data and returnes a cached image when one is available. func thumbnail(for page: Int) -> UIImage? { let pageNumber = NSNumber(value: page) - if let cachedData = thumbnailsCache.object(forKey: pageNumber), let cachedImage = UIImage(data: cachedData as Data) { + if let cachedData = thumbnailsCache.object(forKey: pageNumber), + let cachedImage = UIImage(data: cachedData as Data) + { return cachedImage } else { if let image = self.page(at: page)?.thumbnail, let data = image.jpegData(compressionQuality: 0.5) { @@ -158,7 +166,9 @@ extension TPPEncryptedPDFDocument { /// This function doesn't render new thumbnail images. func cachedThumbnail(for page: Int) -> UIImage? { let pageNumber = NSNumber(value: page) - if let cachedData = thumbnailsCache.object(forKey: pageNumber), let cachedImage = UIImage(data: cachedData as Data) { + if let cachedData = thumbnailsCache.object(forKey: pageNumber), + let cachedImage = UIImage(data: cachedData as Data) + { return cachedImage } return nil @@ -177,7 +187,7 @@ extension TPPEncryptedPDFDocument { extension TPPEncryptedPDFDocument { /// `TPPEncryptedPDFDocument` for SwiftUI previews static var preview: TPPEncryptedPDFDocument { - TPPEncryptedPDFDocument(encryptedData: Data()) { data, start, end in + TPPEncryptedPDFDocument(encryptedData: Data()) { data, _, _ in data } } diff --git a/Palace/PDF/Model/TPPPDFDocument.swift b/Palace/PDF/Model/TPPPDFDocument.swift index b04fa3770..a600a1ed5 100644 --- a/Palace/PDF/Model/TPPPDFDocument.swift +++ b/Palace/PDF/Model/TPPPDFDocument.swift @@ -9,37 +9,41 @@ import Foundation import PDFKit +// MARK: - TPPPDFDocumentDelegate + /// Search delegate protocol TPPPDFDocumentDelegate { func didMatchString(_ instance: TPPPDFLocation) } +// MARK: - TPPPDFDocument + /// Wrapper class for PDF docuument in general @objcMembers class TPPPDFDocument: NSObject { let data: Data let decryptor: ((_ data: Data, _ start: UInt, _ end: UInt) -> Data)? let isEncrypted: Bool - + var delegate: TPPPDFDocumentDelegate? - + /// Initialize with a non-encrypted document /// - Parameter data: PDF document data init(data: Data) { self.data = data - self.decryptor = nil - self.isEncrypted = false + decryptor = nil + isEncrypted = false } - + /// Initialize with an encrypted PDF document data /// - Parameters: /// - encryptedData: Encrypted PDF document data /// - decryptor: Decryptor function init(encryptedData: Data, decryptor: @escaping (_ data: Data, _ start: UInt, _ end: UInt) -> Data) { - self.data = encryptedData + data = encryptedData self.decryptor = decryptor - self.isEncrypted = true + isEncrypted = true } - + /// Encrypted PDF document lazy var encryptedDocument: TPPEncryptedPDFDocument? = { guard let decryptor = decryptor, isEncrypted else { @@ -47,7 +51,7 @@ protocol TPPPDFDocumentDelegate { } return TPPEncryptedPDFDocument(encryptedData: data, decryptor: decryptor) }() - + /// PDFKit PDF document lazy var document: PDFDocument? = { guard !isEncrypted else { @@ -60,14 +64,13 @@ protocol TPPPDFDocumentDelegate { // MARK: - Common properties of encrypted and non-encrypted PDF files extension TPPPDFDocument { - /// PDF title var title: String? { get async { if isEncrypted { - return encryptedDocument?.title + encryptedDocument?.title } else { - return (try? await document?.title()) ?? nil + (try? await document?.title()) } } } @@ -81,12 +84,12 @@ extension TPPPDFDocument { func decrypt(data: Data, start: UInt, end: UInt) -> Data { decryptor?(data, start, end) ?? data } - + /// Number of pages in the PDF document var pageCount: Int { (isEncrypted ? encryptedDocument?.pageCount : document?.pageCount) ?? 0 } - + /// Preview image for a page /// - Parameter page: Page number /// - Returns: Rendered page image @@ -103,10 +106,10 @@ extension TPPPDFDocument { /// `thumbnail` returns a smaller image than `preview` func thumbnail(for page: Int) -> UIImage? { isEncrypted ? - encryptedDocument?.thumbnail(for: page) : - image(page: page, size: .pdfThumbnailSize) + encryptedDocument?.thumbnail(for: page) : + image(page: page, size: .pdfThumbnailSize) } - + /// Image for a page /// - Parameters: /// - page: Page number @@ -114,28 +117,28 @@ extension TPPPDFDocument { /// - Returns: Rendered page image func image(page: Int, size: CGSize) -> UIImage? { isEncrypted ? - encryptedDocument?.page(at: page)?.image(of: size, for: .mediaBox) : - document?.page(at: page)?.thumbnail(of: size, for: .mediaBox) + encryptedDocument?.page(at: page)?.image(of: size, for: .mediaBox) : + document?.page(at: page)?.thumbnail(of: size, for: .mediaBox) } - + /// Page size /// - Parameter page: Page number /// - Returns: Size of the page func size(page: Int) -> CGSize? { isEncrypted ? - encryptedDocument?.page(at: page)?.getBoxRect(.mediaBox).size : - document?.page(at: page)?.bounds(for: .mediaBox).size + encryptedDocument?.page(at: page)?.getBoxRect(.mediaBox).size : + document?.page(at: page)?.bounds(for: .mediaBox).size } - + /// Page label /// - Parameter page: Page number /// - Returns: Page label func label(page: Int) -> String? { isEncrypted ? - encryptedDocument?.page(at: page)?.pageNumber.description : - document?.page(at: page)?.label + encryptedDocument?.page(at: page)?.pageNumber.description : + document?.page(at: page)?.label } - + /// Search the document /// - Parameter text: Text string to look for /// - Returns: Array of PDF locations @@ -184,29 +187,41 @@ extension TPPPDFDocument { ) } } - + /// Unfolds all outline levels into a flat array with `level` parameter for depth level information /// - Parameters: /// - element: `PDFOutline` element /// - level: depth level /// - Returns: `(Int, PDFOutline)` for (depth level, outline element) private func outlineItems(in element: PDFOutline, level: Int = 0) -> [(Int, PDFOutline)] { - [(level, element)] + (0..() - + /// Current page number. @Published var currentPage: Int - + @Published var remotePage: Int? - + private var currentPageCancellable: AnyCancellable? - + private var pdfBookmarks: [TPPPDFPageBookmark]? { didSet { bookmarks = localBookmarks.union(remoteBookmarks) } } - + /// Bookmark page numbers on the remote server private var remoteBookmarks: Set { Set( - (pdfBookmarks ?? []).map { $0.page } + (pdfBookmarks ?? []).map(\.page) ) } - + /// Bookmark page numbers of bookmarks stored in the book registry private var localBookmarks: Set { Set( TPPBookRegistry.shared.genericBookmarksForIdentifier(book.identifier) - .compactMap { $0.pageNumber } + .compactMap(\.pageNumber) ) } - + /// Returns `true` if current account allows synchronisation private var canSync: Bool { TPPAnnotations.syncIsPossibleAndPermitted() } - + /// Initializes metadata. /// - Parameter bookIdentifier: PDF book identifier string. /// @@ -73,7 +73,7 @@ import Combine self.setCurrentPage(value) } } - + /// Set current page in the book registry. /// - Parameter pageNumber: PDF page number. /// @@ -87,12 +87,16 @@ import Combine Log.error(#file, "Error creating and saving PDF Page Location") return } - TPPBookRegistry.shared.setLocation(location, forIdentifier: self.bookIdentifier) + TPPBookRegistry.shared.setLocation(location, forIdentifier: bookIdentifier) if canSync { - TPPAnnotations.postReadingPosition(forBook: bookIdentifier, selectorValue: bookmarkSelector, motivation: .readingProgress) + TPPAnnotations.postReadingPosition( + forBook: bookIdentifier, + selectorValue: bookmarkSelector, + motivation: .readingProgress + ) } } - + /// Fetch reading position stored on the server. func fetchReadingPosition() { guard canSync, let url = TPPAnnotations.annotationsURL else { @@ -116,7 +120,7 @@ import Combine } currentPage = remotePage } - + /// Fetch bookmarks from the server. func fetchBookmarks() { guard canSync, let url = book.annotationsURL ?? TPPAnnotations.annotationsURL else { @@ -130,25 +134,31 @@ import Combine } } } - + /// Add bookmark for the book to the book registry. /// - Parameter pageNumber: PDF page number, `nil` adds current page. func addBookmark(at pageNumber: Int? = nil) { let page = TPPPDFPage(pageNumber: pageNumber ?? currentPage) bookmarks.insert(page.pageNumber) - if let locationString = page.locationString, let location = TPPBookLocation(locationString: locationString, renderer: rendererString) { + if let locationString = page.locationString, let location = TPPBookLocation( + locationString: locationString, + renderer: rendererString + ) { TPPBookRegistry.shared.addGenericBookmark(location, forIdentifier: bookIdentifier) } if canSync { - TPPAnnotations.postBookmark(page, annotationsURL: book.annotationsURL ?? TPPAnnotations.annotationsURL, forBookID: bookIdentifier) - { response in + TPPAnnotations.postBookmark( + page, + annotationsURL: book.annotationsURL ?? TPPAnnotations.annotationsURL, + forBookID: bookIdentifier + ) { response in DispatchQueue.main.async { self.pdfBookmarks?.append(TPPPDFPageBookmark(page: page.pageNumber, annotationID: response?.serverId)) } } } } - + /// Remove bookmark from the book registry. /// - Parameter pageNumber: PDF page number, `nil` removes current page. func removeBookmark(at pageNumber: Int? = nil) { @@ -161,16 +171,17 @@ import Combine } if canSync, let bookmark = pdfBookmarks?.first(where: { page.pageNumber == $0.page }), - let annotationId = bookmark.annotationID { + let annotationId = bookmark.annotationID + { // Remove on the server - TPPAnnotations.deleteBookmark(annotationId: annotationId) { success in + TPPAnnotations.deleteBookmark(annotationId: annotationId) { _ in DispatchQueue.main.async { self.pdfBookmarks?.removeAll(where: { page.pageNumber == $0.page }) } } } } - + /// Checks if page is bookmarked. /// - Parameter page: PDF page number, `nil` check if current page is in bookmarks. /// - Returns: `true` of the page is bookmarked, `false` otherwise. diff --git a/Palace/PDF/Model/TPPPDFLocation.swift b/Palace/PDF/Model/TPPPDFLocation.swift index a8ec0f027..4eb841d8c 100644 --- a/Palace/PDF/Model/TPPPDFLocation.swift +++ b/Palace/PDF/Model/TPPPDFLocation.swift @@ -8,6 +8,8 @@ import SwiftUI +// MARK: - TPPPDFLocation + /// TOC and search location struct TPPPDFLocation { let title: String? @@ -15,7 +17,7 @@ struct TPPPDFLocation { let pageLabel: String? let pageNumber: Int let level: Int - + init(title: String?, subtitle: String?, pageLabel: String?, pageNumber: Int, level: Int = 0) { self.title = title self.subtitle = subtitle @@ -25,6 +27,8 @@ struct TPPPDFLocation { } } +// MARK: Identifiable + extension TPPPDFLocation: Identifiable { var id: String { let t = title ?? "" diff --git a/Palace/PDF/Model/TPPPDFPageBookmark.swift b/Palace/PDF/Model/TPPPDFPageBookmark.swift index 4ea59f116..d2e2c76ac 100644 --- a/Palace/PDF/Model/TPPPDFPageBookmark.swift +++ b/Palace/PDF/Model/TPPPDFPageBookmark.swift @@ -13,18 +13,18 @@ import Foundation let type: String let page: Int var annotationID: String? - + enum CodingKeys: String, CodingKey { case type = "@type" case page } - + init(page: Int, annotationID: String? = nil) { - self.type = Types.locatorPage.rawValue + type = Types.locatorPage.rawValue self.page = page self.annotationID = annotationID } - + enum Types: String { case locatorPage = "LocatorPage" } diff --git a/Palace/PDF/Model/TPPPDFReaderMode.swift b/Palace/PDF/Model/TPPPDFReaderMode.swift index df5c01009..57d8673ee 100644 --- a/Palace/PDF/Model/TPPPDFReaderMode.swift +++ b/Palace/PDF/Model/TPPPDFReaderMode.swift @@ -12,15 +12,19 @@ import Foundation /// /// Used to determine current reader mode in `TPPDFNavigation`, and view roles enum TPPPDFReaderMode { - case reader, previews, bookmarks, toc, search - + case reader + case previews + case bookmarks + case toc + case search + var value: String { switch self { - case .reader: return "Reader" - case .previews: return "Page previews" - case .bookmarks: return "Bookmarks" - case .toc: return "TOC" - case .search: return "Search" + case .reader: "Reader" + case .previews: "Page previews" + case .bookmarks: "Bookmarks" + case .toc: "TOC" + case .search: "Search" } } } diff --git a/Palace/PDF/Model/TPPPDFTextExtractor.swift b/Palace/PDF/Model/TPPPDFTextExtractor.swift index 6371d31fe..8e1befd9e 100644 --- a/Palace/PDF/Model/TPPPDFTextExtractor.swift +++ b/Palace/PDF/Model/TPPPDFTextExtractor.swift @@ -22,29 +22,33 @@ class TPPPDFTextExtractor { // https://opensource.adobe.com/dc-acrobat-sdk-docs/pdfstandards/pdfreference1.3.pdf // "TJ" operator: an array of blocks (strings, numbers, etc) CGPDFOperatorTableSetCallback(operatorTable!, "TJ") { scanner, context in - guard let context = context else { return } + guard let context = context else { + return + } let extractor = Unmanaged.fromOpaque(context).takeUnretainedValue() extractor.handleArray(scanner: scanner) } // String operators for op in ["Tj", "\"", "'"] { CGPDFOperatorTableSetCallback(operatorTable!, op) { scanner, context in - guard let context = context else { return } + guard let context = context else { + return + } let extractor = Unmanaged.fromOpaque(context).takeUnretainedValue() extractor.handleString(scanner: scanner) } } let scanner = CGPDFScannerCreate(stream, operatorTable, Unmanaged.passUnretained(self).toOpaque()) CGPDFScannerScan(scanner) - + // Release resources CGPDFScannerRelease(scanner) CGPDFOperatorTableRelease(operatorTable!) CGPDFContentStreamRelease(stream) - + return textBlocks } - + /// String operator handler /// - Parameter scanner: `CGPDFScannerRef` private func handleString(scanner: CGPDFScannerRef) { @@ -57,26 +61,32 @@ class TPPPDFTextExtractor { } } } - + /// Array operator handler /// - Parameter scanner: `CGPDFScannerRef` private func handleArray(scanner: CGPDFScannerRef) { var array: CGPDFArrayRef? - guard CGPDFScannerPopArray(scanner, &array), let array else { return } - + guard CGPDFScannerPopArray(scanner, &array), let array else { + return + } + var blockValue = "" // Iterate through the array elements let count = CGPDFArrayGetCount(array) for index in 0.. 1.0 else { + guard let maxScale = scrollView?.maximumZoomScale, maxScale > 1.0 else { return } - let viewSize = self.view.bounds.size + let viewSize = view.bounds.size let imageSize = CGSize(width: viewSize.width * maxScale, height: viewSize.height * maxScale) DispatchQueue.pdfImageRenderingQueue.async { if let pageImage = self.document.image(for: self.pageNumber, size: imageSize) { @@ -147,15 +152,16 @@ class TPPEncryptedPDFPageViewController: UIViewController { } } } - } +// MARK: UIScrollViewDelegate + extension TPPEncryptedPDFPageViewController: UIScrollViewDelegate { - func viewForZooming(in scrollView: UIScrollView) -> UIView? { - return imageView + func viewForZooming(in _: UIScrollView) -> UIView? { + imageView } - - func scrollViewDidEndZooming(_ scrollView: UIScrollView, with view: UIView?, atScale scale: CGFloat) { + + func scrollViewDidEndZooming(_: UIScrollView, with _: UIView?, atScale _: CGFloat) { renderZoomedPageImage() } } diff --git a/Palace/PDF/Views/TPPEncryptedPDFView.swift b/Palace/PDF/Views/TPPEncryptedPDFView.swift index fc658fdb5..27644acda 100644 --- a/Palace/PDF/Views/TPPEncryptedPDFView.swift +++ b/Palace/PDF/Views/TPPEncryptedPDFView.swift @@ -11,17 +11,20 @@ import SwiftUI /// This view shows encrypted PDF documents. /// The analog for non-encrypted documents - `TPPPDFView` struct TPPEncryptedPDFView: View { - let encryptedPDF: TPPEncryptedPDFDocument - + @EnvironmentObject var metadata: TPPPDFDocumentMetadata @State private var showingDocumentInfo = true - + var body: some View { ZStack { - TPPEncryptedPDFViewer(encryptedPDF: encryptedPDF, currentPage: $metadata.currentPage, showingDocumentInfo: $showingDocumentInfo) - .edgesIgnoringSafeArea([.all]) + TPPEncryptedPDFViewer( + encryptedPDF: encryptedPDF, + currentPage: $metadata.currentPage, + showingDocumentInfo: $showingDocumentInfo + ) + .edgesIgnoringSafeArea([.all]) VStack { TPPPDFLabel(encryptedPDF.title ?? metadata.book.title) .padding(.top) diff --git a/Palace/PDF/Views/TPPEncryptedPDFViewController.swift b/Palace/PDF/Views/TPPEncryptedPDFViewController.swift index 438739e67..1e8e3556a 100644 --- a/Palace/PDF/Views/TPPEncryptedPDFViewController.swift +++ b/Palace/PDF/Views/TPPEncryptedPDFViewController.swift @@ -8,30 +8,31 @@ import UIKit +// MARK: - TPPEncryptedPDFViewController + /// Encrypted PDF view controller class TPPEncryptedPDFViewController: UIPageViewController { - var document: TPPEncryptedPDFDocument var pageCount: Int = 0 var currentPage: Int = 0 - + func navigate(to page: Int) { - let direction: NavigationDirection = page < currentPage ? .reverse : .forward + let direction: NavigationDirection = page < currentPage ? .reverse : .forward currentPage = min(pageCount - 1, max(0, page)) setViewControllers([pageViewController(page: currentPage)], direction: direction, animated: false, completion: nil) } - + @available(*, unavailable) - required init?(coder: NSCoder) { + required init?(coder _: NSCoder) { fatalError("init(coder:) has not been implemented") } init(encryptedPDF: TPPEncryptedPDFDocument) { - self.document = encryptedPDF - self.pageCount = encryptedPDF.pageCount + document = encryptedPDF + pageCount = encryptedPDF.pageCount super.init(transitionStyle: .scroll, navigationOrientation: .horizontal, options: [.interPageSpacing: 20]) } - + override func viewDidLoad() { view.backgroundColor = .secondarySystemBackground dataSource = self @@ -39,24 +40,32 @@ class TPPEncryptedPDFViewController: UIPageViewController { setViewControllers([pageViewController(page: currentPage)], direction: .forward, animated: false) super.viewDidLoad() } - + func pageViewController(page: Int = 0) -> UIViewController { - return TPPEncryptedPDFPageViewController(encryptedPdf: document, pageNumber: page) + TPPEncryptedPDFPageViewController(encryptedPdf: document, pageNumber: page) } - } +// MARK: UIPageViewControllerDataSource + extension TPPEncryptedPDFViewController: UIPageViewControllerDataSource { - func pageViewController(_ pageViewController: UIPageViewController, viewControllerBefore viewController: UIViewController) -> UIViewController? { + func pageViewController( + _: UIPageViewController, + viewControllerBefore viewController: UIViewController + ) -> UIViewController? { guard let pageVC = viewController as? TPPEncryptedPDFPageViewController, pageVC.pageNumber > 0 else { return nil } - return self.pageViewController(page: pageVC.pageNumber - 1) + return pageViewController(page: pageVC.pageNumber - 1) } - func pageViewController(_ pageViewController: UIPageViewController, viewControllerAfter viewController: UIViewController) -> UIViewController? { + + func pageViewController( + _: UIPageViewController, + viewControllerAfter viewController: UIViewController + ) -> UIViewController? { guard let pageVC = viewController as? TPPEncryptedPDFPageViewController, pageVC.pageNumber < (pageCount - 1) else { return nil } - return self.pageViewController(page: pageVC.pageNumber + 1) + return pageViewController(page: pageVC.pageNumber + 1) } } diff --git a/Palace/PDF/Views/TPPEncryptedPDFViewer.swift b/Palace/PDF/Views/TPPEncryptedPDFViewer.swift index 9a9836a13..725a96f51 100644 --- a/Palace/PDF/Views/TPPEncryptedPDFViewer.swift +++ b/Palace/PDF/Views/TPPEncryptedPDFViewer.swift @@ -12,20 +12,19 @@ import SwiftUI /// Plays the same role as PDFKit's `PDFView` — displays a PDF page, performs swipe navigation between pages. /// Wraps `TPPEncryptedPDFViewController` — `UIPageViewController` struct TPPEncryptedPDFViewer: UIViewControllerRepresentable { - let encryptedPDF: TPPEncryptedPDFDocument - + @Binding var currentPage: Int @Binding var showingDocumentInfo: Bool - + func makeUIViewController(context: Context) -> some UIViewController { let vc = TPPEncryptedPDFViewController(encryptedPDF: encryptedPDF) vc.currentPage = currentPage vc.delegate = context.coordinator return vc } - - func updateUIViewController(_ uiViewController: UIViewControllerType, context: Context) { + + func updateUIViewController(_ uiViewController: UIViewControllerType, context _: Context) { guard let vc = uiViewController as? TPPEncryptedPDFViewController else { return } @@ -33,22 +32,29 @@ struct TPPEncryptedPDFViewer: UIViewControllerRepresentable { vc.navigate(to: currentPage) } } - + func makeCoordinator() -> Coordinator { - return Coordinator(currentPage: $currentPage, showingDocumentInfo: $showingDocumentInfo) + Coordinator(currentPage: $currentPage, showingDocumentInfo: $showingDocumentInfo) } - + class Coordinator: NSObject, UIPageViewControllerDelegate { @Binding var currentPage: Int @Binding var showingDocumentInfo: Bool - + init(currentPage: Binding, showingDocumentInfo: Binding) { - self._currentPage = currentPage - self._showingDocumentInfo = showingDocumentInfo + _currentPage = currentPage + _showingDocumentInfo = showingDocumentInfo } - - func pageViewController(_ pageViewController: UIPageViewController, didFinishAnimating finished: Bool, previousViewControllers: [UIViewController], transitionCompleted completed: Bool) { - guard let pageVC = pageViewController as? TPPEncryptedPDFViewController, let vc = pageViewController.viewControllers?.last as? TPPEncryptedPDFPageViewController else { + + func pageViewController( + _ pageViewController: UIPageViewController, + didFinishAnimating _: Bool, + previousViewControllers _: [UIViewController], + transitionCompleted _: Bool + ) { + guard let pageVC = pageViewController as? TPPEncryptedPDFViewController, + let vc = pageViewController.viewControllers?.last as? TPPEncryptedPDFPageViewController + else { return } if currentPage != vc.pageNumber { @@ -59,5 +65,3 @@ struct TPPEncryptedPDFViewer: UIViewControllerRepresentable { } } } - - diff --git a/Palace/PDF/Views/TPPPDFBackButton.swift b/Palace/PDF/Views/TPPPDFBackButton.swift index ea60379e7..abdb5fed9 100644 --- a/Palace/PDF/Views/TPPPDFBackButton.swift +++ b/Palace/PDF/Views/TPPPDFBackButton.swift @@ -8,11 +8,12 @@ import SwiftUI +// MARK: - TPPPDFBackButton + /// Preconfigured back button view struct TPPPDFBackButton: View { - let action: () -> Void - + var body: some View { Button(action: action) { Image(systemName: "chevron.left") @@ -22,10 +23,10 @@ struct TPPPDFBackButton: View { } } +// MARK: - TPPPDFBackButton_Previews + struct TPPPDFBackButton_Previews: PreviewProvider { static var previews: some View { - TPPPDFBackButton { - - } + TPPPDFBackButton {} } } diff --git a/Palace/PDF/Views/TPPPDFDocumentView.swift b/Palace/PDF/Views/TPPPDFDocumentView.swift index c2519f633..0f7331292 100644 --- a/Palace/PDF/Views/TPPPDFDocumentView.swift +++ b/Palace/PDF/Views/TPPPDFDocumentView.swift @@ -6,22 +6,21 @@ // Copyright © 2022 The Palace Project. All rights reserved. // -import SwiftUI -import PDFKit import Combine +import PDFKit +import SwiftUI /// Wraps PDFKit PDFView control struct TPPPDFDocumentView: UIViewRepresentable { - var document: PDFDocument var pdfView: PDFView @Binding var showingDocumentInfo: Bool @Binding var isTracking: Bool - + @EnvironmentObject var metadata: TPPPDFDocumentMetadata private let pdfViewGestureRecognizer = PDFViewGestureRecognizer() - + func makeUIView(context: Context) -> some UIView { pdfView.autoScales = true pdfView.displayMode = .singlePage @@ -33,28 +32,27 @@ struct TPPPDFDocumentView: UIViewRepresentable { pdfView.go(to: page) } pdfView.delegate = context.coordinator - - - pdfViewGestureRecognizer.onTouchEnded({ touches in - if let touchPoint = touches.first?.location(in: self.pdfView) { - let elementTapped = self.pdfView.areaOfInterest(for: touchPoint) + + pdfViewGestureRecognizer.onTouchEnded { touches in + if let touchPoint = touches.first?.location(in: pdfView) { + let elementTapped = pdfView.areaOfInterest(for: touchPoint) // If the tapped element is not interactive, change bar visibility if elementTapped.intersection([.linkArea, .controlArea, .popupArea, .textFieldArea]).isEmpty { showingDocumentInfo.toggle() } } - }) - + } + pdfViewGestureRecognizer.onTrackingChanged { value in isTracking = value } - + pdfView.addGestureRecognizer(pdfViewGestureRecognizer) return pdfView } - - func updateUIView(_ uiView: UIViewType, context: Context) { + + func updateUIView(_ uiView: UIViewType, context _: Context) { guard let pdfView = uiView as? PDFView, let page = pdfView.currentPage, let pageIndex = pdfView.document?.index(for: page) @@ -65,58 +63,57 @@ struct TPPPDFDocumentView: UIViewRepresentable { pdfView.go(to: page) } } - + func makeCoordinator() -> Coordinator { - return Coordinator(currentPage: $metadata.currentPage) + Coordinator(currentPage: $metadata.currentPage) } - + class Coordinator: NSObject, PDFViewDelegate { @Binding var currentPage: Int - + init(currentPage: Binding) { - self._currentPage = currentPage + _currentPage = currentPage } - + func pdfViewPerformGo(toPage sender: PDFView) { if let page = sender.currentPage, let pageIndex = sender.document?.index(for: page) { currentPage = pageIndex } } } - + class PDFViewGestureRecognizer: UIGestureRecognizer { var isTracking = false { didSet { - self.trackingChanged?(isTracking) + trackingChanged?(isTracking) } } - + private var touchCompletion: ((_ touches: Set) -> Void)? private var trackingChanged: ((_ value: Bool) -> Void)? - + func onTouchEnded(_ touchCompleted: @escaping (_ touches: Set) -> Void) { - self.touchCompletion = touchCompleted + touchCompletion = touchCompleted } - + func onTrackingChanged(_ action: @escaping (_ value: Bool) -> Void) { - self.trackingChanged = action + trackingChanged = action } - + override func touchesBegan(_ touches: Set, with event: UIEvent) { super.touchesBegan(touches, with: event) isTracking = true } - + override func touchesEnded(_ touches: Set, with event: UIEvent) { super.touchesEnded(touches, with: event) isTracking = false touchCompletion?(touches) } - + override func touchesCancelled(_ touches: Set, with event: UIEvent) { super.touchesCancelled(touches, with: event) isTracking = false } } - } diff --git a/Palace/PDF/Views/TPPPDFLabel.swift b/Palace/PDF/Views/TPPPDFLabel.swift index 80beb843f..6fa444081 100644 --- a/Palace/PDF/Views/TPPPDFLabel.swift +++ b/Palace/PDF/Views/TPPPDFLabel.swift @@ -6,20 +6,19 @@ // Copyright © 2022 The Palace Project. All rights reserved. // -import SwiftUI import PalaceUIKit +import SwiftUI /// Floating label /// /// PDF name, page number struct TPPPDFLabel: View { - let text: String - + init(_ text: String) { self.text = text } - + var body: some View { Text(text) .palaceFont(.subheadline, weight: .semibold) diff --git a/Palace/PDF/Views/TPPPDFLocationView.swift b/Palace/PDF/Views/TPPPDFLocationView.swift index fba27858a..778f4a609 100644 --- a/Palace/PDF/Views/TPPPDFLocationView.swift +++ b/Palace/PDF/Views/TPPPDFLocationView.swift @@ -6,23 +6,25 @@ // Copyright © 2022 The Palace Project. All rights reserved. // -import SwiftUI import PalaceUIKit +import SwiftUI + +// MARK: - TPPPDFLocationView /// PDF page location view struct TPPPDFLocationView: View { let location: TPPPDFLocation var emphasizeLevel: Int = -1 - + init(location: TPPPDFLocation) { self.location = location } - + init(location: TPPPDFLocation, emphasizeLevel: Int) { self.location = location self.emphasizeLevel = emphasizeLevel } - + var body: some View { HStack(alignment: .center) { VStack(alignment: .leading, spacing: 4) { @@ -42,11 +44,16 @@ struct TPPPDFLocationView: View { } } +// MARK: - TPPPDFLocationView_Previews + struct TPPPDFLocationView_Previews: PreviewProvider { - static var previews: some View { - List { - TPPPDFLocationView(location: .init(title: "Chapter 1", subtitle: "Subtitle", pageLabel: "xi", pageNumber: 11), emphasizeLevel: 1) - TPPPDFLocationView(location: .init(title: "Chapter 1", subtitle: "Subtitle", pageLabel: "xi", pageNumber: 11)) - } + static var previews: some View { + List { + TPPPDFLocationView( + location: .init(title: "Chapter 1", subtitle: "Subtitle", pageLabel: "xi", pageNumber: 11), + emphasizeLevel: 1 + ) + TPPPDFLocationView(location: .init(title: "Chapter 1", subtitle: "Subtitle", pageLabel: "xi", pageNumber: 11)) } + } } diff --git a/Palace/PDF/Views/TPPPDFNavigation.swift b/Palace/PDF/Views/TPPPDFNavigation.swift index 85f8e0757..2d78c501b 100644 --- a/Palace/PDF/Views/TPPPDFNavigation.swift +++ b/Palace/PDF/Views/TPPPDFNavigation.swift @@ -10,54 +10,56 @@ import SwiftUI /// Navigation between previews, TOC and bookmarks struct TPPPDFNavigation: View where Content: View { - private enum TPPPDFReaderModeValues: Int, Identifiable { - case previews, toc, bookmarks - + case previews + case toc + case bookmarks + var id: Int { rawValue } - + var image: Image { switch self { - case .previews: return Image(systemName: "rectangle.grid.3x2") - case .toc: return Image(systemName: "list.bullet") - case .bookmarks: return Image(systemName: "bookmark") + case .previews: Image(systemName: "rectangle.grid.3x2") + case .toc: Image(systemName: "list.bullet") + case .bookmarks: Image(systemName: "bookmark") } } - + static var allValues: [TPPPDFReaderModeValues] { - return [.previews, .toc, .bookmarks] + [.previews, .toc, .bookmarks] } - + var readerMode: TPPPDFReaderMode { switch self { - case .previews: return .previews - case .toc: return .toc - case .bookmarks: return .bookmarks + case .previews: .previews + case .toc: .toc + case .bookmarks: .bookmarks } } } - + @EnvironmentObject var metadata: TPPPDFDocumentMetadata - + @Environment(\.presentationMode) var presentationMode: Binding - + @Binding var readerMode: TPPPDFReaderMode private var isShowingPdfContorls: Bool { readerMode == .previews || readerMode == .bookmarks || readerMode == .toc } + @State private var pickerSelection = 0 let content: (TPPPDFReaderMode) -> Content - + var body: some View { content(readerMode) .navigationBarItems(leading: leadingItems, trailing: trailingItems) } - + private let minButtonSize = CGSize(width: 24, height: 24) - + @ViewBuilder var leadingItems: some View { HStack { @@ -100,7 +102,9 @@ struct TPPPDFNavigation: View where Content: View { metadata.addBookmark() } } - .accessibilityIdentifier(metadata.isBookmarked() ? Strings.TPPBaseReaderViewController.removeBookmark : Strings.TPPBaseReaderViewController.addBookmark) + .accessibilityIdentifier(metadata.isBookmarked() ? Strings.TPPBaseReaderViewController.removeBookmark : Strings + .TPPBaseReaderViewController.addBookmark + ) } .visible(when: !isShowingPdfContorls) } diff --git a/Palace/PDF/Views/TPPPDFPreviewBar.swift b/Palace/PDF/Views/TPPPDFPreviewBar.swift index 98b5f9d7a..4e4baaed9 100644 --- a/Palace/PDF/Views/TPPPDFPreviewBar.swift +++ b/Palace/PDF/Views/TPPPDFPreviewBar.swift @@ -11,33 +11,32 @@ import SwiftUI /// Bottom bar with page thumbnails /// Performs similar to PDFKit's `PDFThumbnails` view. struct TPPPDFPreviewBar: View { - private let barPreviewsHeight = 24.0 private let barPreviewsSpacing = 3.0 private let selectedPreviewHeight = 30.0 private var previewSize: CGSize { - let h = self.barPreviewsHeight + let h = barPreviewsHeight let w = h * 3 / 4 return CGSize(width: w, height: h) } - + private var selectedPreviewSize: CGSize { - let h = self.selectedPreviewHeight + let h = selectedPreviewHeight let w = h * 3 / 4 return CGSize(width: w, height: h) } - + let document: TPPEncryptedPDFDocument @Binding var currentPage: Int - + @State private var previewsAreaSize: CGSize = .zero @State private var previewsBarSize: CGSize = .zero @State private var touchLocation: CGPoint = .zero - + @State private var indices: [Int] = [] @State private var timer: Timer? - + var body: some View { VStack(alignment: .center) { Divider() @@ -87,18 +86,18 @@ struct TPPPDFPreviewBar: View { } .padding(.bottom, 6) } - + /// Debounce size updates /// - Parameters: /// - timeInterval: Delay before action /// - action: Action to perform - private func debounce(_ timeInterval: TimeInterval, action: @escaping () -> Void) { + private func debounce(_: TimeInterval, action: @escaping () -> Void) { timer?.invalidate() timer = Timer.scheduledTimer(withTimeInterval: 0.1, repeats: false) { _ in action() } } - + /// Array of visible page indices /// - Parameter size: Bar size /// - Returns: Array of indices @@ -111,7 +110,7 @@ struct TPPPDFPreviewBar: View { } return result } - + /// Page index for location point on the bar /// - Parameters: /// - location: Location point on the bar @@ -122,7 +121,7 @@ struct TPPPDFPreviewBar: View { let loc = max(0, min(rect.width, location.x)) return max(0, min(document.pageCount - 1, Int(numberOfPages * (loc / rect.width)))) } - + /// Offset for current page thumbnail /// - Parameter rect: Bar size /// - Returns: offset `CGSize` value for `.offset` view modifier @@ -133,5 +132,4 @@ struct TPPPDFPreviewBar: View { let h = rect.height / 2 - barPreviewsHeight / 2 return CGSize(width: w, height: h) } - } diff --git a/Palace/PDF/Views/TPPPDFPreviewGrid.swift b/Palace/PDF/Views/TPPPDFPreviewGrid.swift index 37b3efb43..0b4a99280 100644 --- a/Palace/PDF/Views/TPPPDFPreviewGrid.swift +++ b/Palace/PDF/Views/TPPPDFPreviewGrid.swift @@ -10,13 +10,13 @@ import SwiftUI import UIKit /// Previews and bookmarks. -/// Wraps `TPPPDFPreviewGridController` — `UICollectionViewController` +/// Wraps `TPPPDFPreviewGridController` — `UICollectionViewController` struct TPPPDFPreviewGrid: UIViewControllerRepresentable { let document: TPPPDFDocument var pageIndices: Set? var isVisible = false let done: () -> Void - + @EnvironmentObject var metadata: TPPPDFDocumentMetadata func makeUIViewController(context: Context) -> some UIViewController { @@ -27,7 +27,7 @@ struct TPPPDFPreviewGrid: UIViewControllerRepresentable { return vc } - func updateUIViewController(_ uiViewController: UIViewControllerType, context: Context) { + func updateUIViewController(_ uiViewController: UIViewControllerType, context _: Context) { guard let vc = uiViewController as? TPPPDFPreviewGridController else { return } @@ -49,9 +49,9 @@ struct TPPPDFPreviewGrid: UIViewControllerRepresentable { func didSelectPage(_ n: Int) { action(n) } - + init(changePageAction: @escaping (Int) -> Void) { - self.action = changePageAction + action = changePageAction } } } diff --git a/Palace/PDF/Views/TPPPDFPreviewGridCell.swift b/Palace/PDF/Views/TPPPDFPreviewGridCell.swift index d85da34ba..279f904dd 100644 --- a/Palace/PDF/Views/TPPPDFPreviewGridCell.swift +++ b/Palace/PDF/Views/TPPPDFPreviewGridCell.swift @@ -9,10 +9,9 @@ import Foundation class TPPPDFPreviewGridCell: UICollectionViewCell { - /// Page number for the page preview image var pageNumber: Int? - + var imageView: UIImageView = { let imageView = UIImageView() imageView.backgroundColor = .clear @@ -22,7 +21,7 @@ class TPPPDFPreviewGridCell: UICollectionViewCell { imageView.layer.shadowOpacity = 0.2 return imageView }() - + var pageLabel: UILabel = { let label = UILabel() label.font = UIFont.systemFont(ofSize: UIFont.smallSystemFontSize) @@ -32,7 +31,7 @@ class TPPPDFPreviewGridCell: UICollectionViewCell { }() @available(*, unavailable) - required init?(coder: NSCoder) { + required init?(coder _: NSCoder) { fatalError("init(coder:) has not been implemented") } @@ -40,7 +39,7 @@ class TPPPDFPreviewGridCell: UICollectionViewCell { super.init(frame: frame) addSubviews() } - + func addSubviews() { addSubview(imageView) addSubview(pageLabel) diff --git a/Palace/PDF/Views/TPPPDFPreviewGridController.swift b/Palace/PDF/Views/TPPPDFPreviewGridController.swift index 45cd60431..dda4dfacd 100644 --- a/Palace/PDF/Views/TPPPDFPreviewGridController.swift +++ b/Palace/PDF/Views/TPPPDFPreviewGridController.swift @@ -8,24 +8,25 @@ import UIKit +// MARK: - TPPPDFPreviewGridController + /// PDF preview grid. /// /// Shows page previews and bookmarks in `.pdf` files. class TPPPDFPreviewGridController: UICollectionViewController { - private let preferredPreviewWidth: CGFloat = 200 private let minimumItemsPerRow: Int = 3 - + /// Indices of pages to show in previews. var indices: [Int]? { didSet { collectionView.reloadData() } } - + /// Current page, collection view scrolls to that page in previews var currentPage: Int = 0 - + /// Defines if this view is currently visible in SwiftUI view /// /// Added to optimize data updates and generate page previews only when the view is visible to the user. @@ -34,24 +35,25 @@ class TPPPDFPreviewGridController: UICollectionViewController { scrollToCurrentItem() } } - + var delegate: TPPPDFPreviewGridDelegate? - + private var document: TPPPDFDocument? private let cellId = "cell" private let previewCache = NSCache() - + private func configurePreviewCache() { previewCache.totalCostLimit = 20 * 1024 * 1024 previewCache.countLimit = 50 } + private let itemSpacing = 10.0 @available(*, unavailable) - required init?(coder: NSCoder) { + required init?(coder _: NSCoder) { fatalError("init(coder:) has not been implemented") } - + /// Initializes PDF preview grid. /// - Parameters: /// - document: PDF document @@ -69,12 +71,12 @@ class TPPPDFPreviewGridController: UICollectionViewController { } return item } - + override func didReceiveMemoryWarning() { super.didReceiveMemoryWarning() previewCache.removeAllObjects() } - + override func viewDidLoad() { super.viewDidLoad() configurePreviewCache() @@ -85,34 +87,46 @@ class TPPPDFPreviewGridController: UICollectionViewController { collectionView.delegate = self collectionView.register(TPPPDFPreviewGridCell.self, forCellWithReuseIdentifier: cellId) collectionView.backgroundColor = .secondarySystemBackground - collectionView.contentInset = UIEdgeInsets(top: itemSpacing, left: itemSpacing, bottom: itemSpacing, right: itemSpacing) + collectionView.contentInset = UIEdgeInsets( + top: itemSpacing, + left: itemSpacing, + bottom: itemSpacing, + right: itemSpacing + ) view.addSubview(collectionView) } - + override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) if indices == nil && currentPage < (document?.pageCount ?? 0) { - collectionView.scrollToItem(at: IndexPath(item: currentPage, section: 0), at: .centeredVertically, animated: false) + collectionView.scrollToItem( + at: IndexPath(item: currentPage, section: 0), + at: .centeredVertically, + animated: false + ) } } - - override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) { + + override func viewWillTransition(to _: CGSize, with _: UIViewControllerTransitionCoordinator) { DispatchQueue.main.async { self.collectionView.collectionViewLayout.invalidateLayout() self.scrollToCurrentItem() } } - override func numberOfSections(in collectionView: UICollectionView) -> Int { - return 1 + override func numberOfSections(in _: UICollectionView) -> Int { + 1 } - - override func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int { - return indices?.count ?? document?.pageCount ?? 0 + + override func collectionView(_: UICollectionView, numberOfItemsInSection _: Int) -> Int { + indices?.count ?? document?.pageCount ?? 0 } - - override func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell { - let page = self.pageNumber(for: indexPath.item) + + override func collectionView( + _ collectionView: UICollectionView, + cellForItemAt indexPath: IndexPath + ) -> UICollectionViewCell { + let page = pageNumber(for: indexPath.item) let key = NSNumber(value: page) let cell = collectionView.dequeueReusableCell(withReuseIdentifier: cellId, for: indexPath) as! TPPPDFPreviewGridCell cell.pageNumber = page @@ -139,14 +153,16 @@ class TPPPDFPreviewGridController: UICollectionViewController { } return cell } - - override func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { + + override func collectionView(_: UICollectionView, didSelectItemAt indexPath: IndexPath) { let page = pageNumber(for: indexPath.item) delegate?.didSelectPage(page) } - + private func scrollToCurrentItem() { - guard isVisible else { return } + guard isVisible else { + return + } let totalItems = indices?.count ?? document?.pageCount ?? 0 guard currentPage >= 0, currentPage < totalItems else { @@ -158,12 +174,19 @@ class TPPPDFPreviewGridController: UICollectionViewController { } } +// MARK: UICollectionViewDelegateFlowLayout + extension TPPPDFPreviewGridController: UICollectionViewDelegateFlowLayout { - func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize { + func collectionView( + _ collectionView: UICollectionView, + layout collectionViewLayout: UICollectionViewLayout, + sizeForItemAt indexPath: IndexPath + ) -> CGSize { var itemsPerRow = minimumItemsPerRow let device = UIDevice.current if device.userInterfaceIdiom == .pad && - (device.orientation == .landscapeLeft || device.orientation == .landscapeRight) { + (device.orientation == .landscapeLeft || device.orientation == .landscapeRight) + { itemsPerRow = max(minimumItemsPerRow, Int(collectionView.bounds.width / preferredPreviewWidth)) } let contentWidth = collectionView.bounds.width @@ -176,13 +199,18 @@ extension TPPPDFPreviewGridController: UICollectionViewDelegateFlowLayout { } let width = (contentWidth - interitemSpace - itemSpacing) / CGFloat(itemsPerRow) var height = width * 1.5 - let pageNumber = self.pageNumber(for: indexPath.item) + let pageNumber = pageNumber(for: indexPath.item) if let pageSize = document?.size(page: pageNumber) { height = pageSize.height * (width / pageSize.width) } return CGSize(width: width, height: height) } - func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, minimumLineSpacingForSectionAt section: Int) -> CGFloat { + + func collectionView( + _: UICollectionView, + layout _: UICollectionViewLayout, + minimumLineSpacingForSectionAt _: Int + ) -> CGFloat { UIFont.smallSystemFontSize * 2 } } diff --git a/Palace/PDF/Views/TPPPDFPreviewThumbnail.swift b/Palace/PDF/Views/TPPPDFPreviewThumbnail.swift index ca0a7cad3..2b2e14027 100644 --- a/Palace/PDF/Views/TPPPDFPreviewThumbnail.swift +++ b/Palace/PDF/Views/TPPPDFPreviewThumbnail.swift @@ -10,19 +10,18 @@ import SwiftUI /// Single page thumbnail view struct TPPPDFPreviewThumbnail: View { - @ObservedObject var thumbnailGenerator: ThumbnailFetcher let document: TPPEncryptedPDFDocument let index: Int let size: CGSize - + init(document: TPPEncryptedPDFDocument, index: Int, size: CGSize) { self.document = document self.index = index self.size = size - self._thumbnailGenerator = ObservedObject(wrappedValue: ThumbnailFetcher(document: document, index: index)) + _thumbnailGenerator = ObservedObject(wrappedValue: ThumbnailFetcher(document: document, index: index)) } - + var body: some View { Image(uiImage: thumbnailGenerator.image) .resizable() @@ -31,7 +30,7 @@ struct TPPPDFPreviewThumbnail: View { .background(Color(UIColor.secondarySystemBackground)) .border(.gray) } - + /// Ths view needs an observable object to correctly update selected page thumbnail /// Without it the thimbnail will always contain the first assigned image /// It seems SwiftUI optimizes that @@ -43,13 +42,13 @@ struct TPPPDFPreviewThumbnail: View { self.document = document self.index = index if let cachedThumbnail = document.cachedThumbnail(for: index) { - self.image = cachedThumbnail + image = cachedThumbnail } else { - self.image = UIImage() + image = UIImage() fetchThumbnail() } } - + private func fetchThumbnail() { DispatchQueue.pdfThumbnailRenderingQueue.async { let thumbnail = self.document.thumbnail(for: self.index) @@ -61,5 +60,4 @@ struct TPPPDFPreviewThumbnail: View { } } } - } diff --git a/Palace/PDF/Views/TPPPDFReaderView.swift b/Palace/PDF/Views/TPPPDFReaderView.swift index 85461f437..2a4f85a1f 100644 --- a/Palace/PDF/Views/TPPPDFReaderView.swift +++ b/Palace/PDF/Views/TPPPDFReaderView.swift @@ -6,22 +6,19 @@ // Copyright © 2022 The Palace Project. All rights reserved. // -import SwiftUI import PalaceUIKit +import SwiftUI struct TPPPDFReaderView: View { - typealias DisplayStrings = Strings.TPPLastReadPositionSynchronizer - + @EnvironmentObject var metadata: TPPPDFDocumentMetadata @State private var readerMode: TPPPDFReaderMode = .reader @State private var shouldRequestPageSync = false - private var isShowingSearch: Bool { - get { readerMode == .search } - } + private var isShowingSearch: Bool { readerMode == .search } let document: TPPPDFDocument - + var body: some View { TPPPDFNavigation(readerMode: $readerMode) { _ in ZStack { @@ -29,14 +26,14 @@ struct TPPPDFReaderView: View { .onReceive(metadata.$remotePage, perform: showRemotePositionAlert) .visible(when: readerMode == .reader || readerMode == .search) .alert(isPresented: $shouldRequestPageSync) { - Alert(title: Text(DisplayStrings.syncReadingPositionAlertTitle), - message: Text(DisplayStrings.syncReadingPositionAlertBody), - primaryButton: .default(Text(DisplayStrings.move), action: metadata.syncReadingPosition), - secondaryButton: .cancel(Text(DisplayStrings.stay)) + Alert( + title: Text(DisplayStrings.syncReadingPositionAlertTitle), + message: Text(DisplayStrings.syncReadingPositionAlertBody), + primaryButton: .default(Text(DisplayStrings.move), action: metadata.syncReadingPosition), + secondaryButton: .cancel(Text(DisplayStrings.stay)) ) } - TPPPDFPreviewGrid(document: document, pageIndices: nil, isVisible: readerMode == .previews, done: done) .visible(when: readerMode == .previews) bookmarkView @@ -46,11 +43,11 @@ struct TPPPDFReaderView: View { } .sheet(isPresented: .constant(isShowingSearch)) { TPPPDFSearchView(document: document, done: done) - .environmentObject(metadata) + .environmentObject(metadata) } } } - + @ViewBuilder /// Document renderer var documentView: some View { @@ -72,25 +69,30 @@ struct TPPPDFReaderView: View { @ViewBuilder var bookmarkView: some View { if !metadata.bookmarks.isEmpty { - TPPPDFPreviewGrid(document: document, pageIndices: metadata.bookmarks, isVisible: readerMode == .bookmarks, done: done) - .visible(when: readerMode == .bookmarks) + TPPPDFPreviewGrid( + document: document, + pageIndices: metadata.bookmarks, + isVisible: readerMode == .bookmarks, + done: done + ) + .visible(when: readerMode == .bookmarks) } else { Text(NSLocalizedString("There are no bookmarks for this book.", comment: "")) .palaceFont(.body) } } - + @ViewBuilder var unableToLoadView: some View { Text("Unable to load PDF file") .palaceFont(.body) } - + /// Done picking a page func done() { readerMode = .reader } - + /// Present navigation alert func showRemotePositionAlert(_ value: Published.Publisher.Output) { if let value = value, metadata.currentPage != value { diff --git a/Palace/PDF/Views/TPPPDFSearchView.swift b/Palace/PDF/Views/TPPPDFSearchView.swift index 47651ec12..573a48253 100644 --- a/Palace/PDF/Views/TPPPDFSearchView.swift +++ b/Palace/PDF/Views/TPPPDFSearchView.swift @@ -6,24 +6,24 @@ // Copyright © 2022 The Palace Project. All rights reserved. // -import SwiftUI import PalaceUIKit +import SwiftUI struct TPPPDFSearchView: View { @StateObject var searchDelegate: SearchDelegate @EnvironmentObject var metadata: TPPPDFDocumentMetadata - + let document: TPPPDFDocument let done: () -> Void - + @State private var searchText = "" init(document: TPPPDFDocument, done: @escaping () -> Void) { self.document = document self.done = done - self._searchDelegate = StateObject(wrappedValue: SearchDelegate(document: document)) + _searchDelegate = StateObject(wrappedValue: SearchDelegate(document: document)) } - + var body: some View { VStack(spacing: 0) { HStack { @@ -53,33 +53,32 @@ struct TPPPDFSearchView: View { } } } - + func performSearch(string: String) { searchDelegate.search(text: string) } class SearchDelegate: ObservableObject, TPPPDFDocumentDelegate { - let document: TPPPDFDocument - + @Published var searchResults: [TPPPDFLocation] = [] init(document: TPPPDFDocument) { self.document = document self.document.delegate = self } - + func search(text: String) { searchResults = [] if text.count >= 3 { document.search(text: text) } } - + func cancelSearch() { document.cancelSearch() } - + func didMatchString(_ instance: TPPPDFLocation) { DispatchQueue.main.async { self.searchResults.append(instance) diff --git a/Palace/PDF/Views/TPPPDFTOCView.swift b/Palace/PDF/Views/TPPPDFTOCView.swift index 88db53d4e..6817d7313 100644 --- a/Palace/PDF/Views/TPPPDFTOCView.swift +++ b/Palace/PDF/Views/TPPPDFTOCView.swift @@ -10,7 +10,6 @@ import SwiftUI /// TOC View struct TPPPDFTOCView: View { - @EnvironmentObject var metadata: TPPPDFDocumentMetadata let document: TPPPDFDocument let done: () -> Void diff --git a/Palace/PDF/Views/TPPPDFThumbnailView.swift b/Palace/PDF/Views/TPPPDFThumbnailView.swift index 76f2b685c..7365db457 100644 --- a/Palace/PDF/Views/TPPPDFThumbnailView.swift +++ b/Palace/PDF/Views/TPPPDFThumbnailView.swift @@ -6,23 +6,22 @@ // Copyright © 2022 The Palace Project. All rights reserved. // -import SwiftUI import PDFKit +import SwiftUI /// Wraps PDFKit PDFThumbnails control struct TPPPDFThumbnailView: UIViewRepresentable { - var pdfView: PDFView - - func makeUIView(context: Context) -> some UIView { + + func makeUIView(context _: Context) -> some UIView { let view = PDFThumbnailView() view.pdfView = pdfView view.layoutMode = .horizontal view.backgroundColor = .systemBackground return view } - - func updateUIView(_ uiView: UIViewType, context: Context) { + + func updateUIView(_: UIViewType, context _: Context) { // } } diff --git a/Palace/PDF/Views/TPPPDFToolbarButton.swift b/Palace/PDF/Views/TPPPDFToolbarButton.swift index 2f25ff02e..2e3bc98a3 100644 --- a/Palace/PDF/Views/TPPPDFToolbarButton.swift +++ b/Palace/PDF/Views/TPPPDFToolbarButton.swift @@ -6,28 +6,29 @@ // Copyright © 2022 The Palace Project. All rights reserved. // -import SwiftUI import PalaceUIKit +import SwiftUI + +// MARK: - TPPPDFToolbarButton /// Preconfigured toolbar button view struct TPPPDFToolbarButton: View { - let action: () -> Void let image: Image? let text: String? - + init(icon: String, action: @escaping () -> Void) { self.action = action - self.image = Image(systemName: icon) - self.text = nil + image = Image(systemName: icon) + text = nil } - + init(text: String, action: @escaping () -> Void) { self.action = action - self.image = nil + image = nil self.text = text } - + var body: some View { Button(action: action) { if let image = image { @@ -42,6 +43,8 @@ struct TPPPDFToolbarButton: View { } } +// MARK: - ToolbarButton_Previews + struct ToolbarButton_Previews: PreviewProvider { static var previews: some View { TPPPDFToolbarButton(text: "Hello") { diff --git a/Palace/PDF/Views/TPPPDFView.swift b/Palace/PDF/Views/TPPPDFView.swift index 15dd1808e..fa9420862 100644 --- a/Palace/PDF/Views/TPPPDFView.swift +++ b/Palace/PDF/Views/TPPPDFView.swift @@ -6,15 +6,14 @@ // Copyright © 2022 The Palace Project. All rights reserved. // -import SwiftUI import PDFKit +import SwiftUI /// This view shows PDFKit views when PDF is not encrypted /// PDFKit reading controls (PDFView and PDFThumbnails) are generally faster because of direct data reading, /// instead of reading blocks of data with data provider. /// The analog for encrypted documents - `TPPEncryptedPDFView` struct TPPPDFView: View { - let document: PDFDocument let pdfView = PDFView() private let pageChangePublisher = NotificationCenter.default.publisher(for: .PDFViewPageChanged) @@ -27,14 +26,21 @@ struct TPPPDFView: View { var body: some View { ZStack { - TPPPDFDocumentView(document: document, pdfView: pdfView, showingDocumentInfo: $showingDocumentInfo, isTracking: $isTracking) - .edgesIgnoringSafeArea([.all]) + TPPPDFDocumentView( + document: document, + pdfView: pdfView, + showingDocumentInfo: $showingDocumentInfo, + isTracking: $isTracking + ) + .edgesIgnoringSafeArea([.all]) VStack { TPPPDFLabel(documentTitle) .padding(.top) Spacer() - if let pageLabel = document.page(at: metadata.currentPage)?.label, Int(pageLabel) != (metadata.currentPage + 1) { + if let pageLabel = document.page(at: metadata.currentPage)?.label, + Int(pageLabel) != (metadata.currentPage + 1) + { TPPPDFLabel("\(pageLabel) (\(metadata.currentPage + 1)/\(document.pageCount))") } else { TPPPDFLabel("\(metadata.currentPage + 1)/\(document.pageCount)") @@ -60,7 +66,9 @@ struct TPPPDFView: View { } } .onReceive(pageChangePublisher) { value in - if let pdfView = (value.object as? PDFView), let page = pdfView.currentPage, let pageIndex = pdfView.document?.index(for: page) { + if let pdfView = (value.object as? PDFView), let page = pdfView.currentPage, + let pageIndex = pdfView.document?.index(for: page) + { metadata.currentPage = pageIndex if isTracking { showingDocumentInfo = false diff --git a/Palace/PDF/Views/TPPPDFViewController.swift b/Palace/PDF/Views/TPPPDFViewController.swift index dbcd63180..e8c4a7d0c 100644 --- a/Palace/PDF/Views/TPPPDFViewController.swift +++ b/Palace/PDF/Views/TPPPDFViewController.swift @@ -10,15 +10,18 @@ import Foundation import SwiftUI /// Maximum file size the app can decrypt without crashing -fileprivate let supportedEncryptedDataSize = 200 * 1024 * 1024 +private let supportedEncryptedDataSize = 200 * 1024 * 1024 -class TPPPDFViewController: NSObject { +// MARK: - TPPPDFViewController +class TPPPDFViewController: NSObject { @objc static func create(document: TPPPDFDocument, metadata: TPPPDFDocumentMetadata) -> UIViewController { var controller: UIViewController! if document.isEncrypted && document.data.count < supportedEncryptedDataSize { let data = document.decrypt(data: document.data, start: 0, end: UInt(document.data.count)) - controller = UIHostingController(rootView: TPPPDFReaderView(document: TPPPDFDocument(data: data)).environmentObject(metadata)) + controller = UIHostingController(rootView: TPPPDFReaderView(document: TPPPDFDocument(data: data)) + .environmentObject(metadata) + ) } else { controller = UIHostingController(rootView: TPPPDFReaderView(document: document).environmentObject(metadata)) } @@ -26,5 +29,4 @@ class TPPPDFViewController: NSObject { controller.hidesBottomBarWhenPushed = true return controller } - } diff --git a/Palace/Reader2/Bookmarks/AudioBookmark.swift b/Palace/Reader2/Bookmarks/AudioBookmark.swift index 85a8dc000..4f8b2121c 100644 --- a/Palace/Reader2/Bookmarks/AudioBookmark.swift +++ b/Palace/Reader2/Bookmarks/AudioBookmark.swift @@ -8,11 +8,15 @@ import Foundation +// MARK: - BookmarkType + enum BookmarkType: String, Codable { case locatorAudioBookTime = "LocatorAudioBookTime" case locatorHrefProgression = "LocatorHrefProgression" } +// MARK: - AudioBookmark + @objc public class AudioBookmark: NSObject, Bookmark, Codable, NSCopying { let type: BookmarkType var annotationId: String @@ -25,11 +29,11 @@ enum BookmarkType: String, Codable { var title: String? var part: Int? var time: Int? - + var isUnsynced: Bool { annotationId.isEmpty } - + enum CodingKeys: String, CodingKey { case type = "@type" case timeStamp @@ -42,7 +46,7 @@ enum BookmarkType: String, Codable { case part case time } - + init( type: BookmarkType, version: Int = 2, @@ -56,7 +60,7 @@ enum BookmarkType: String, Codable { time: Int? = nil ) { self.type = type - self.lastSavedTimeStamp = timeStamp + lastSavedTimeStamp = timeStamp self.annotationId = annotationId self.version = version self.readingOrderItem = readingOrderItem @@ -66,22 +70,25 @@ enum BookmarkType: String, Codable { self.part = part self.time = time } - - required public init(from decoder: Decoder) throws { + + public required init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) type = try container.decode(BookmarkType.self, forKey: .type) lastSavedTimeStamp = try container.decodeIfPresent(String.self, forKey: .timeStamp) annotationId = try container.decodeIfPresent(String.self, forKey: .annotationId) ?? "" version = try container.decodeIfPresent(Int.self, forKey: .version) ?? 1 - + readingOrderItem = try container.decodeIfPresent(String.self, forKey: .readingOrderItem) - readingOrderItemOffsetMilliseconds = try container.decodeIfPresent(Int.self, forKey: .readingOrderItemOffsetMilliseconds) + readingOrderItemOffsetMilliseconds = try container.decodeIfPresent( + Int.self, + forKey: .readingOrderItemOffsetMilliseconds + ) chapter = try container.decodeIfPresent(String.self, forKey: .chapter) title = try container.decodeIfPresent(String.self, forKey: .title) part = try container.decodeIfPresent(Int.self, forKey: .part) time = try container.decodeIfPresent(Int.self, forKey: .time) } - + public func encode(to encoder: Encoder) throws { var container = encoder.container(keyedBy: CodingKeys.self) try container.encode(type.rawValue, forKey: .type) @@ -95,17 +102,26 @@ enum BookmarkType: String, Codable { try container.encodeIfPresent(part, forKey: .part) try container.encodeIfPresent(time, forKey: .time) } - - static func create(locatorData: [String: Any], timeStamp: String? = Date().iso8601, annotationId: String = "") -> AudioBookmark? { + + static func create( + locatorData: [String: Any], + timeStamp: String? = Date().iso8601, + annotationId: String = "" + ) -> AudioBookmark? { guard let typeString = locatorData["@type"] as? String, - let type = BookmarkType(rawValue: typeString) else { return nil } + let type = BookmarkType(rawValue: typeString) + else { + return nil + } let version = locatorData["@version"] as? Int ?? 1 - let lastSavedTimeStamp = (locatorData["timeStamp"] as? String)?.isEmpty == false ? locatorData["timeStamp"] as? String : timeStamp + let lastSavedTimeStamp = (locatorData["timeStamp"] as? String)? + .isEmpty == false ? locatorData["timeStamp"] as? String : timeStamp let id = locatorData["annotationId"] as? String ?? annotationId let readingOrderItem = locatorData["readingOrderItem"] as? String - let readingOrderItemOffsetMilliseconds = locatorData["readingOrderItemOffsetMilliseconds"] as? Int ?? locatorData["time"] as? Int + let readingOrderItemOffsetMilliseconds = locatorData["readingOrderItemOffsetMilliseconds"] as? Int ?? + locatorData["time"] as? Int let chapter = locatorData["chapter"] as? String ?? String(locatorData["chapter"] as? Int ?? 0) let title = locatorData["title"] as? String let part = locatorData["part"] as? Int @@ -126,29 +142,30 @@ enum BookmarkType: String, Codable { } public func toData() -> Data? { - return try? JSONEncoder().encode(self) + try? JSONEncoder().encode(self) } - + public func isSimilar(to other: AudioBookmark) -> Bool { - return self.type == other.type && - self.readingOrderItem == other.readingOrderItem && - self.readingOrderItemOffsetMilliseconds == other.readingOrderItemOffsetMilliseconds && - self.chapter == other.chapter && - self.title == other.title && - self.part == other.part && - self.time == other.time + type == other.type && + readingOrderItem == other.readingOrderItem && + readingOrderItemOffsetMilliseconds == other.readingOrderItemOffsetMilliseconds && + chapter == other.chapter && + title == other.title && + part == other.part && + time == other.time } - + public func toTPPBookLocation() -> TPPBookLocation? { guard let data = toData(), - let locationString = String(data: data, encoding: .utf8) else { + let locationString = String(data: data, encoding: .utf8) + else { return nil } return TPPBookLocation(locationString: locationString, renderer: "PalaceAudiobookToolkit") } - - public func copy(with zone: NSZone? = nil) -> Any { - return AudioBookmark( + + public func copy(with _: NSZone? = nil) -> Any { + AudioBookmark( type: type, version: version, timeStamp: lastSavedTimeStamp, @@ -163,13 +180,15 @@ enum BookmarkType: String, Codable { } } +// MARK: - AnyCodable + struct AnyCodable: Codable { var value: Any - + init(_ value: Any) { self.value = value } - + init(from decoder: Decoder) throws { let container = try decoder.singleValueContainer() if let intVal = try? container.decode(Int.self) { @@ -183,12 +202,12 @@ struct AnyCodable: Codable { } else if let nestedVal = try? container.decode([String: AnyCodable].self) { value = Dictionary(uniqueKeysWithValues: nestedVal.map { key, value in (key, value.value) }) } else if let arrayVal = try? container.decode([AnyCodable].self) { - value = arrayVal.map { $0.value } + value = arrayVal.map(\.value) } else { throw DecodingError.dataCorruptedError(in: container, debugDescription: "Unsupported type") } } - + func encode(to encoder: Encoder) throws { var container = encoder.singleValueContainer() if let intVal = value as? UInt { @@ -208,14 +227,17 @@ struct AnyCodable: Codable { } else if let arrayVal = value as? [Any] { try container.encode(arrayVal.map { AnyCodable($0) }) } else { - throw EncodingError.invalidValue(value, EncodingError.Context(codingPath: encoder.codingPath, debugDescription: "Unsupported type")) + throw EncodingError.invalidValue( + value, + EncodingError.Context(codingPath: encoder.codingPath, debugDescription: "Unsupported type") + ) } } } extension Date { var iso8601: String { - return ISO8601DateFormatter().string(from: self) + ISO8601DateFormatter().string(from: self) } } @@ -229,5 +251,3 @@ extension AudioBookmark { return "" } } - - diff --git a/Palace/Reader2/Bookmarks/AudiobookBookmarkBusinessLogic.swift b/Palace/Reader2/Bookmarks/AudiobookBookmarkBusinessLogic.swift index 7221767ca..f1a3046bb 100644 --- a/Palace/Reader2/Bookmarks/AudiobookBookmarkBusinessLogic.swift +++ b/Palace/Reader2/Bookmarks/AudiobookBookmarkBusinessLogic.swift @@ -9,6 +9,8 @@ import Foundation import PalaceAudiobookToolkit +// MARK: - AudiobookBookmarkBusinessLogic + @objc public class AudiobookBookmarkBusinessLogic: NSObject { private var book: TPPBook private var registry: TPPBookRegistryProvider @@ -23,21 +25,21 @@ import PalaceAudiobookToolkit @objc convenience init(book: TPPBook) { self.init(book: book, registry: TPPBookRegistry.shared, annotationsManager: TPPAnnotationsWrapper()) } - + init(book: TPPBook, registry: TPPBookRegistryProvider, annotationsManager: AnnotationsManager) { self.book = book self.registry = registry self.annotationsManager = annotationsManager } - + // MARK: - Bookmark Management - + public func saveListeningPosition(at position: TrackPosition, completion: ((String?) -> Void)?) { debounce { self.saveListeningPositionImmediate(at: position, completion: completion) } } - + private func saveListeningPositionImmediate(at position: TrackPosition, completion: ((String?) -> Void)?) { let audioBookmark = position.toAudioBookmark() audioBookmark.lastSavedTimeStamp = Date().iso8601 @@ -45,26 +47,29 @@ import PalaceAudiobookToolkit completion?(nil) return } - - annotationsManager.postListeningPosition(forBook: self.book.identifier, selectorValue: tppLocation.locationString) { response in - if let response { - audioBookmark.lastSavedTimeStamp = response.timeStamp ?? "" - audioBookmark.annotationId = response.serverId ?? "" - self.registry.setLocation(audioBookmark.toTPPBookLocation(), forIdentifier: self.book.identifier) - completion?(response.timeStamp) - } else { - completion?(nil) + + annotationsManager + .postListeningPosition(forBook: book.identifier, selectorValue: tppLocation.locationString) { response in + if let response { + audioBookmark.lastSavedTimeStamp = response.timeStamp ?? "" + audioBookmark.annotationId = response.serverId ?? "" + self.registry.setLocation(audioBookmark.toTPPBookLocation(), forIdentifier: self.book.identifier) + completion?(response.timeStamp) + } else { + completion?(nil) + } } - } } - + public func saveBookmark(at position: TrackPosition, completion: ((_ position: TrackPosition?) -> Void)? = nil) { debounce { Task { [weak self] in - guard let self else { return } + guard let self else { + return + } let location = position.toAudioBookmark() var updatedPosition = position - + defer { updatedPosition.lastSavedTimeStamp = location.lastSavedTimeStamp ?? Date().iso8601 updatedPosition.annotationId = location.annotationId @@ -73,57 +78,67 @@ import PalaceAudiobookToolkit } DispatchQueue.main.async { completion?(updatedPosition) } } - + guard let data = location.toData(), let locationString = String(data: data, encoding: .utf8) else { Log.error(#file, "Failed to encode location data for bookmark.") DispatchQueue.main.async { completion?(nil) } return } - - if let annotationResponse = try? await self.annotationsManager.postAudiobookBookmark(forBook: self.book.identifier, selectorValue: locationString) { + + if let annotationResponse = try? await annotationsManager.postAudiobookBookmark( + forBook: book.identifier, + selectorValue: locationString + ) { location.annotationId = annotationResponse.serverId ?? "" location.lastSavedTimeStamp = annotationResponse.timeStamp ?? "" } } } } - + public func fetchBookmarks(for tracks: Tracks, toc: [Chapter], completion: @escaping ([TrackPosition]) -> Void) { queue.async { [weak self] in - guard let self else { return } - let localBookmarks: [AudioBookmark] = self.fetchLocalBookmarks() - - self.syncBookmarks(localBookmarks: localBookmarks) { syncedBookmarks in - let trackPositions = syncedBookmarks.combineAndRemoveDuplicates(with: localBookmarks).compactMap { TrackPosition(audioBookmark: $0, toc: toc, tracks: tracks) } + guard let self else { + return + } + let localBookmarks: [AudioBookmark] = fetchLocalBookmarks() + + syncBookmarks(localBookmarks: localBookmarks) { syncedBookmarks in + let trackPositions = syncedBookmarks.combineAndRemoveDuplicates(with: localBookmarks) + .compactMap { TrackPosition( + audioBookmark: $0, + toc: toc, + tracks: tracks + ) } DispatchQueue.main.async { completion(trackPositions) } } } } - + public func deleteBookmark(at position: TrackPosition, completion: ((Bool) -> Void)? = nil) { let bookmark = position.toAudioBookmark() deleteBookmark(at: bookmark, completion: completion) } - + public func deleteBookmark(at bookmark: AudioBookmark, completion: ((Bool) -> Void)? = nil) { if let genericLocation = bookmark.toTPPBookLocation() { - self.registry.deleteGenericBookmark(genericLocation, forIdentifier: self.book.identifier) + registry.deleteGenericBookmark(genericLocation, forIdentifier: book.identifier) } - + guard !bookmark.isUnsynced else { DispatchQueue.main.async { completion?(true) } return } - + annotationsManager.deleteBookmark(annotationId: bookmark.annotationId) { success in DispatchQueue.main.async { completion?(success) } } } - + // MARK: - Sync Logic - + func syncBookmarks(localBookmarks: [AudioBookmark], completion: (([AudioBookmark]) -> Void)? = nil) { guard !isSyncing else { if let completion { @@ -131,42 +146,48 @@ import PalaceAudiobookToolkit } return } - + isSyncing = true Task { [weak self] in - guard let self else { return } + guard let self else { + return + } await uploadUnsyncedBookmarks(localBookmarks) - + fetchServerBookmarks { [weak self] remoteBookmarks in - guard let strongSelf = self else { return } - + guard let strongSelf = self else { + return + } + strongSelf.updateLocalBookmarks(with: remoteBookmarks) { updatedBookmarks in strongSelf.finalizeSync(with: updatedBookmarks, completion: completion) } } } } - + private func fetchLocalBookmarks() -> [AudioBookmark] { - return registry.genericBookmarksForIdentifier(book.identifier).compactMap { bookmark in + registry.genericBookmarksForIdentifier(book.identifier).compactMap { bookmark in guard let dictionary = bookmark.locationStringDictionary(), - let localBookmark = AudioBookmark.create(locatorData: dictionary) else { + let localBookmark = AudioBookmark.create(locatorData: dictionary) + else { return nil } return localBookmark } } - + private func fetchServerBookmarks(completion: @escaping ([AudioBookmark]) -> Void) { - annotationsManager.getServerBookmarks(forBook: book, atURL: self.book.annotationsURL, motivation: .bookmark) { serverBookmarks in - guard let audioBookmarks = serverBookmarks as? [AudioBookmark] else { - completion([]) - return + annotationsManager + .getServerBookmarks(forBook: book, atURL: book.annotationsURL, motivation: .bookmark) { serverBookmarks in + guard let audioBookmarks = serverBookmarks as? [AudioBookmark] else { + completion([]) + return + } + completion(audioBookmarks) } - completion(audioBookmarks) - } } - + private func uploadUnsyncedBookmarks(_ localBookmarks: [AudioBookmark]) async { for bookmark in localBookmarks where bookmark.isUnsynced { do { @@ -176,18 +197,24 @@ import PalaceAudiobookToolkit } } } - + private func uploadBookmark(_ bookmark: AudioBookmark) async throws { guard let data = bookmark.toData(), - let locationString = String(data: data, encoding: .utf8) else { return } - - guard let annotationResponse = try await annotationsManager.postAudiobookBookmark(forBook: self.book.identifier, selectorValue: locationString) else { + let locationString = String(data: data, encoding: .utf8) + else { + return + } + + guard let annotationResponse = try await annotationsManager.postAudiobookBookmark( + forBook: book.identifier, + selectorValue: locationString + ) else { return } - + updateLocalBookmark(bookmark, with: annotationResponse) } - + private func updateLocalBookmark(_ bookmark: AudioBookmark, with annotationResponse: AnnotationResponse) { if let updatedBookmark = bookmark.copy() as? AudioBookmark { updatedBookmark.annotationId = annotationResponse.serverId ?? "" @@ -195,29 +222,32 @@ import PalaceAudiobookToolkit replace(oldLocation: bookmark, with: updatedBookmark) } } - - private func updateLocalBookmarks(with remoteBookmarks: [AudioBookmark], completion: @escaping ([AudioBookmark]) -> Void) { + + private func updateLocalBookmarks( + with remoteBookmarks: [AudioBookmark], + completion: @escaping ([AudioBookmark]) -> Void + ) { let localBookmarks = fetchLocalBookmarks() - + guard annotationsManager.syncIsPossibleAndPermitted else { completion(localBookmarks) return } - + var updatedLocalBookmarks = localBookmarks - + let newRemoteBookmarks = remoteBookmarks.filter { remoteBookmark in let isSimilar = localBookmarks.contains { $0.isSimilar(to: remoteBookmark) } return !isSimilar } - + addNewBookmarksToLocalStore(newRemoteBookmarks) - + updatedLocalBookmarks = fetchLocalBookmarks() - + completion(updatedLocalBookmarks) } - + private func addNewBookmarksToLocalStore(_ bookmarks: [AudioBookmark]) { bookmarks.forEach { bookmark in bookmark.annotationId = UUID().uuidString @@ -226,14 +256,14 @@ import PalaceAudiobookToolkit } } } - + private func deleteBookmarks(_ bookmarks: [AudioBookmark]) { bookmarks.forEach { bookmark in deleteBookmark(at: bookmark) annotationsManager.deleteBookmark(annotationId: bookmark.annotationId) { _ in } } } - + private func finalizeSync(with bookmarks: [AudioBookmark], completion: (([AudioBookmark]) -> Void)?) { isSyncing = false DispatchQueue.main.async { @@ -242,19 +272,22 @@ import PalaceAudiobookToolkit self.completionHandlersQueue.removeAll() } } - + private func replace(oldLocation: AudioBookmark, with newLocation: AudioBookmark) { guard let oldLocation = oldLocation.toTPPBookLocation(), - let newLocation = newLocation.toTPPBookLocation() else { return } + let newLocation = newLocation.toTPPBookLocation() + else { + return + } registry.replaceGenericBookmark(oldLocation, with: newLocation, forIdentifier: book.identifier) } - + // MARK: - Helpers - + private func debounce(action: @escaping () -> Void) { debounceWorkItem?.cancel() - + let workItem = DispatchWorkItem(block: action) debounceWorkItem = workItem DispatchQueue.global(qos: .userInitiated).asyncAfter(deadline: .now() + debounceInterval, execute: workItem) @@ -264,11 +297,11 @@ import PalaceAudiobookToolkit private extension Array where Element == AudioBookmark { func combineAndRemoveDuplicates(with otherArray: [AudioBookmark]) -> [AudioBookmark] { var uniqueArray: [AudioBookmark] = [] - - for location in (self + otherArray) where !uniqueArray.contains(where: { $0.isSimilar(to: location) }) { + + for location in self + otherArray where !uniqueArray.contains(where: { $0.isSimilar(to: location) }) { uniqueArray.append(location) } - + return uniqueArray } } @@ -281,4 +314,6 @@ private extension Array { } } +// MARK: - AudiobookBookmarkBusinessLogic + AudiobookBookmarkDelegate + extension AudiobookBookmarkBusinessLogic: AudiobookBookmarkDelegate {} diff --git a/Palace/Reader2/Bookmarks/TPPAnnotations.swift b/Palace/Reader2/Bookmarks/TPPAnnotations.swift index 68a8289c3..ec03f45c1 100644 --- a/Palace/Reader2/Bookmarks/TPPAnnotations.swift +++ b/Palace/Reader2/Bookmarks/TPPAnnotations.swift @@ -1,42 +1,70 @@ -import UIKit import ReadiumShared +import UIKit + +// MARK: - AnnotationResponse public struct AnnotationResponse { var serverId: String? var timeStamp: String? } +// MARK: - AnnotationsManager + protocol AnnotationsManager { var syncIsPossibleAndPermitted: Bool { get } - func postListeningPosition(forBook bookID: String, selectorValue: String, completion: ((_ response: AnnotationResponse?) -> Void)?) + func postListeningPosition( + forBook bookID: String, + selectorValue: String, + completion: ((_ response: AnnotationResponse?) -> Void)? + ) func postAudiobookBookmark(forBook bookID: String, selectorValue: String) async throws -> AnnotationResponse? - func getServerBookmarks(forBook book: TPPBook?, - atURL annotationURL:URL?, - motivation: TPPBookmarkSpec.Motivation, - completion: @escaping (_ bookmarks: [Bookmark]?) -> ()) - func deleteBookmark(annotationId: String, completionHandler: @escaping (_ success: Bool) -> ()) + func getServerBookmarks( + forBook book: TPPBook?, + atURL annotationURL: URL?, + motivation: TPPBookmarkSpec.Motivation, + completion: @escaping (_ bookmarks: [Bookmark]?) -> Void + ) + func deleteBookmark(annotationId: String, completionHandler: @escaping (_ success: Bool) -> Void) } +// MARK: - TPPAnnotationsWrapper + @objcMembers final class TPPAnnotationsWrapper: NSObject, AnnotationsManager { var syncIsPossibleAndPermitted: Bool { TPPAnnotations.syncIsPossibleAndPermitted() } - func postListeningPosition(forBook bookID: String, selectorValue: String, completion: ((_ response: AnnotationResponse?) -> Void)?) { + func postListeningPosition( + forBook bookID: String, + selectorValue: String, + completion: ((_ response: AnnotationResponse?) -> Void)? + ) { TPPAnnotations.postListeningPosition(forBook: bookID, selectorValue: selectorValue, completion: completion) } - + func postAudiobookBookmark(forBook bookID: String, selectorValue: String) async throws -> AnnotationResponse? { try await TPPAnnotations.postAudiobookBookmark(forBook: bookID, selectorValue: selectorValue) } - - func getServerBookmarks(forBook book: TPPBook?, atURL annotationURL: URL?, motivation: TPPBookmarkSpec.Motivation = .bookmark, completion: @escaping ([Bookmark]?) -> ()) { - TPPAnnotations.getServerBookmarks(forBook: book, atURL: annotationURL, motivation: motivation, completion: completion) + + func getServerBookmarks( + forBook book: TPPBook?, + atURL annotationURL: URL?, + motivation: TPPBookmarkSpec.Motivation = .bookmark, + completion: @escaping ([Bookmark]?) -> Void + ) { + TPPAnnotations.getServerBookmarks( + forBook: book, + atURL: annotationURL, + motivation: motivation, + completion: completion + ) } - - func deleteBookmark(annotationId: String, completionHandler: @escaping (Bool) -> ()) { + + func deleteBookmark(annotationId: String, completionHandler: @escaping (Bool) -> Void) { TPPAnnotations.deleteBookmark(annotationId: annotationId, completionHandler: completionHandler) } } +// MARK: - TPPAnnotations + @objcMembers final class TPPAnnotations: NSObject { // MARK: - Reading Position @@ -55,7 +83,9 @@ protocol AnnotationsManager { var didResume = false getServerBookmarks(forBook: book, atURL: url, motivation: .readingProgress) { bookmarks in - guard !didResume else { return } + guard !didResume else { + return + } didResume = true continuation.resume(returning: bookmarks) @@ -65,17 +95,28 @@ protocol AnnotationsManager { return bookmarks?.first } - class func postListeningPosition(forBook bookID: String, selectorValue: String, completion: ((_ response: AnnotationResponse?) -> Void)? = nil) { - postReadingPosition(forBook: bookID, selectorValue: selectorValue, motivation: .readingProgress, completion: completion) + class func postListeningPosition( + forBook bookID: String, + selectorValue: String, + completion: ((_ response: AnnotationResponse?) -> Void)? = nil + ) { + postReadingPosition( + forBook: bookID, + selectorValue: selectorValue, + motivation: .readingProgress, + completion: completion + ) } class func postAudiobookBookmark(forBook bookID: String, selectorValue: String) async throws -> AnnotationResponse? { - return try await withCheckedThrowingContinuation { continuation in + try await withCheckedThrowingContinuation { continuation in var didResume = false postReadingPosition(forBook: bookID, selectorValue: selectorValue, motivation: .bookmark) { response in DispatchQueue.main.async { - guard !didResume else { return } + guard !didResume else { + return + } didResume = true if let response { @@ -88,7 +129,12 @@ protocol AnnotationsManager { } } - class func postReadingPosition(forBook bookID: String, selectorValue: String, motivation: TPPBookmarkSpec.Motivation, completion: ((_ response: AnnotationResponse?) -> Void)? = nil) { + class func postReadingPosition( + forBook bookID: String, + selectorValue: String, + motivation: TPPBookmarkSpec.Motivation, + completion: ((_ response: AnnotationResponse?) -> Void)? = nil + ) { guard syncIsPossibleAndPermitted() else { Log.debug(#file, "Account does not support sync or sync is disabled.") completion?(nil) @@ -102,21 +148,31 @@ protocol AnnotationsManager { } // Format bookmark for submission to server according to spec - let bookmark = TPPBookmarkSpec(time: NSDate(), - device: TPPUserAccount.sharedAccount().deviceID ?? "", - motivation: motivation, - bookID: bookID, - selectorValue: selectorValue) + let bookmark = TPPBookmarkSpec( + time: NSDate(), + device: TPPUserAccount.sharedAccount().deviceID ?? "", + motivation: motivation, + bookID: bookID, + selectorValue: selectorValue + ) let parameters = bookmark.dictionaryForJSONSerialization() - postAnnotation(forBook: bookID, withAnnotationURL: annotationsURL, withParameters: parameters, queueOffline: true) { (success, id, timeStamp) in + postAnnotation( + forBook: bookID, + withAnnotationURL: annotationsURL, + withParameters: parameters, + queueOffline: true + ) { success, id, timeStamp in guard success else { - TPPErrorLogger.logError(withCode: .apiCall, - summary: "Error posting annotation", - metadata: [ - "bookID": bookID, - "annotationID": id ?? "N/A", - "annotationURL": annotationsURL]) + TPPErrorLogger.logError( + withCode: .apiCall, + summary: "Error posting annotation", + metadata: [ + "bookID": bookID, + "annotationID": id ?? "N/A", + "annotationURL": annotationsURL + ] + ) completion?(nil) return } @@ -125,8 +181,13 @@ protocol AnnotationsManager { completion?(AnnotationResponse(serverId: id, timeStamp: timeStamp)) } } - - class func postBookmark(_ page: TPPPDFPage, annotationsURL: URL?, forBookID bookID: String, completion: @escaping (_ annotationResponse: AnnotationResponse?) -> Void) { + + class func postBookmark( + _ page: TPPPDFPage, + annotationsURL: URL?, + forBookID bookID: String, + completion: @escaping (_ annotationResponse: AnnotationResponse?) -> Void + ) { guard syncIsPossibleAndPermitted() else { Log.debug(#file, "Account does not support sync or sync is disabled.") completion(nil) @@ -142,7 +203,7 @@ protocol AnnotationsManager { Log.error(#file, "Bookmark selectorValue was nil while posting bookmark") return } - + let spec = TPPBookmarkSpec( time: NSDate(), device: TPPUserAccount.sharedAccount().deviceID ?? "", @@ -153,15 +214,21 @@ protocol AnnotationsManager { let parameters = spec.dictionaryForJSONSerialization() - postAnnotation(forBook: bookID, withAnnotationURL: annotationsURL, withParameters: parameters, queueOffline: false) { (success, id, timeStamp) in + postAnnotation( + forBook: bookID, + withAnnotationURL: annotationsURL, + withParameters: parameters, + queueOffline: false + ) { _, id, timeStamp in completion(AnnotationResponse(serverId: id, timeStamp: timeStamp)) } } - - class func postBookmark(_ bookmark: TPPReadiumBookmark, - forBookID bookID: String, - completion: @escaping (_ annotationResponse: AnnotationResponse?) -> ()) - { + + class func postBookmark( + _ bookmark: TPPReadiumBookmark, + forBookID bookID: String, + completion: @escaping (_ annotationResponse: AnnotationResponse?) -> Void + ) { guard syncIsPossibleAndPermitted() else { Log.debug(#file, "Account does not support sync or sync is disabled.") completion(nil) @@ -184,19 +251,29 @@ protocol AnnotationsManager { let parameters = spec.dictionaryForJSONSerialization() - postAnnotation(forBook: bookID, withAnnotationURL: annotationsURL, withParameters: parameters, queueOffline: false) { (success, id, timeStamp) in + postAnnotation( + forBook: bookID, + withAnnotationURL: annotationsURL, + withParameters: parameters, + queueOffline: false + ) { _, id, timeStamp in completion(AnnotationResponse(serverId: id, timeStamp: timeStamp)) } } /// Serializes the `parameters` into JSON and POSTs them to the server. - class func postAnnotation(forBook bookID: String, - withAnnotationURL url: URL, - withParameters parameters: [String: Any], - timeout: TimeInterval = TPPDefaultRequestTimeout, - queueOffline: Bool, - _ completionHandler: @escaping (_ success: Bool, _ annotationID: String?, _ timeStamp: String?) -> ()) { - + class func postAnnotation( + forBook bookID: String, + withAnnotationURL url: URL, + withParameters parameters: [String: Any], + timeout: TimeInterval = TPPDefaultRequestTimeout, + queueOffline: Bool, + _ completionHandler: @escaping ( + _ success: Bool, + _ annotationID: String?, + _ timeStamp: String? + ) -> Void + ) { guard let jsonData = try? JSONSerialization.data(withJSONObject: parameters, options: [.prettyPrinted]) else { Log.error(#file, "Network request abandoned. Could not create JSON from given parameters.") completionHandler(false, nil, nil) @@ -208,7 +285,7 @@ protocol AnnotationsManager { request.httpBody = jsonData request.timeoutInterval = timeout - let task = TPPNetworkExecutor.shared.POST(request, useTokenIfAvailable: true) { (data, response, error) in + let task = TPPNetworkExecutor.shared.POST(request, useTokenIfAvailable: true) { data, response, error in if let error = error as NSError? { Log.error(#file, "Annotation POST error (nsCode: \(error.code) Description: \(error.localizedDescription))") if (NetworkQueue.StatusCodes.contains(error.code)) && (queueOffline == true) { @@ -241,7 +318,7 @@ protocol AnnotationsManager { Log.error(#file, "No Annotation ID saved: No data received from server.") return nil } - guard let json = try? JSONSerialization.jsonObject(with: data, options: []) as? [String:Any] else { + guard let json = try? JSONSerialization.jsonObject(with: data, options: []) as? [String: Any] else { Log.error(#file, "No Annotation ID saved: JSON could not be created from data.") return nil } @@ -252,17 +329,19 @@ protocol AnnotationsManager { return nil } } - + private class func timeStamp(fromNetworkData data: Data?) -> String? { guard let data = data else { Log.error(#file, "No Annotation ID saved: No data received from server.") return nil } - guard let json = try? JSONSerialization.jsonObject(with: data, options: []) as? [String:Any] else { + guard let json = try? JSONSerialization.jsonObject(with: data, options: []) as? [String: Any] else { Log.error(#file, "No Annotation ID saved: JSON could not be created from data.") return nil } - if let body = json[TPPBookmarkSpec.Body.key] as? [String:Any], let timeStamp = body[TPPBookmarkSpec.Body.Time.key] as? String { + if let body = json[TPPBookmarkSpec.Body.key] as? [String: Any], + let timeStamp = body[TPPBookmarkSpec.Body.Time.key] as? String + { return timeStamp } else { Log.error(#file, "No Annotation ID saved: Key/Value not found in JSON response.") @@ -274,11 +353,12 @@ protocol AnnotationsManager { // Completion handler will return a nil parameter if there are any failures with // the network request, deserialization, or sync permission is not allowed. - class func getServerBookmarks(forBook book:TPPBook?, - atURL annotationURL:URL?, - motivation: TPPBookmarkSpec.Motivation = .bookmark, - completion: @escaping (_ bookmarks: [Bookmark]?) -> ()) { - + class func getServerBookmarks( + forBook book: TPPBook?, + atURL annotationURL: URL?, + motivation: TPPBookmarkSpec.Motivation = .bookmark, + completion: @escaping (_ bookmarks: [Bookmark]?) -> Void + ) { guard syncIsPossibleAndPermitted() else { Log.debug(#file, "Account does not support sync or sync is disabled.") completion(nil) @@ -290,9 +370,8 @@ protocol AnnotationsManager { completion(nil) return } - - let dataTask = TPPNetworkExecutor.shared.GET(annotationURL, useTokenIfAvailable: true) { (data, response, error) in - + + let dataTask = TPPNetworkExecutor.shared.GET(annotationURL, useTokenIfAvailable: true) { data, _, error in if let error = error as NSError? { Log.error(#file, "Request Error Code: \(error.code). Description: \(error.localizedDescription)") completion(nil) @@ -300,24 +379,28 @@ protocol AnnotationsManager { } guard let data, - let jsonObject = try? JSONSerialization.jsonObject(with: data, options: []), - let json = jsonObject as? [String: Any] else { - Log.error(#file, "Response from annotation server could not be serialized.") - completion(nil) - return + let jsonObject = try? JSONSerialization.jsonObject(with: data, options: []), + let json = jsonObject as? [String: Any] + else { + Log.error(#file, "Response from annotation server could not be serialized.") + completion(nil) + return } guard let first = json["first"] as? [String: Any], - let items = first["items"] as? [[String: Any]] else { - Log.error(#file, "Missing required key from Annotations response, or no items exist.") - completion(nil) - return + let items = first["items"] as? [[String: Any]] + else { + Log.error(#file, "Missing required key from Annotations response, or no items exist.") + completion(nil) + return } let bookmarks = items.compactMap { - TPPBookmarkFactory.make(fromServerAnnotation: $0, - annotationType: motivation, - book: book) + TPPBookmarkFactory.make( + fromServerAnnotation: $0, + annotationType: motivation, + book: book + ) } completion(bookmarks) @@ -327,7 +410,6 @@ protocol AnnotationsManager { } class func deleteBookmarks(_ bookmarks: [TPPReadiumBookmark]) { - for localBookmark in bookmarks { if let annotationID = localBookmark.annotationId { deleteBookmark(annotationId: annotationID) { success in @@ -341,9 +423,10 @@ protocol AnnotationsManager { } } - class func deleteBookmark(annotationId: String, - completionHandler: @escaping (_ success: Bool) -> ()) { - + class func deleteBookmark( + annotationId: String, + completionHandler: @escaping (_ success: Bool) -> Void + ) { if !syncIsPossibleAndPermitted() { Log.debug(#file, "Account does not support sync or sync is disabled.") completionHandler(true) @@ -359,7 +442,7 @@ protocol AnnotationsManager { var request = TPPNetworkExecutor.shared.request(for: url) request.timeoutInterval = TPPDefaultRequestTimeout - let task = TPPNetworkExecutor.shared.DELETE(request, useTokenIfAvailable: true) { (data, response, error) in + let task = TPPNetworkExecutor.shared.DELETE(request, useTokenIfAvailable: true) { _, response, error in let response = response as? HTTPURLResponse if response?.statusCode == 200 { Log.info(#file, "200: DELETE bookmark success") @@ -371,18 +454,25 @@ protocol AnnotationsManager { Log.error(#file, "DELETE bookmark failed with server response code: \(code)") completionHandler(false) } else { - guard let error = error as NSError? else { return } - Log.error(#file, "DELETE bookmark Request Failed with Error Code: \(error.code). Description: \(error.localizedDescription)") + guard let error = error as NSError? else { + return + } + Log.error( + #file, + "DELETE bookmark Request Failed with Error Code: \(error.code). Description: \(error.localizedDescription)" + ) completionHandler(false) } } - + task?.resume() } - class func uploadLocalBookmarks(_ bookmarks: [TPPReadiumBookmark], - forBook bookID: String, - completion: @escaping ([TPPReadiumBookmark], [TPPReadiumBookmark])->()) { + class func uploadLocalBookmarks( + _ bookmarks: [TPPReadiumBookmark], + forBook bookID: String, + completion: @escaping ([TPPReadiumBookmark], [TPPReadiumBookmark]) -> Void + ) { if !syncIsPossibleAndPermitted() { Log.debug(#file, "Account does not support sync or sync is disabled.") return @@ -394,7 +484,9 @@ protocol AnnotationsManager { var bookmarksUpdated = [TPPReadiumBookmark]() for localBookmark in bookmarks { - guard localBookmark.annotationId == nil else { continue } + guard localBookmark.annotationId == nil else { + continue + } uploadGroup.enter() postBookmark(localBookmark, forBookID: bookID) { response in @@ -417,6 +509,7 @@ protocol AnnotationsManager { completion(bookmarksUpdated, bookmarksFailedToUpdate) } } + // MARK: - /// Annotation-syncing is possible only if the given `account` is signed-in @@ -432,10 +525,10 @@ protocol AnnotationsManager { } static var annotationsURL: URL? { - return TPPConfiguration.mainFeedURL()?.appendingPathComponent("annotations/") + TPPConfiguration.mainFeedURL()?.appendingPathComponent("annotations/") } - private class func addToOfflineQueue(_ bookID: String?, _ url: URL, _ parameters: [String:Any]) { + private class func addToOfflineQueue(_ bookID: String?, _ url: URL, _ parameters: [String: Any]) { let libraryID = AccountsManager.shared.currentAccount?.uuid ?? "" let parameterData = try? JSONSerialization.data(withJSONObject: parameters, options: [.prettyPrinted]) let headers = TPPNetworkExecutor.shared.request(for: url).allHTTPHeaderFields diff --git a/Palace/Reader2/Bookmarks/TPPBookLocation+Locator.swift b/Palace/Reader2/Bookmarks/TPPBookLocation+Locator.swift index fde671949..0237c98d5 100644 --- a/Palace/Reader2/Bookmarks/TPPBookLocation+Locator.swift +++ b/Palace/Reader2/Bookmarks/TPPBookLocation+Locator.swift @@ -4,11 +4,12 @@ import ReadiumShared extension TPPBookLocation { static let r3Renderer = "readium3" - convenience init?(locator: Locator, - type: String, - publication: Publication, - renderer: String = TPPBookLocation.r3Renderer) { - + convenience init?( + locator: Locator, + type: String, + publication _: Publication, + renderer: String = TPPBookLocation.r3Renderer + ) { let dict: [String: Any] = [ TPPBookLocation.hrefKey: locator.href.string, TPPBookLocation.typeKey: type, @@ -28,19 +29,20 @@ extension TPPBookLocation { } // Initialize with properties directly - convenience init?(href: String, - type: String, - time: Double? = nil, - part: Float? = nil, - chapter: String? = nil, - chapterProgression: Float? = nil, - totalProgression: Float? = nil, - title: String? = nil, - position: Double? = nil, - cssSelector: String? = nil, - publication: Publication? = nil, - renderer: String = TPPBookLocation.r3Renderer) { - + convenience init?( + href: String, + type: String, + time: Double? = nil, + part: Float? = nil, + chapter: String? = nil, + chapterProgression: Float? = nil, + totalProgression: Float? = nil, + title: String? = nil, + position: Double? = nil, + cssSelector: String? = nil, + publication _: Publication? = nil, + renderer: String = TPPBookLocation.r3Renderer + ) { guard let normalizedHref = AnyURL(legacyHREF: href)?.string else { Log.warn(#file, "Invalid href format") return nil @@ -68,9 +70,10 @@ extension TPPBookLocation { } func convertToLocator(publication: Publication) async -> Locator? { - guard self.renderer == TPPBookLocation.r3Renderer, - let data = self.locationString.data(using: .utf8), - let dict = (try? JSONSerialization.jsonObject(with: data, options: [])) as? [String: Any] else { + guard renderer == TPPBookLocation.r3Renderer, + let data = locationString.data(using: .utf8), + let dict = (try? JSONSerialization.jsonObject(with: data, options: [])) as? [String: Any] + else { Log.error(#file, "Failed to convert TPPBookLocation to Locator with string: \(locationString)") return nil } @@ -94,7 +97,8 @@ extension TPPBookLocation { progression: dict[TPPBookLocation.chapterProgressKey] as? Double, totalProgression: dict[TPPBookLocation.bookProgressKey] as? Double, position: position, - otherLocations: dict[TPPBookLocation.cssSelector] != nil ? [TPPBookLocation.cssSelector: dict[TPPBookLocation.cssSelector]!] : [:] + otherLocations: dict[TPPBookLocation.cssSelector] != nil ? + [TPPBookLocation.cssSelector: dict[TPPBookLocation.cssSelector]!] : [:] ) return Locator( diff --git a/Palace/Reader2/Bookmarks/TPPBookmarkFactory.swift b/Palace/Reader2/Bookmarks/TPPBookmarkFactory.swift index 01a22a993..57997d1f9 100644 --- a/Palace/Reader2/Bookmarks/TPPBookmarkFactory.swift +++ b/Palace/Reader2/Bookmarks/TPPBookmarkFactory.swift @@ -10,7 +10,6 @@ import Foundation import ReadiumShared class TPPBookmarkFactory { - private let book: TPPBook private let publication: Publication private let drmDeviceID: String? @@ -21,13 +20,15 @@ class TPPBookmarkFactory { self.drmDeviceID = drmDeviceID } - func make(fromR3Location bookmarkLoc: TPPBookmarkR3Location, - usingBookRegistry bookRegistry: TPPBookRegistryProvider, - for book: TPPBook, - publication: Publication) async -> TPPReadiumBookmark? { - + func make( + fromR3Location bookmarkLoc: TPPBookmarkR3Location, + usingBookRegistry bookRegistry: TPPBookRegistryProvider, + for book: TPPBook, + publication: Publication + ) async -> TPPReadiumBookmark? { guard let chapterProgress = bookmarkLoc.locator.locations.progression.map(Float.init), - let totalProgress = bookmarkLoc.locator.locations.totalProgression.map(Float.init) else { + let totalProgress = bookmarkLoc.locator.locations.totalProgression.map(Float.init) + else { return nil } @@ -35,10 +36,10 @@ class TPPBookmarkFactory { let href = bookmarkLoc.locator.href.string - var chapter: String? = nil + var chapter: String? let tocResult = await publication.tableOfContents() switch tocResult { - case .success(let toc): + case let .success(toc): chapter = toc.firstWithHREF(bookmarkLoc.locator.href)?.title case .failure: chapter = nil @@ -61,32 +62,37 @@ class TPPBookmarkFactory { ) } - class func make(fromServerAnnotation annotation: [String: Any], - annotationType: TPPBookmarkSpec.Motivation, - book: TPPBook) -> Bookmark? { - + class func make( + fromServerAnnotation annotation: [String: Any], + annotationType: TPPBookmarkSpec.Motivation, + book: TPPBook + ) -> Bookmark? { let bookID = book.identifier - + guard let annotationID = annotation[TPPBookmarkSpec.Id.key] as? String else { Log.error(#file, "Missing AnnotationID:\(annotation)") return nil } guard let target = annotation[TPPBookmarkSpec.Target.key] as? [String: AnyObject], - let source = target[TPPBookmarkSpec.Target.Source.key] as? String, - let motivation = annotation[TPPBookmarkSpec.Motivation.key] as? String else { - Log.error(#file, "Error parsing required key/values for target.") - return nil + let source = target[TPPBookmarkSpec.Target.Source.key] as? String, + let motivation = annotation[TPPBookmarkSpec.Motivation.key] as? String + else { + Log.error(#file, "Error parsing required key/values for target.") + return nil } - guard source == bookID else { - TPPErrorLogger.logError(withCode: .bookmarkReadError, - summary: "Got bookmark for a different book", - metadata: [ - "requestedBookID": bookID, - "serverAnnotation": annotation]) - return nil - } + guard source == bookID else { + TPPErrorLogger.logError( + withCode: .bookmarkReadError, + summary: "Got bookmark for a different book", + metadata: [ + "requestedBookID": bookID, + "serverAnnotation": annotation + ] + ) + return nil + } guard motivation == annotationType.rawValue else { return nil @@ -96,65 +102,75 @@ class TPPBookmarkFactory { let body = annotation[TPPBookmarkSpec.Body.key] as? [String: AnyObject], let device = body[TPPBookmarkSpec.Body.Device.key] as? String, let time = body[TPPBookmarkSpec.Body.Time.key] as? String - else { - Log.error(#file, "Error reading required bookmark key/values from body") - return nil + else { + Log.error(#file, "Error reading required bookmark key/values from body") + return nil } guard let selector = target[TPPBookmarkSpec.Target.Selector.key] as? [String: AnyObject], let selectorValueEscJSON = selector[TPPBookmarkSpec.Target.Selector.Value.key] as? String - else { - Log.error(#file, "Error reading required Selector Value from Target.") - return nil + else { + Log.error(#file, "Error reading required Selector Value from Target.") + return nil } guard let selectorValueData = selectorValueEscJSON.data(using: String.Encoding.utf8), - let selectorValueDict = try? JSONSerialization.jsonObject(with: selectorValueData, options: []) as? [String: Any] + let selectorValueDict = try? JSONSerialization + .jsonObject(with: selectorValueData, options: []) as? [String: Any] else { Log.error(#file, "Error serializing serverCFI into JSON. Selector.Value=\(selectorValueEscJSON)") - return nil + return nil } - + if book.isAudiobook, let audioBookmark = AudioBookmark.create( - locatorData: selectorValueDict, - timeStamp: time, - annotationId: annotationID - ) { + locatorData: selectorValueDict, + timeStamp: time, + annotationId: annotationID + ) + { return audioBookmark } - + if let pdfPageBookmark = try? JSONDecoder().decode(TPPPDFPageBookmark.self, from: selectorValueData), - pdfPageBookmark.type == TPPPDFPageBookmark.Types.locatorPage.rawValue { + pdfPageBookmark.type == TPPPDFPageBookmark.Types.locatorPage.rawValue + { pdfPageBookmark.annotationID = annotationID return pdfPageBookmark } - - guard let selectorValueJSON = (try? JSONSerialization.jsonObject(with: selectorValueData, options: [])) as? [String: Any] else { + + guard let selectorValueJSON = (try? JSONSerialization.jsonObject( + with: selectorValueData, + options: [] + )) as? [String: Any] else { Log.error(#file, "Error serializing serverCFI into JSON. Selector.Value=\(selectorValueEscJSON)") return nil } - - let href = selectorValueJSON["href"] as? String ?? "" - let chapter = body[TPPBookmarkSpec.Body.ChapterTitle.key] as? String ?? selectorValueJSON["title"] as? String - let progressWithinChapter = selectorValueJSON["progressWithinChapter"] as? Float ?? Float((selectorValueJSON["progressWithinChapter"] as? Double) ?? 0.0) - let progressWithinBook = Float(selectorValueJSON["progressWithinBook"] as? Double ?? body[TPPBookmarkSpec.Body.ProgressWithinBook.key] as? Double ?? 0.0) - let readingOrderItem = selectorValueJSON["readingOrderItem"] as? String - let readingOrderItemOffsetMilliseconds = selectorValueJSON["readingOrderItemOffsetMilliseconds"] as? Float - - return TPPReadiumBookmark( - annotationId: annotationID, - href: href, - chapter: chapter, - page: nil, - location: selectorValueEscJSON, - progressWithinChapter: progressWithinChapter, - progressWithinBook: progressWithinBook, - readingOrderItem: readingOrderItem, - readingOrderItemOffsetMilliseconds: readingOrderItemOffsetMilliseconds, - time:time, - device:device + + let href = selectorValueJSON["href"] as? String ?? "" + let chapter = body[TPPBookmarkSpec.Body.ChapterTitle.key] as? String ?? selectorValueJSON["title"] as? String + let progressWithinChapter = selectorValueJSON["progressWithinChapter"] as? Float ?? + Float((selectorValueJSON["progressWithinChapter"] as? Double) ?? 0.0) + let progressWithinBook = + Float(selectorValueJSON["progressWithinBook"] as? Double ?? + body[TPPBookmarkSpec.Body.ProgressWithinBook.key] as? Double ?? 0.0 ) + let readingOrderItem = selectorValueJSON["readingOrderItem"] as? String + let readingOrderItemOffsetMilliseconds = selectorValueJSON["readingOrderItemOffsetMilliseconds"] as? Float + + return TPPReadiumBookmark( + annotationId: annotationID, + href: href, + chapter: chapter, + page: nil, + location: selectorValueEscJSON, + progressWithinChapter: progressWithinChapter, + progressWithinBook: progressWithinBook, + readingOrderItem: readingOrderItem, + readingOrderItemOffsetMilliseconds: readingOrderItemOffsetMilliseconds, + time: time, + device: device + ) } } diff --git a/Palace/Reader2/Bookmarks/TPPBookmarkR3Location.swift b/Palace/Reader2/Bookmarks/TPPBookmarkR3Location.swift index 665b68052..8df097b35 100644 --- a/Palace/Reader2/Bookmarks/TPPBookmarkR3Location.swift +++ b/Palace/Reader2/Bookmarks/TPPBookmarkR3Location.swift @@ -9,6 +9,8 @@ import Foundation import ReadiumShared +// MARK: - TPPBookmarkR3Location + class TPPBookmarkR3Location { var resourceIndex: Int var locator: Locator @@ -28,7 +30,11 @@ extension TPPBookmarkR3Location { /// - locator: The `Locator` representing the reading position. /// - publication: The `Publication` containing the reading material. /// - Returns: An optional `TPPBookmarkR3Location` if the `Locator` resolves successfully. - static func from(locator: Locator, in publication: Publication, creationDate: Date = Date()) -> TPPBookmarkR3Location? { + static func from( + locator: Locator, + in publication: Publication, + creationDate: Date = Date() + ) -> TPPBookmarkR3Location? { let href = locator.href guard let resourceIndex = publication.readingOrder.firstIndex(where: { $0.href == href.string }) else { diff --git a/Palace/Reader2/Bookmarks/TPPBookmarkSpec.swift b/Palace/Reader2/Bookmarks/TPPBookmarkSpec.swift index c6d2f9a34..ce3a0e9dc 100644 --- a/Palace/Reader2/Bookmarks/TPPBookmarkSpec.swift +++ b/Palace/Reader2/Bookmarks/TPPBookmarkSpec.swift @@ -27,14 +27,14 @@ /// See the [full spec](https://github.com/ThePalaceProject/mobile-bookmark-spec) /// for more details. struct TPPBookmarkSpec { - struct Context { + enum Context { /// The key identifying the `Context` section. static let key = "@context" /// The only possible value for the `Context` section key. static let value = "http://www.w3.org/ns/anno.jsonld" } - struct type { + enum type { /// The key identifying the `Type` section. static let key = "type" /// The only possible value for the `Type` section key. @@ -60,6 +60,7 @@ struct TPPBookmarkSpec { /// The timestamp value itself must be in UTC time zone. let value: String } + let time: Time struct Device { @@ -71,18 +72,21 @@ struct TPPBookmarkSpec { /// provided by `NYPLUserAccount::deviceID`. let value: String } + let device: Device - + struct ChapterTitle { static let key = "http://librarysimplified.org/terms/chapter" let value: String } + let chapterTitle: ChapterTitle struct ProgressWithinBook { static let key = "http://librarysimplified.org/terms/progressWithinBook" let value: Double } + let progressWithinBook: ProgressWithinBook init(time: String, device: String, chapterTitle: String, progressWithinBook: Double) { @@ -114,7 +118,7 @@ struct TPPBookmarkSpec { static let key = "selector" /// The Selector `type` has always a fixed value. - struct type { + enum type { /// The key identifying the Selector type. static let key = "type" /// The only possible value for the Selector's `Type` key. @@ -156,8 +160,9 @@ struct TPPBookmarkSpec { /// \"progressWithinChapter\": 0.5}" let selectorValue: String } + let value: Value - } //Selector + } // Selector let selector: Selector struct Source { @@ -165,11 +170,12 @@ struct TPPBookmarkSpec { /// Typically the book ID from the OPDS feed. let value: String } + let source: Source init(bookID: String, selectorValue: String) { - self.source = Source(value: bookID) - self.selector = Selector(value: Selector.Value(selectorValue: selectorValue)) + source = Source(value: bookID) + selector = Selector(value: Selector.Value(selectorValue: selectorValue)) } } @@ -178,37 +184,40 @@ struct TPPBookmarkSpec { let motivation: Motivation let target: Target - init(id: String? = nil, - time: NSDate, - device: String, - motivation: Motivation, - bookID: String, - selectorValue: String) { + init( + id: String? = nil, + time: NSDate, + device: String, + motivation: Motivation, + bookID: String, + selectorValue: String + ) { self.id = Id(value: id) var title = "" var progressWithinBook = 0.0 if let value = selectorValue.data(using: .utf8), - let dict = try? JSONSerialization.jsonObject(with: value, options: []) as? [String: Any] { + let dict = try? JSONSerialization.jsonObject(with: value, options: []) as? [String: Any] + { title = dict["title"] as? String ?? "" progressWithinBook = dict["progressWithinBook"] as? Double ?? 0.0 } - - self.body = Body(time: time.rfc3339String(), device: device, chapterTitle: title, progressWithinBook: progressWithinBook) + + body = Body(time: time.rfc3339String(), device: device, chapterTitle: title, progressWithinBook: progressWithinBook) self.motivation = motivation - self.target = Target(bookID: bookID, selectorValue: selectorValue) + target = Target(bookID: bookID, selectorValue: selectorValue) } /// - returns: A dictionary that can be given to `JSONSerialization` as a /// JSON object to be serialized into a binary Data blob. func dictionaryForJSONSerialization() -> [String: Any] { - return [ + [ TPPBookmarkSpec.Context.key: TPPBookmarkSpec.Context.value, TPPBookmarkSpec.type.key: TPPBookmarkSpec.type.value, TPPBookmarkSpec.Body.key: [ - TPPBookmarkSpec.Body.Time.key : body.time.value, - TPPBookmarkSpec.Body.Device.key : body.device.value, + TPPBookmarkSpec.Body.Time.key: body.time.value, + TPPBookmarkSpec.Body.Device.key: body.device.value, TPPBookmarkSpec.Body.ChapterTitle.key: body.chapterTitle.value ], TPPBookmarkSpec.Motivation.key: motivation.rawValue, @@ -219,6 +228,6 @@ struct TPPBookmarkSpec { TPPBookmarkSpec.Target.Selector.Value.key: target.selector.value.selectorValue ] ] - ] as [String: Any] + ] as [String: Any] } } diff --git a/Palace/Reader2/Bookmarks/TPPReadiumBookmark+R3.swift b/Palace/Reader2/Bookmarks/TPPReadiumBookmark+R3.swift index 2377cab8f..fdc86c3e4 100644 --- a/Palace/Reader2/Bookmarks/TPPReadiumBookmark+R3.swift +++ b/Palace/Reader2/Bookmarks/TPPReadiumBookmark+R3.swift @@ -7,11 +7,10 @@ // import Foundation -import ReadiumShared import ReadiumNavigator +import ReadiumShared extension TPPReadiumBookmark { - /// Converts the bookmark model into a location object that can be used /// with Readium 3. /// @@ -21,8 +20,9 @@ extension TPPReadiumBookmark { /// - Parameter publication: The Readium 3 `Publication` object where the bookmark is located. /// - Returns: A `Locator` object for Readium 3, or `nil` if conversion fails. func convertToR3(from publication: Publication) -> TPPBookmarkR3Location? { - guard let href = AnyURL(string: self.href), - let link = publication.linkWithHREF(href) else { + guard let href = AnyURL(string: href), + let link = publication.linkWithHREF(href) + else { return nil } @@ -37,7 +37,7 @@ extension TPPReadiumBookmark { let locator = Locator( href: href, mediaType: mediaType, - title: self.chapter, + title: chapter, locations: locations, text: Locator.Text(highlight: nil) ) @@ -46,26 +46,24 @@ extension TPPReadiumBookmark { return nil } - let creationDate = NSDate(rfc3339String: self.time) as Date? ?? Date() + let creationDate = NSDate(rfc3339String: time) as Date? ?? Date() return TPPBookmarkR3Location(resourceIndex: resourceIndex, locator: locator, creationDate: creationDate) } func locationMatches(_ locator: Locator) -> Bool { - let locatorTotalProgress: Float? - if let totalProgress = locator.locations.totalProgression { - locatorTotalProgress = Float(totalProgress) + let locatorTotalProgress: Float? = if let totalProgress = locator.locations.totalProgression { + Float(totalProgress) } else { - locatorTotalProgress = nil + nil } - let locatorChapterProgress: Float? - if let chapterProgress = locator.locations.progression { - locatorChapterProgress = Float(chapterProgress) + let locatorChapterProgress: Float? = if let chapterProgress = locator.locations.progression { + Float(chapterProgress) } else { - locatorChapterProgress = nil + nil } - return self.progressWithinChapter =~= locatorChapterProgress && self.progressWithinBook =~= locatorTotalProgress + return progressWithinChapter =~= locatorChapterProgress && progressWithinBook =~= locatorTotalProgress } } diff --git a/Palace/Reader2/Bookmarks/TPPReadiumBookmark.swift b/Palace/Reader2/Bookmarks/TPPReadiumBookmark.swift index 6cba4bc96..5e79f6dd1 100644 --- a/Palace/Reader2/Bookmarks/TPPReadiumBookmark.swift +++ b/Palace/Reader2/Bookmarks/TPPReadiumBookmark.swift @@ -1,3 +1,5 @@ +// MARK: - TPPBookmarkDictionaryRepresentation + /// This class specifies the keys used to represent a TPPReadiumBookmark /// as a dictionary. /// @@ -23,52 +25,56 @@ fileprivate static let readingOrderItemOffsetMilliseconds = "readingOrderItemOffsetMilliseconds" } +// MARK: - Bookmark + protocol Bookmark: NSObject {} +// MARK: - TPPReadiumBookmark + /// Internal representation of an annotation. This may represent an actual /// user bookmark as well as the "bookmark" of the last read position in a book. @objcMembers final class TPPReadiumBookmark: NSObject, Bookmark { - /// The bookmark ID. - var annotationId:String? + var annotationId: String? + + var chapter: String? + var page: String? - var chapter:String? - var page:String? + var location: String + var href: String - var location:String - var href:String + var progressWithinChapter: Float = 0.0 + var progressWithinBook: Float = 0.0 - var progressWithinChapter:Float = 0.0 - var progressWithinBook:Float = 0.0 - - var readingOrderItem:String? - var readingOrderItemOffsetMilliseconds:Float = 0.0 + var readingOrderItem: String? + var readingOrderItemOffsetMilliseconds: Float = 0.0 - var percentInChapter:String { - return (self.progressWithinChapter * 100).roundTo(decimalPlaces: 0) + var percentInChapter: String { + (progressWithinChapter * 100).roundTo(decimalPlaces: 0) } - var percentInBook:String { - return (self.progressWithinBook * 100).roundTo(decimalPlaces: 0) + + var percentInBook: String { + (progressWithinBook * 100).roundTo(decimalPlaces: 0) } - - var device:String? - /// Date formatted as per RFC 3339 - let time:String - - init?(annotationId:String?, - href:String?, - chapter:String?, - page:String?, - location:String?, - progressWithinChapter:Float, - progressWithinBook:Float, - readingOrderItem: String?, - readingOrderItemOffsetMilliseconds: Float?, - time:String?, - device:String?) - { + var device: String? + /// Date formatted as per RFC 3339 + let time: String + + init?( + annotationId: String?, + href: String?, + chapter: String?, + page: String?, + location _: String?, + progressWithinChapter: Float, + progressWithinBook: Float, + readingOrderItem: String?, + readingOrderItemOffsetMilliseconds: Float?, + time: String?, + device: String? + ) { guard let href = href else { Log.error(#file, "Bookmark creation failed init due to nil `href`.") return nil @@ -79,7 +85,7 @@ protocol Bookmark: NSObject {} self.chapter = chapter ?? "" self.page = page ?? "" - self.location = TPPBookLocation( + location = TPPBookLocation( href: href, type: "LocatorHrefProgression", chapterProgression: progressWithinChapter, @@ -87,7 +93,7 @@ protocol Bookmark: NSObject {} title: chapter, position: nil )?.locationString ?? "" - + self.progressWithinChapter = progressWithinChapter self.progressWithinBook = progressWithinBook self.readingOrderItem = readingOrderItem @@ -96,53 +102,57 @@ protocol Bookmark: NSObject {} self.device = device } - init?(dictionary:NSDictionary) - { + init?(dictionary: NSDictionary) { guard let href = dictionary[TPPBookmarkDictionaryRepresentation.hrefKey] as? String, - let location = dictionary[TPPBookmarkDictionaryRepresentation.locationKey] as? String, - let time = dictionary[TPPBookmarkDictionaryRepresentation.timeKey] as? String else { - Log.error(#file, "Bookmark failed to init from dictionary.") - return nil + let location = dictionary[TPPBookmarkDictionaryRepresentation.locationKey] as? String, + let time = dictionary[TPPBookmarkDictionaryRepresentation.timeKey] as? String + else { + Log.error(#file, "Bookmark failed to init from dictionary.") + return nil } - if let annotationID = dictionary[TPPBookmarkDictionaryRepresentation.annotationIdKey] as? String, !annotationID.isEmpty { - self.annotationId = annotationID + if let annotationID = dictionary[TPPBookmarkDictionaryRepresentation.annotationIdKey] as? String, + !annotationID.isEmpty + { + annotationId = annotationID } else { - self.annotationId = nil + annotationId = nil } self.href = href self.location = location self.time = time - self.chapter = dictionary[TPPBookmarkDictionaryRepresentation.chapterKey] as? String - self.page = dictionary[TPPBookmarkDictionaryRepresentation.pageKey] as? String - self.device = dictionary[TPPBookmarkDictionaryRepresentation.deviceKey] as? String - self.readingOrderItem = dictionary[TPPBookmarkDictionaryRepresentation.readingOrderItem] as? String - - if let readingOrderItemOffsetMilliseconds = dictionary[TPPBookmarkDictionaryRepresentation.readingOrderItemOffsetMilliseconds] as? NSNumber { - self.progressWithinChapter = readingOrderItemOffsetMilliseconds.floatValue + chapter = dictionary[TPPBookmarkDictionaryRepresentation.chapterKey] as? String + page = dictionary[TPPBookmarkDictionaryRepresentation.pageKey] as? String + device = dictionary[TPPBookmarkDictionaryRepresentation.deviceKey] as? String + readingOrderItem = dictionary[TPPBookmarkDictionaryRepresentation.readingOrderItem] as? String + + if let readingOrderItemOffsetMilliseconds = + dictionary[TPPBookmarkDictionaryRepresentation.readingOrderItemOffsetMilliseconds] as? NSNumber + { + progressWithinChapter = readingOrderItemOffsetMilliseconds.floatValue } - + if let progressChapter = dictionary[TPPBookmarkDictionaryRepresentation.chapterProgressKey] as? NSNumber { - self.progressWithinChapter = progressChapter.floatValue + progressWithinChapter = progressChapter.floatValue } if let progressBook = dictionary[TPPBookmarkDictionaryRepresentation.bookProgressKey] as? NSNumber { - self.progressWithinBook = progressBook.floatValue + progressWithinBook = progressBook.floatValue } } - var dictionaryRepresentation:NSDictionary { - return [ - TPPBookmarkDictionaryRepresentation.annotationIdKey: self.annotationId ?? "", - TPPBookmarkDictionaryRepresentation.hrefKey: self.href, - TPPBookmarkDictionaryRepresentation.chapterKey: self.chapter ?? "", - TPPBookmarkDictionaryRepresentation.pageKey: self.page ?? "", - TPPBookmarkDictionaryRepresentation.locationKey: self.location, - TPPBookmarkDictionaryRepresentation.timeKey: self.time, - TPPBookmarkDictionaryRepresentation.deviceKey: self.device ?? "", - TPPBookmarkDictionaryRepresentation.chapterProgressKey: self.progressWithinChapter, - TPPBookmarkDictionaryRepresentation.bookProgressKey: self.progressWithinBook, - TPPBookmarkDictionaryRepresentation.readingOrderItem: self.readingOrderItem ?? "", - TPPBookmarkDictionaryRepresentation.readingOrderItemOffsetMilliseconds: self.readingOrderItemOffsetMilliseconds + var dictionaryRepresentation: NSDictionary { + [ + TPPBookmarkDictionaryRepresentation.annotationIdKey: annotationId ?? "", + TPPBookmarkDictionaryRepresentation.hrefKey: href, + TPPBookmarkDictionaryRepresentation.chapterKey: chapter ?? "", + TPPBookmarkDictionaryRepresentation.pageKey: page ?? "", + TPPBookmarkDictionaryRepresentation.locationKey: location, + TPPBookmarkDictionaryRepresentation.timeKey: time, + TPPBookmarkDictionaryRepresentation.deviceKey: device ?? "", + TPPBookmarkDictionaryRepresentation.chapterProgressKey: progressWithinChapter, + TPPBookmarkDictionaryRepresentation.bookProgressKey: progressWithinBook, + TPPBookmarkDictionaryRepresentation.readingOrderItem: readingOrderItem ?? "", + TPPBookmarkDictionaryRepresentation.readingOrderItemOffsetMilliseconds: readingOrderItemOffsetMilliseconds ] } @@ -151,39 +161,42 @@ protocol Bookmark: NSObject {} return false } - if let id = annotationId, let otherId = other.annotationId, id == otherId { return true } + if let id = annotationId, let otherId = other.annotationId, id == otherId { + return true + } - return self.href == other.href - && self.progressWithinBook =~= other.progressWithinBook - && self.progressWithinChapter =~= other.progressWithinChapter - && self.chapter == other.chapter - && self.readingOrderItem == other.readingOrderItem - && self.readingOrderItemOffsetMilliseconds =~= other.readingOrderItemOffsetMilliseconds + return href == other.href + && progressWithinBook =~= other.progressWithinBook + && progressWithinChapter =~= other.progressWithinChapter + && chapter == other.chapter + && readingOrderItem == other.readingOrderItem + && readingOrderItemOffsetMilliseconds =~= other.readingOrderItemOffsetMilliseconds } } extension TPPReadiumBookmark { override var description: String { - return "\(dictionaryRepresentation)" + "\(dictionaryRepresentation)" } } extension TPPReadiumBookmark { func toJSONDictionary() -> [String: Any] { var dict: [String: Any] = [:] - dict["annotationId"] = self.annotationId - dict["chapter"] = self.chapter - dict["page"] = self.page - dict["href"] = self.href - dict["progressWithinChapter"] = self.progressWithinChapter - dict["progressWithinBook"] = self.progressWithinBook - dict["device"] = self.device - dict["time"] = self.time - dict["readingOrderItemOffsetMilliseconds"] = self.readingOrderItemOffsetMilliseconds - dict["readingOrderItem"] = self.readingOrderItem - - if let locationData = self.location.data(using: .utf8), - let locationDict = try? JSONSerialization.jsonObject(with: locationData, options: []) as? [String: Any] { + dict["annotationId"] = annotationId + dict["chapter"] = chapter + dict["page"] = page + dict["href"] = href + dict["progressWithinChapter"] = progressWithinChapter + dict["progressWithinBook"] = progressWithinBook + dict["device"] = device + dict["time"] = time + dict["readingOrderItemOffsetMilliseconds"] = readingOrderItemOffsetMilliseconds + dict["readingOrderItem"] = readingOrderItem + + if let locationData = location.data(using: .utf8), + let locationDict = try? JSONSerialization.jsonObject(with: locationData, options: []) as? [String: Any] + { for (key, value) in locationDict { dict[key] = value } diff --git a/Palace/Reader2/Bookmarks/TrackPosition+Annotations.swift b/Palace/Reader2/Bookmarks/TrackPosition+Annotations.swift index 142975fc5..10160d215 100644 --- a/Palace/Reader2/Bookmarks/TrackPosition+Annotations.swift +++ b/Palace/Reader2/Bookmarks/TrackPosition+Annotations.swift @@ -17,7 +17,7 @@ public extension TrackPosition { ATLog(.debug, "Warning: Negative timestamp encountered. Defaulting to 0.") offsetMilliseconds = 0 } - + return AudioBookmark( type: .locatorAudioBookTime, version: 2, @@ -27,52 +27,72 @@ public extension TrackPosition { readingOrderItemOffsetMilliseconds: offsetMilliseconds ) } - + init?(audioBookmark: AudioBookmark, toc: [Chapter], tracks: Tracks) { guard audioBookmark.type == .locatorAudioBookTime else { ATLog(.debug, "Unsupported bookmark type: \(audioBookmark.type)") return nil } - + if audioBookmark.version == 2 { guard let readingOrderItem = audioBookmark.readingOrderItem, let readingOrderItemOffsetMilliseconds = audioBookmark.readingOrderItemOffsetMilliseconds, - let track = tracks.track(forKey: readingOrderItem) else { + let track = tracks.track(forKey: readingOrderItem) + else { ATLog(.debug, "Unable to find a valid track for the provided locator.") return nil } let timestamp = Double(readingOrderItemOffsetMilliseconds) / 1000.0 self.init(track: track, timestamp: timestamp, tracks: tracks) } else { - if let initializedFromReadingOrderItem = TrackPosition.initializeFromReadingOrderItem(audioBookmark: audioBookmark, tracks: tracks) { + if let initializedFromReadingOrderItem = TrackPosition.initializeFromReadingOrderItem( + audioBookmark: audioBookmark, + tracks: tracks + ) { self = initializedFromReadingOrderItem - } else if let initializedFromHref = TrackPosition.initializeFromHref(audioBookmark: audioBookmark, toc: toc, tracks: tracks) { + } else if let initializedFromHref = TrackPosition.initializeFromHref( + audioBookmark: audioBookmark, + toc: toc, + tracks: tracks + ) { self = initializedFromHref - } else if let initializedFromPartAndChapter = TrackPosition.initializeFromPartAndChapter(audioBookmark: audioBookmark, toc: toc, tracks: tracks) { + } else if let initializedFromPartAndChapter = TrackPosition.initializeFromPartAndChapter( + audioBookmark: audioBookmark, + toc: toc, + tracks: tracks + ) { self = initializedFromPartAndChapter - } else if let initializedFromChapterIndex = TrackPosition.initializeFromChapterIndex(audioBookmark: audioBookmark, toc: toc) { + } else if let initializedFromChapterIndex = TrackPosition.initializeFromChapterIndex( + audioBookmark: audioBookmark, + toc: toc + ) { self = initializedFromChapterIndex } else { ATLog(.debug, "Unable to find a valid track for the provided locator.") return nil } } - - self.annotationId = audioBookmark.annotationId - self.lastSavedTimeStamp = audioBookmark.lastSavedTimeStamp ?? "" + + annotationId = audioBookmark.annotationId + lastSavedTimeStamp = audioBookmark.lastSavedTimeStamp ?? "" } - + private static func initializeFromReadingOrderItem(audioBookmark: AudioBookmark, tracks: Tracks) -> TrackPosition? { guard let readingOrderItem = audioBookmark.readingOrderItem, let readingOrderItemOffsetMilliseconds = audioBookmark.readingOrderItemOffsetMilliseconds, - let track = tracks.track(forKey: readingOrderItem) ?? tracks.track(forTitle: readingOrderItem) else { + let track = tracks.track(forKey: readingOrderItem) ?? tracks.track(forTitle: readingOrderItem) + else { return nil } let timestamp = Double(readingOrderItemOffsetMilliseconds) / 1000.0 return TrackPosition(track: track, timestamp: timestamp, tracks: tracks) } - - private static func initializeFromHref(audioBookmark: AudioBookmark, toc: [Chapter], tracks: Tracks) -> TrackPosition? { + + private static func initializeFromHref( + audioBookmark: AudioBookmark, + toc _: [Chapter], + tracks: Tracks + ) -> TrackPosition? { guard let href = audioBookmark.readingOrderItem else { return nil } @@ -81,25 +101,32 @@ public extension TrackPosition { return TrackPosition(track: track, timestamp: timestamp, tracks: tracks) } else if let part = audioBookmark.part, let chapter = Int(audioBookmark.chapter ?? ""), - let track = tracks.track(forPart: part, sequence: chapter) { + let track = tracks.track(forPart: part, sequence: chapter) + { return TrackPosition(track: track, timestamp: timestamp, tracks: tracks) } return nil } - - private static func initializeFromPartAndChapter(audioBookmark: AudioBookmark, toc: [Chapter], tracks: Tracks) -> TrackPosition? { + + private static func initializeFromPartAndChapter( + audioBookmark: AudioBookmark, + toc _: [Chapter], + tracks: Tracks + ) -> TrackPosition? { guard let part = audioBookmark.part, let chapter = Int(audioBookmark.chapter ?? ""), - let track = tracks.track(forPart: part, sequence: chapter) else { + let track = tracks.track(forPart: part, sequence: chapter) + else { return nil } let timestamp = Double(audioBookmark.time ?? 0) / 1000.0 return TrackPosition(track: track, timestamp: timestamp, tracks: tracks) } - + private static func initializeFromChapterIndex(audioBookmark: AudioBookmark, toc: [Chapter]) -> TrackPosition? { guard let chapterIndex = Int(audioBookmark.chapter ?? ""), - toc.indices.contains(chapterIndex) else { + toc.indices.contains(chapterIndex) + else { return nil } let track = toc[chapterIndex].position.track diff --git a/Palace/Reader2/BusinessLogic/TPPLastReadPositionPoster.swift b/Palace/Reader2/BusinessLogic/TPPLastReadPositionPoster.swift index 888af69fb..e7b6f978b 100644 --- a/Palace/Reader2/BusinessLogic/TPPLastReadPositionPoster.swift +++ b/Palace/Reader2/BusinessLogic/TPPLastReadPositionPoster.swift @@ -24,21 +24,28 @@ class TPPLastReadPositionPoster { // Internal state management private var lastReadPositionUploadDate: Date private var queuedReadPosition: Locator? - private let serialQueue = DispatchQueue(label: "\(Bundle.main.bundleIdentifier!).lastReadPositionPoster", qos: .utility) - - init(book: TPPBook, - publication: Publication, - bookRegistryProvider: TPPBookRegistryProvider) { + private let serialQueue = DispatchQueue( + label: "\(Bundle.main.bundleIdentifier!).lastReadPositionPoster", + qos: .utility + ) + + init( + book: TPPBook, + publication: Publication, + bookRegistryProvider: TPPBookRegistryProvider + ) { self.book = book self.publication = publication self.bookRegistryProvider = bookRegistryProvider - self.lastReadPositionUploadDate = Date() + lastReadPositionUploadDate = Date() .addingTimeInterval(-TPPLastReadPositionPoster.throttlingInterval) - NotificationCenter.default.addObserver(self, - selector: #selector(postQueuedReadPositionInSerialQueue), - name: UIApplication.willResignActiveNotification, - object: nil) + NotificationCenter.default.addObserver( + self, + selector: #selector(postQueuedReadPositionInSerialQueue), + name: UIApplication.willResignActiveNotification, + object: nil + ) } // MARK: - Storing @@ -46,7 +53,9 @@ class TPPLastReadPositionPoster { /// Stores a new reading progress location on the server. /// - Parameter locator: The new local progress to be stored. func storeReadPosition(locator: Locator) { - guard shouldStore(locator: locator) else { return } + guard shouldStore(locator: locator) else { + return + } // Save location locally let location = TPPBookLocation(locator: locator, type: "LocatorHrefProgression", publication: publication) @@ -67,25 +76,31 @@ class TPPLastReadPositionPoster { /// Requests are throttled to avoid excessive updates. private func postReadPosition(locator: Locator) { serialQueue.async { [weak self] in - guard let self = self else { return } + guard let self = self else { + return + } - self.queuedReadPosition = locator + queuedReadPosition = locator - if Date() > self.lastReadPositionUploadDate.addingTimeInterval(TPPLastReadPositionPoster.throttlingInterval) { - self.postQueuedReadPosition() + if Date() > lastReadPositionUploadDate.addingTimeInterval(TPPLastReadPositionPoster.throttlingInterval) { + postQueuedReadPosition() } } } private func postQueuedReadPosition() { - guard let locator = self.queuedReadPosition, let selectorValue = locator.jsonString else { return } + guard let locator = queuedReadPosition, let selectorValue = locator.jsonString else { + return + } - TPPAnnotations.postReadingPosition(forBook: book.identifier, - selectorValue: selectorValue, - motivation: .readingProgress) + TPPAnnotations.postReadingPosition( + forBook: book.identifier, + selectorValue: selectorValue, + motivation: .readingProgress + ) - self.queuedReadPosition = nil - self.lastReadPositionUploadDate = Date() + queuedReadPosition = nil + lastReadPositionUploadDate = Date() } @objc private func postQueuedReadPositionInSerialQueue() { diff --git a/Palace/Reader2/BusinessLogic/TPPLastReadPositionSynchronizer.swift b/Palace/Reader2/BusinessLogic/TPPLastReadPositionSynchronizer.swift index 47914d107..44ebee2a6 100644 --- a/Palace/Reader2/BusinessLogic/TPPLastReadPositionSynchronizer.swift +++ b/Palace/Reader2/BusinessLogic/TPPLastReadPositionSynchronizer.swift @@ -37,10 +37,12 @@ class TPPLastReadPositionSynchronizer { /// - drmDeviceID: The device ID is used to identify if the last read /// position retrieved from the server was from a different device. /// - completion: Called when syncing is complete. - func sync(for publication: Publication, - book: TPPBook, - drmDeviceID: String?, - completion: @escaping () -> Void) { + func sync( + for publication: Publication, + book: TPPBook, + drmDeviceID: String?, + completion: @escaping () -> Void + ) { Task { await sync(for: publication, book: book, drmDeviceID: drmDeviceID) TPPMainThreadRun.asyncIfNeeded { @@ -49,24 +51,29 @@ class TPPLastReadPositionSynchronizer { } } - func sync(for publication: Publication, - book: TPPBook, - drmDeviceID: String?) async { + func sync( + for publication: Publication, + book: TPPBook, + drmDeviceID: String? + ) async { let serverLocator = await syncReadPosition(for: book, drmDeviceID: drmDeviceID, publication: publication) if let serverLocator = serverLocator { - await presentNavigationAlert(for: serverLocator, - publication: publication, - book: book) + await presentNavigationAlert( + for: serverLocator, + publication: publication, + book: book + ) } } - // MARK:- Private methods + // MARK: - Private methods private func syncReadPosition(for book: TPPBook, drmDeviceID: String?, publication: Publication) async -> Locator? { let localLocation = bookRegistry.location(forIdentifier: book.identifier) - guard let bookmark = await TPPAnnotations.syncReadingPosition(ofBook: book, toURL: TPPAnnotations.annotationsURL) else { + guard let bookmark = await TPPAnnotations.syncReadingPosition(ofBook: book, toURL: TPPAnnotations.annotationsURL) + else { Log.info(#function, "No reading position annotation exists on the server for \(book.loggableShortString()).") return nil } @@ -83,8 +90,8 @@ class TPPLastReadPositionSynchronizer { // 1 - The most recent page on the server comes from the same device and there is no localLocation, or // 2 - The server and the client have the same page marked if (deviceID == drmDeviceID && localLocation != nil) - || localLocation?.locationString == serverLocationString { - + || localLocation?.locationString == serverLocationString + { // Server location does not differ from or should take no precedence // over the local position. return nil @@ -92,15 +99,19 @@ class TPPLastReadPositionSynchronizer { // We got a server location that differs from the local: return that // so that clients can decide what to do. - let bookLocation = TPPBookLocation(locationString: serverLocationString, - renderer: TPPBookLocation.r3Renderer) + let bookLocation = TPPBookLocation( + locationString: serverLocationString, + renderer: TPPBookLocation.r3Renderer + ) return await bookLocation?.convertToLocator(publication: publication) } - private func presentNavigationAlert(for serverLocator: Locator, - publication: Publication, - book: TPPBook, - completion: @escaping () -> Void) { + private func presentNavigationAlert( + for serverLocator: Locator, + publication: Publication, + book: TPPBook, + completion: @escaping () -> Void + ) { Task { await presentNavigationAlert(for: serverLocator, publication: publication, book: book) completion() @@ -108,14 +119,18 @@ class TPPLastReadPositionSynchronizer { } /// Async version of `presentNavigationAlert`. - private func presentNavigationAlert(for serverLocator: Locator, - publication: Publication, - book: TPPBook) async { + private func presentNavigationAlert( + for serverLocator: Locator, + publication: Publication, + book: TPPBook + ) async { await withCheckedContinuation { continuation in DispatchQueue.main.async { - let alert = UIAlertController(title: DisplayStrings.syncReadingPositionAlertTitle, - message: DisplayStrings.syncReadingPositionAlertBody, - preferredStyle: .alert) + let alert = UIAlertController( + title: DisplayStrings.syncReadingPositionAlertTitle, + message: DisplayStrings.syncReadingPositionAlertBody, + preferredStyle: .alert + ) let stayText = DisplayStrings.stay let stayAction = UIAlertAction(title: stayText, style: .cancel) { _ in @@ -124,9 +139,11 @@ class TPPLastReadPositionSynchronizer { let moveText = DisplayStrings.move let moveAction = UIAlertAction(title: moveText, style: .default) { _ in - let loc = TPPBookLocation(locator: serverLocator, - type: "LocatorHrefProgression", - publication: publication) + let loc = TPPBookLocation( + locator: serverLocator, + type: "LocatorHrefProgression", + publication: publication + ) self.bookRegistry.setLocation(loc, forIdentifier: book.identifier) continuation.resume() } diff --git a/Palace/Reader2/BusinessLogic/TPPReaderBookmarksBusinessLogic.swift b/Palace/Reader2/BusinessLogic/TPPReaderBookmarksBusinessLogic.swift index 1b8368fba..b86a900dd 100644 --- a/Palace/Reader2/BusinessLogic/TPPReaderBookmarksBusinessLogic.swift +++ b/Palace/Reader2/BusinessLogic/TPPReaderBookmarksBusinessLogic.swift @@ -7,13 +7,12 @@ // import Foundation -import ReadiumShared import ReadiumNavigator +import ReadiumShared /// Encapsulates all of the SimplyE business logic related to bookmarking /// for a given book. class TPPReaderBookmarksBusinessLogic: NSObject { - var bookmarks: [TPPReadiumBookmark] = [] let book: TPPBook private let publication: Publication @@ -22,20 +21,24 @@ class TPPReaderBookmarksBusinessLogic: NSObject { private let currentLibraryAccountProvider: TPPCurrentLibraryAccountProvider private let bookmarksFactory: TPPBookmarkFactory - init(book: TPPBook, - r2Publication: Publication, - drmDeviceID: String?, - bookRegistryProvider: TPPBookRegistryProvider, - currentLibraryAccountProvider: TPPCurrentLibraryAccountProvider) { + init( + book: TPPBook, + r2Publication: Publication, + drmDeviceID: String?, + bookRegistryProvider: TPPBookRegistryProvider, + currentLibraryAccountProvider: TPPCurrentLibraryAccountProvider + ) { self.book = book - self.publication = r2Publication + publication = r2Publication self.drmDeviceID = drmDeviceID - self.bookRegistry = bookRegistryProvider + bookRegistry = bookRegistryProvider bookmarks = bookRegistryProvider.readiumBookmarks(forIdentifier: book.identifier) self.currentLibraryAccountProvider = currentLibraryAccountProvider - self.bookmarksFactory = TPPBookmarkFactory(book: book, - publication: publication, - drmDeviceID: drmDeviceID) + bookmarksFactory = TPPBookmarkFactory( + book: book, + publication: publication, + drmDeviceID: drmDeviceID + ) super.init() } @@ -57,8 +60,9 @@ class TPPReaderBookmarksBusinessLogic: NSObject { func currentLocation(in navigator: Navigator) -> TPPBookmarkR3Location? { guard let locator = navigator.currentLocation, - let index = publication.resourceIndex(forLocator: locator) else { - return nil + let index = publication.resourceIndex(forLocator: locator) + else { + return nil } return TPPBookmarkR3Location(resourceIndex: index, locator: locator) @@ -73,7 +77,7 @@ class TPPReaderBookmarksBusinessLogic: NSObject { return nil } - return bookmarks.first(where: { $0.locationMatches(currentLocator)}) + return bookmarks.first(where: { $0.locationMatches(currentLocator) }) } /// Creates a new bookmark at the given location for the publication. @@ -87,12 +91,13 @@ class TPPReaderBookmarksBusinessLogic: NSObject { /// lacked progress information. func addBookmark(_ bookmarkLoc: TPPBookmarkR3Location) async -> TPPReadiumBookmark? { guard let bookmark = - await bookmarksFactory.make( - fromR3Location: bookmarkLoc, - usingBookRegistry: bookRegistry, - for: self.book, - publication: publication - ) else { + await bookmarksFactory.make( + fromR3Location: bookmarkLoc, + usingBookRegistry: bookRegistry, + for: book, + publication: publication + ) + else { return nil } @@ -103,16 +108,17 @@ class TPPReaderBookmarksBusinessLogic: NSObject { return bookmark } - + private func postBookmark(_ bookmark: TPPReadiumBookmark) { guard let currentAccount = currentLibraryAccountProvider.currentAccount, let accountDetails = currentAccount.details, - accountDetails.syncPermissionGranted else { - self.bookRegistry.add(bookmark, forIdentifier: book.identifier) - return + accountDetails.syncPermissionGranted + else { + bookRegistry.add(bookmark, forIdentifier: book.identifier) + return } - + TPPAnnotations.postBookmark(bookmark, forBookID: book.identifier) { response in Log.debug(#function, response?.serverId != nil ? "Bookmark upload succeed" : "Bookmark failed to upload") bookmark.annotationId = response?.serverId @@ -122,7 +128,7 @@ class TPPReaderBookmarksBusinessLogic: NSObject { func deleteBookmark(_ bookmark: TPPReadiumBookmark) { var wasDeleted = false - bookmarks.removeAll { + bookmarks.removeAll { let isMatching = $0.isEqual(bookmark) if isMatching { wasDeleted = true @@ -150,17 +156,21 @@ class TPPReaderBookmarksBusinessLogic: NSObject { bookRegistry.delete(bookmark, forIdentifier: book.identifier) guard let currentAccount = currentLibraryAccountProvider.currentAccount, - let details = currentAccount.details, - let annotationId = bookmark.annotationId else { + let details = currentAccount.details, + let annotationId = bookmark.annotationId + else { Log.debug(#file, "Delete on Server skipped: Annotation ID did not exist for bookmark.") return } - + if details.syncPermissionGranted && annotationId.count > 0 { - TPPAnnotations.deleteBookmark(annotationId: annotationId) { (success) in - Log.debug(#file, success ? - "Bookmark successfully deleted" : - "Failed to delete bookmark from server. Will attempt again on next Sync") + TPPAnnotations.deleteBookmark(annotationId: annotationId) { success in + Log.debug( + #file, + success ? + "Bookmark successfully deleted" : + "Failed to delete bookmark from server. Will attempt again on next Sync" + ) } } } @@ -169,66 +179,80 @@ class TPPReaderBookmarksBusinessLogic: NSObject { Strings.TPPReaderBookmarksBusinessLogic.noBookmarks } - func shouldSelectBookmark(at index: Int) -> Bool { - return true + func shouldSelectBookmark(at _: Int) -> Bool { + true } // MARK: - Bookmark Syncing func shouldAllowRefresh() -> Bool { - return TPPAnnotations.syncIsPossibleAndPermitted() + TPPAnnotations.syncIsPossibleAndPermitted() } - - func syncBookmarks(completion: @escaping (Bool, [TPPReadiumBookmark]) -> ()) { - guard Reachability.shared.isConnectedToNetwork() else { - self.handleBookmarksSyncFail(message: "Error: host was not reachable for bookmark sync attempt.", - completion: completion) - return - } - - Log.debug(#file, "Syncing bookmarks...") - // First check for and upload any local bookmarks that have never been saved to the server. - // Wait til that's finished, then download the server's bookmark list and filter out any that can be deleted. - let localBookmarks = self.bookRegistry.readiumBookmarks(forIdentifier: self.book.identifier) - TPPAnnotations.uploadLocalBookmarks(localBookmarks, forBook: self.book.identifier) { (bookmarksUploaded, bookmarksFailedToUpload) in - for localBookmark in localBookmarks { - for uploadedBookmark in bookmarksUploaded { - if localBookmark.isEqual(uploadedBookmark) { - self.bookRegistry.replace(localBookmark, with: uploadedBookmark, forIdentifier: self.book.identifier) + + func syncBookmarks(completion: @escaping (Bool, [TPPReadiumBookmark]) -> Void) { + guard Reachability.shared.isConnectedToNetwork() else { + handleBookmarksSyncFail( + message: "Error: host was not reachable for bookmark sync attempt.", + completion: completion + ) + return + } + + Log.debug(#file, "Syncing bookmarks...") + // First check for and upload any local bookmarks that have never been saved to the server. + // Wait til that's finished, then download the server's bookmark list and filter out any that can be deleted. + let localBookmarks = bookRegistry.readiumBookmarks(forIdentifier: book.identifier) + TPPAnnotations + .uploadLocalBookmarks(localBookmarks, forBook: book.identifier) { bookmarksUploaded, bookmarksFailedToUpload in + for localBookmark in localBookmarks { + for uploadedBookmark in bookmarksUploaded { + if localBookmark.isEqual(uploadedBookmark) { + self.bookRegistry.replace(localBookmark, with: uploadedBookmark, forIdentifier: self.book.identifier) + } } } - } - - TPPAnnotations.getServerBookmarks(forBook: self.book, atURL: self.book.annotationsURL, motivation: .bookmark) { serverBookmarks in - - guard let serverBookmarks = serverBookmarks as? [TPPReadiumBookmark] else { - self.handleBookmarksSyncFail(message: "Ending sync without running completion. Returning original list of bookmarks.", - completion: completion) - return - } - - Log.debug(#file, serverBookmarks.count == 0 ? "No server bookmarks" : "Server bookmarks count: \(serverBookmarks.count)") - - self.updateLocalBookmarks(serverBookmarks: serverBookmarks, - localBookmarks: localBookmarks, - bookmarksFailedToUpload: bookmarksFailedToUpload) - { [weak self] in - guard let self = self else { - completion(false, localBookmarks) - return + + TPPAnnotations + .getServerBookmarks( + forBook: self.book, + atURL: self.book.annotationsURL, + motivation: .bookmark + ) { serverBookmarks in + guard let serverBookmarks = serverBookmarks as? [TPPReadiumBookmark] else { + self.handleBookmarksSyncFail( + message: "Ending sync without running completion. Returning original list of bookmarks.", + completion: completion + ) + return + } + + Log.debug( + #file, + serverBookmarks.count == 0 ? "No server bookmarks" : "Server bookmarks count: \(serverBookmarks.count)" + ) + + self.updateLocalBookmarks( + serverBookmarks: serverBookmarks, + localBookmarks: localBookmarks, + bookmarksFailedToUpload: bookmarksFailedToUpload + ) { [weak self] in + guard let self = self else { + completion(false, localBookmarks) + return + } + bookmarks = bookRegistry.readiumBookmarks(forIdentifier: book.identifier) + completion(true, bookmarks) + } } - self.bookmarks = self.bookRegistry.readiumBookmarks(forIdentifier: self.book.identifier) - completion(true, self.bookmarks) - } } - } } - - func updateLocalBookmarks(serverBookmarks: [TPPReadiumBookmark], - localBookmarks: [TPPReadiumBookmark], - bookmarksFailedToUpload: [TPPReadiumBookmark], - completion: @escaping () -> ()) - { + + func updateLocalBookmarks( + serverBookmarks: [TPPReadiumBookmark], + localBookmarks: [TPPReadiumBookmark], + bookmarksFailedToUpload: [TPPReadiumBookmark], + completion: @escaping () -> Void + ) { var localBookmarksToKeep = [TPPReadiumBookmark]() var serverBookmarksToAdd = [TPPReadiumBookmark]() + bookmarksFailedToUpload var serverBookmarksToDelete = [TPPReadiumBookmark]() @@ -237,7 +261,7 @@ class TPPReaderBookmarksBusinessLogic: NSObject { if let localBookmark = localBookmarks.first(where: { $0.annotationId == serverBookmark.annotationId }) { localBookmarksToKeep.append(localBookmark) } else { - serverBookmarksToAdd.append(serverBookmark) + serverBookmarksToAdd.append(serverBookmark) } } @@ -252,7 +276,7 @@ class TPPReaderBookmarksBusinessLogic: NSObject { // Add missing bookmarks from server for bookmark in serverBookmarksToAdd { - bookRegistry.add(bookmark, forIdentifier: self.book.identifier) + bookRegistry.add(bookmark, forIdentifier: book.identifier) } // Remove locally deleted bookmarks from the server @@ -261,11 +285,13 @@ class TPPReaderBookmarksBusinessLogic: NSObject { completion() } - private func handleBookmarksSyncFail(message: String, - completion: @escaping (Bool, [TPPReadiumBookmark]) -> ()) { + private func handleBookmarksSyncFail( + message: String, + completion: @escaping (Bool, [TPPReadiumBookmark]) -> Void + ) { Log.info(#file, message) - - self.bookmarks = self.bookRegistry.readiumBookmarks(forIdentifier: self.book.identifier) - completion(false, self.bookmarks) + + bookmarks = bookRegistry.readiumBookmarks(forIdentifier: book.identifier) + completion(false, bookmarks) } } diff --git a/Palace/Reader2/BusinessLogic/TPPReaderTOCBusinessLogic.swift b/Palace/Reader2/BusinessLogic/TPPReaderTOCBusinessLogic.swift index 35de855d4..4a4d53359 100644 --- a/Palace/Reader2/BusinessLogic/TPPReaderTOCBusinessLogic.swift +++ b/Palace/Reader2/BusinessLogic/TPPReaderTOCBusinessLogic.swift @@ -11,6 +11,8 @@ import ReadiumShared typealias TPPReaderTOCLink = (level: Int, link: Link) +// MARK: - TPPReaderTOCBusinessLogic + /// This class captures the business logic related to the Table Of Contents /// for a given Readium 2 Publication. class TPPReaderTOCBusinessLogic { @@ -19,13 +21,13 @@ class TPPReaderTOCBusinessLogic { private let currentLocation: Locator? // for current chapter init(r2Publication: Publication, currentLocation: Locator?) { - self.publication = r2Publication + publication = r2Publication self.currentLocation = currentLocation Task { let tocResult = await publication.tableOfContents() switch tocResult { - case .success(let toc): + case let .success(toc): self.tocElements = flatten(toc) case .failure: return @@ -34,7 +36,7 @@ class TPPReaderTOCBusinessLogic { } private func flatten(_ links: [Link], level: Int = 0) -> [(level: Int, link: Link)] { - return links.flatMap { [(level, $0)] + flatten($0.children, level: level + 1) } + links.flatMap { [(level, $0)] + flatten($0.children, level: level + 1) } } var tocDisplayTitle: String { @@ -59,7 +61,7 @@ class TPPReaderTOCBusinessLogic { func titleAndLevel(forItemAt index: Int) -> (title: String, level: Int) { let item = tocElements[index] - return (title: (item.link.title ?? item.link.href), level: item.level) + return (title: item.link.title ?? item.link.href, level: item.level) } func title(for href: String) -> String? { diff --git a/Palace/Reader2/Internal/Publication+NYPLAdditions.swift b/Palace/Reader2/Internal/Publication+NYPLAdditions.swift index 26372922d..71293924c 100644 --- a/Palace/Reader2/Internal/Publication+NYPLAdditions.swift +++ b/Palace/Reader2/Internal/Publication+NYPLAdditions.swift @@ -10,7 +10,6 @@ import Foundation import ReadiumShared extension Publication { - /// Obtains a R2 Link object from a given ID reference. /// /// This for example can be used to get the link object related to a R1 @@ -27,7 +26,7 @@ extension Publication { // The Publication stores all bookmarks (and TOC; positions in general) in // the `readingOrder` list. Each `Link` stores its metadata in a // `properties` dictionary. - return readingOrder.first { $0.properties["id"] as? String == idref } + readingOrder.first { $0.properties["id"] as? String == idref } } /// Derives the `idref` (often used in Readium 1) from a Readium 2 `href`. @@ -50,7 +49,7 @@ extension Publication { /// Shortcut helper to get the publication ID. var id: String? { - return metadata.identifier + metadata.identifier } /// Shortcut to get the resource index (stored within internal R2 data diff --git a/Palace/Reader2/ReaderPresentation/ReaderError.swift b/Palace/Reader2/ReaderPresentation/ReaderError.swift index 7f412f51d..e79b0d368 100644 --- a/Palace/Reader2/ReaderPresentation/ReaderError.swift +++ b/Palace/Reader2/ReaderPresentation/ReaderError.swift @@ -15,14 +15,13 @@ import Foundation enum ReaderError: LocalizedError { case formatNotSupported case epubNotValid - + var errorDescription: String? { switch self { case .formatNotSupported: - return Strings.Error.formatNotSupportedError + Strings.Error.formatNotSupportedError case .epubNotValid: - return Strings.Error.epubNotValidError + Strings.Error.epubNotValidError } } - } diff --git a/Palace/Reader2/ReaderPresentation/ReaderModule.swift b/Palace/Reader2/ReaderPresentation/ReaderModule.swift index 6aa451ecc..ca70f2443 100644 --- a/Palace/Reader2/ReaderPresentation/ReaderModule.swift +++ b/Palace/Reader2/ReaderPresentation/ReaderModule.swift @@ -11,9 +11,10 @@ // import Foundation -import UIKit import ReadiumShared +import UIKit +// MARK: - ModuleDelegate /// Base module delegate, that sub-modules' delegate can extend. /// Provides basic shared functionalities. @@ -22,33 +23,37 @@ protocol ModuleDelegate: AnyObject { func presentError(_ error: Error?, from viewController: UIViewController) } -// MARK:- +// MARK: - ReaderModuleAPI + +// MARK: - /// The ReaderModuleAPI declares what is needed to handle the presentation /// of a publication. protocol ReaderModuleAPI { - var delegate: ModuleDelegate? { get } - + /// Presents the given publication to the user, inside the given navigation controller. /// - Parameter publication: The R2 publication to display. /// - Parameter book: Our internal book model related to the `publication`. /// - Parameter navigationController: The navigation stack the book will be presented in. /// - Parameter completion: Called once the publication is presented, or if an error occured. - func presentPublication(_ publication: Publication, - book: TPPBook, - in navigationController: UINavigationController, - forSample: Bool) + func presentPublication( + _ publication: Publication, + book: TPPBook, + in navigationController: UINavigationController, + forSample: Bool + ) } -// MARK:- +// MARK: - ReaderModule + +// MARK: - /// The ReaderModule handles the presentation of a publication. /// /// It contains sub-modules implementing `ReaderFormatModule` to handle each /// publication format (e.g. EPUB, PDF, etc). final class ReaderModule: ReaderModuleAPI { - weak var delegate: ModuleDelegate? private let bookRegistry: TPPBookRegistryProvider private let progressSynchronizer: TPPLastReadPositionSynchronizer @@ -56,54 +61,65 @@ final class ReaderModule: ReaderModuleAPI { /// Sub-modules to handle different publication formats (eg. EPUB, CBZ) var formatModules: [ReaderFormatModule] = [] - init(delegate: ModuleDelegate?, - resourcesServer: HTTPServer, - bookRegistry: TPPBookRegistryProvider) { + init( + delegate: ModuleDelegate?, + resourcesServer: HTTPServer, + bookRegistry: TPPBookRegistryProvider + ) { self.delegate = delegate self.bookRegistry = bookRegistry - self.progressSynchronizer = TPPLastReadPositionSynchronizer(bookRegistry: bookRegistry) + progressSynchronizer = TPPLastReadPositionSynchronizer(bookRegistry: bookRegistry) formatModules = [ EPUBModule(delegate: self.delegate, resourcesServer: resourcesServer) ] } - func presentPublication(_ publication: Publication, - book: TPPBook, - in navigationController: UINavigationController, - forSample: Bool = false) { + func presentPublication( + _ publication: Publication, + book: TPPBook, + in navigationController: UINavigationController, + forSample: Bool = false + ) { if delegate == nil { TPPErrorLogger.logError(nil, summary: "ReaderModule delegate is not set") } - guard let formatModule = self.formatModules.first(where:{ $0.supports(publication) }) else { + guard let formatModule = formatModules.first(where: { $0.supports(publication) }) else { delegate?.presentError(ReaderError.formatNotSupported, from: navigationController) return } let drmDeviceID = TPPUserAccount.sharedAccount().deviceID - progressSynchronizer.sync(for: publication, - book: book, - drmDeviceID: drmDeviceID) { [weak self] in - - self?.finalizePresentation(for: publication, - book: book, - formatModule: formatModule, - in: navigationController, - forSample: forSample) + progressSynchronizer.sync( + for: publication, + book: book, + drmDeviceID: drmDeviceID + ) { [weak self] in + self?.finalizePresentation( + for: publication, + book: book, + formatModule: formatModule, + in: navigationController, + forSample: forSample + ) } } - func finalizePresentation(for publication: Publication, - book: TPPBook, - formatModule: ReaderFormatModule, - in navigationController: UINavigationController, - forSample: Bool = false) { + func finalizePresentation( + for publication: Publication, + book: TPPBook, + formatModule: ReaderFormatModule, + in navigationController: UINavigationController, + forSample: Bool = false + ) { Task.detached { [weak self] in - guard let self else { return } + guard let self else { + return + } do { - let lastSavedLocation = self.bookRegistry.location(forIdentifier: book.identifier) + let lastSavedLocation = bookRegistry.location(forIdentifier: book.identifier) let initialLocator = await lastSavedLocation?.convertToLocator(publication: publication) let readerVC = try await formatModule.makeReaderViewController( diff --git a/Palace/Reader2/ReaderPresentation/ReadingFormats/EPUBModule.swift b/Palace/Reader2/ReaderPresentation/ReadingFormats/EPUBModule.swift index a2d6d0af3..01343ee3e 100644 --- a/Palace/Reader2/ReaderPresentation/ReadingFormats/EPUBModule.swift +++ b/Palace/Reader2/ReaderPresentation/ReadingFormats/EPUBModule.swift @@ -11,11 +11,10 @@ // import Foundation -import UIKit import ReadiumShared +import UIKit final class EPUBModule: ReaderFormatModule { - weak var delegate: ModuleDelegate? let resourcesServer: HTTPServer @@ -23,28 +22,31 @@ final class EPUBModule: ReaderFormatModule { self.delegate = delegate self.resourcesServer = resourcesServer } - + func supports(_ publication: Publication) -> Bool { // .allAreHTML matches .wepub format - return publication.conforms(to: .epub) || publication.readingOrder.allAreHTML + publication.conforms(to: .epub) || publication.readingOrder.allAreHTML } @MainActor - func makeReaderViewController(for publication: Publication, - book: TPPBook, - initialLocation: Locator?, - forSample: Bool = false) throws -> UIViewController { - + func makeReaderViewController( + for publication: Publication, + book: TPPBook, + initialLocation: Locator?, + forSample: Bool = false + ) throws -> UIViewController { guard publication.metadata.identifier != nil else { throw ReaderError.epubNotValid } - let epubVC = try TPPEPUBViewController(publication: publication, - book: book, - initialLocation: initialLocation, - resourcesServer: resourcesServer, - forSample: forSample) - epubVC.moduleDelegate = delegate - return epubVC - } + let epubVC = try TPPEPUBViewController( + publication: publication, + book: book, + initialLocation: initialLocation, + resourcesServer: resourcesServer, + forSample: forSample + ) + epubVC.moduleDelegate = delegate + return epubVC + } } diff --git a/Palace/Reader2/ReaderPresentation/ReadingFormats/ReaderFormatModule.swift b/Palace/Reader2/ReaderPresentation/ReadingFormats/ReaderFormatModule.swift index c01a2fe81..7c9c0975b 100644 --- a/Palace/Reader2/ReaderPresentation/ReadingFormats/ReaderFormatModule.swift +++ b/Palace/Reader2/ReaderPresentation/ReadingFormats/ReaderFormatModule.swift @@ -11,25 +11,23 @@ // import Foundation -import UIKit import ReadiumShared - +import UIKit /// A ReaderFormatModule handles presentation of publications in a /// given format (eg. EPUB, CBZ). protocol ReaderFormatModule { - var delegate: ModuleDelegate? { get } - + /// Returns whether the given publication is supported by this module. func supports(_ publication: Publication) -> Bool /// Creates the view controller to present the publication. @MainActor - func makeReaderViewController(for publication: Publication, - book: TPPBook, - initialLocation: Locator?, - forSample: Bool) throws -> UIViewController - + func makeReaderViewController( + for publication: Publication, + book: TPPBook, + initialLocation: Locator?, + forSample: Bool + ) throws -> UIViewController } - diff --git a/Palace/Reader2/ReaderSettings/TPPAssociatedColors.swift b/Palace/Reader2/ReaderSettings/TPPAssociatedColors.swift index 83194e5ba..f5cd34b6c 100644 --- a/Palace/Reader2/ReaderSettings/TPPAssociatedColors.swift +++ b/Palace/Reader2/ReaderSettings/TPPAssociatedColors.swift @@ -1,8 +1,10 @@ import Foundation -import ReadiumShared import ReadiumNavigator +import ReadiumShared import UIKit +// MARK: - TPPAppearanceColors + struct TPPAppearanceColors { let backgroundColor: UIColor let backgroundMediaOverlayHighlightColor: UIColor @@ -51,6 +53,8 @@ struct TPPAppearanceColors { } } +// MARK: - TPPAssociatedColors + class TPPAssociatedColors { static let shared = TPPAssociatedColors() @@ -71,11 +75,11 @@ class TPPAssociatedColors { static func colors(for theme: Theme) -> TPPAppearanceColors { switch theme { case .sepia: - return .blackOnSepiaColors + .blackOnSepiaColors case .dark: - return .whiteOnBlackColors + .whiteOnBlackColors default: - return .blackOnWhiteColors + .blackOnWhiteColors } } } diff --git a/Palace/Reader2/ReaderSettings/TPPReaderAppearance.swift b/Palace/Reader2/ReaderSettings/TPPReaderAppearance.swift index 0b941d92c..abef6345a 100644 --- a/Palace/Reader2/ReaderSettings/TPPReaderAppearance.swift +++ b/Palace/Reader2/ReaderSettings/TPPReaderAppearance.swift @@ -8,32 +8,34 @@ import Foundation -enum TPPReaderAppearance: Int, CaseIterable, Identifiable { - case blackOnWhite, blackOnSepia, whiteOnBlack - +enum TPPReaderAppearance: Int, CaseIterable, Identifiable { + case blackOnWhite + case blackOnSepia + case whiteOnBlack + typealias DisplayStrings = Strings.TPPReaderAppearance var id: Int { rawValue } - + var propertyIndex: Int { rawValue } - + var associatedColors: TPPAppearanceColors { switch self { - case .blackOnWhite: return .blackOnWhiteColors - case .blackOnSepia: return .blackOnSepiaColors - case .whiteOnBlack: return .whiteOnBlackColors + case .blackOnWhite: .blackOnWhiteColors + case .blackOnSepia: .blackOnSepiaColors + case .whiteOnBlack: .whiteOnBlackColors } } - + var accessibilityText: String { switch self { - case .blackOnWhite: return DisplayStrings.blackOnWhiteText - case .blackOnSepia: return DisplayStrings.blackOnSepiaText - case .whiteOnBlack: return DisplayStrings.whiteOnBlackText + case .blackOnWhite: DisplayStrings.blackOnWhiteText + case .blackOnSepia: DisplayStrings.blackOnSepiaText + case .whiteOnBlack: DisplayStrings.whiteOnBlackText } } } diff --git a/Palace/Reader2/ReaderSettings/TPPReaderFont.swift b/Palace/Reader2/ReaderSettings/TPPReaderFont.swift index f5178d24d..91b92d8bc 100644 --- a/Palace/Reader2/ReaderSettings/TPPReaderFont.swift +++ b/Palace/Reader2/ReaderSettings/TPPReaderFont.swift @@ -13,21 +13,21 @@ enum TPPReaderFont: String, CaseIterable, Identifiable { case sansSerif = "Helvetica" case serif = "Georgia" case dyslexic = "OpenDyslexic" - + typealias DisplayStrings = Strings.TPPReaderFont var id: String { rawValue } - + /// Font size for preview var previewSize: CGFloat { switch self { - case .dyslexic: return 20.0 - default: return 24.0 + case .dyslexic: 20.0 + default: 24.0 } } - + /// Property index returns non-optional element index var propertyIndex: Int { guard let index = TPPReaderFont.allCases.firstIndex(of: self) else { @@ -35,15 +35,15 @@ enum TPPReaderFont: String, CaseIterable, Identifiable { } return index } - + /// UIFont object for TPPReaderFont element private var uiFont: UIFont? { switch self { - case .dyslexic: return UIFont(name: "OpenDyslexic3", size: previewSize) // "OpenDyslexic" in Readium2 - default: return UIFont(name: rawValue, size: previewSize) + case .dyslexic: UIFont(name: "OpenDyslexic3", size: previewSize) // "OpenDyslexic" in Readium2 + default: UIFont(name: rawValue, size: previewSize) } } - + /// SwiftUI Font structure for TPPReaderFont element var font: Font? { if let uiFont = uiFont { @@ -51,14 +51,14 @@ enum TPPReaderFont: String, CaseIterable, Identifiable { } return nil } - + /// Accessibility text for accessibility labels var accessibilityText: String { switch self { - case .original: return DisplayStrings.original - case .sansSerif: return DisplayStrings.sans - case .serif: return DisplayStrings.serif - case .dyslexic: return DisplayStrings.dyslexic + case .original: DisplayStrings.original + case .sansSerif: DisplayStrings.sans + case .serif: DisplayStrings.serif + case .dyslexic: DisplayStrings.dyslexic } } } diff --git a/Palace/Reader2/ReaderSettings/TPPReaderSettings.swift b/Palace/Reader2/ReaderSettings/TPPReaderSettings.swift index 36194b483..2636f5342 100644 --- a/Palace/Reader2/ReaderSettings/TPPReaderSettings.swift +++ b/Palace/Reader2/ReaderSettings/TPPReaderSettings.swift @@ -1,6 +1,8 @@ -import SwiftUI -import ReadiumShared import ReadiumNavigator +import ReadiumShared +import SwiftUI + +// MARK: - TPPReaderSettings @MainActor class TPPReaderSettings: ObservableObject { @@ -33,12 +35,12 @@ class TPPReaderSettings: ObservableObject { self.delegate = delegate // Initialize font size - self.fontSize = Float(preferences.fontSize ?? 0.5) + fontSize = Float(preferences.fontSize ?? 0.5) screenBrightness = UIScreen.main.brightness // Set font and appearance based on initial preferences - self.fontFamilyIndex = TPPReaderSettings.mapFontFamilyToIndex(preferences.fontFamily) - self.appearanceIndex = TPPReaderSettings.mapAppearanceToIndex(preferences.theme) + fontFamilyIndex = TPPReaderSettings.mapFontFamilyToIndex(preferences.fontFamily) + appearanceIndex = TPPReaderSettings.mapAppearanceToIndex(preferences.theme) updateColors(for: TPPReaderAppearance(rawValue: appearanceIndex) ?? .blackOnWhite) } @@ -51,7 +53,9 @@ class TPPReaderSettings: ObservableObject { // Font size increase method func increaseFontSize() { - guard canIncreaseFontSize else { return } + guard canIncreaseFontSize else { + return + } fontSize = min(fontSize + fontSizeStep, maxFontSize) preferences.fontSize = Double(fontSize) delegate?.updateUserPreferencesStyle(for: preferences) @@ -60,7 +64,9 @@ class TPPReaderSettings: ObservableObject { // Font size decrease method func decreaseFontSize() { - guard canDecreaseFontSize else { return } + guard canDecreaseFontSize else { + return + } fontSize = max(fontSize - fontSizeStep, minFontSize) preferences.fontSize = Double(fontSize) delegate?.updateUserPreferencesStyle(for: preferences) @@ -107,7 +113,8 @@ class TPPReaderSettings: ObservableObject { static func loadPreferences() -> EPUBPreferences { if let data = UserDefaults.standard.data(forKey: TPPReaderSettings.preferencesKey), - let preferences = try? JSONDecoder().decode(EPUBPreferences.self, from: data) { + let preferences = try? JSONDecoder().decode(EPUBPreferences.self, from: data) + { return preferences } return EPUBPreferences() @@ -116,36 +123,36 @@ class TPPReaderSettings: ObservableObject { // Mapping helper for font families static func mapFontFamilyToIndex(_ fontFamily: FontFamily?) -> Int { switch fontFamily { - case .some(.sansSerif): return TPPReaderFont.sansSerif.propertyIndex - case .some(.serif): return TPPReaderFont.serif.propertyIndex - case .some(.openDyslexic): return TPPReaderFont.dyslexic.propertyIndex - default: return TPPReaderFont.original.propertyIndex + case .some(.sansSerif): TPPReaderFont.sansSerif.propertyIndex + case .some(.serif): TPPReaderFont.serif.propertyIndex + case .some(.openDyslexic): TPPReaderFont.dyslexic.propertyIndex + default: TPPReaderFont.original.propertyIndex } } // Mapping helper for appearance themes static func mapAppearanceToIndex(_ theme: Theme?) -> Int { switch theme { - case .dark: return TPPReaderAppearance.whiteOnBlack.propertyIndex - case .sepia: return TPPReaderAppearance.blackOnSepia.propertyIndex - default: return TPPReaderAppearance.blackOnWhite.propertyIndex + case .dark: TPPReaderAppearance.whiteOnBlack.propertyIndex + case .sepia: TPPReaderAppearance.blackOnSepia.propertyIndex + default: TPPReaderAppearance.blackOnWhite.propertyIndex } } static func mapIndexToAppearance(_ index: Int) -> Theme { switch index { - case TPPReaderAppearance.whiteOnBlack.propertyIndex: return .dark - case TPPReaderAppearance.blackOnSepia.propertyIndex: return .sepia - default: return .light + case TPPReaderAppearance.whiteOnBlack.propertyIndex: .dark + case TPPReaderAppearance.blackOnSepia.propertyIndex: .sepia + default: .light } } static func mapIndexToFontFamily(_ index: Int) -> FontFamily? { switch index { - case TPPReaderFont.sansSerif.propertyIndex: return .sansSerif - case TPPReaderFont.serif.propertyIndex: return .serif - case TPPReaderFont.dyslexic.propertyIndex: return .openDyslexic - default: return nil + case TPPReaderFont.sansSerif.propertyIndex: .sansSerif + case TPPReaderFont.serif.propertyIndex: .serif + case TPPReaderFont.dyslexic.propertyIndex: .openDyslexic + default: nil } } } @@ -154,7 +161,8 @@ class TPPReaderSettings: ObservableObject { func TPPReaderPreferencesLoad() -> EPUBPreferences { let key = "TPPReaderSettings" if let data = UserDefaults.standard.data(forKey: key), - let preferences = try? JSONDecoder().decode(EPUBPreferences.self, from: data) { + let preferences = try? JSONDecoder().decode(EPUBPreferences.self, from: data) + { return preferences } return EPUBPreferences() diff --git a/Palace/Reader2/ReaderSettings/TPPReaderSettingsVC.swift b/Palace/Reader2/ReaderSettings/TPPReaderSettingsVC.swift index 57e2201a3..ffceada89 100644 --- a/Palace/Reader2/ReaderSettings/TPPReaderSettingsVC.swift +++ b/Palace/Reader2/ReaderSettings/TPPReaderSettingsVC.swift @@ -6,10 +6,12 @@ // Copyright © 2022 The Palace Project. All rights reserved. // -import UIKit -import SwiftUI import ReadiumNavigator import ReadiumShared +import SwiftUI +import UIKit + +// MARK: - TPPReaderSettingsDelegate protocol TPPReaderSettingsDelegate: AnyObject { func getUserPreferences() -> EPUBPreferences @@ -17,6 +19,8 @@ protocol TPPReaderSettingsDelegate: AnyObject { func setUIColor(for appearance: EPUBPreferences) } +// MARK: - TPPReaderSettingsVC + class TPPReaderSettingsVC: UIViewController { static func makeSwiftUIView(preferences: EPUBPreferences, delegate: TPPReaderSettingsDelegate) -> UIViewController { let readerSettings = TPPReaderSettings(preferences: preferences, delegate: delegate) diff --git a/Palace/Reader2/ReaderSettings/TPPReaderSettingsView.swift b/Palace/Reader2/ReaderSettings/TPPReaderSettingsView.swift index 5c7104f97..d4c64be98 100644 --- a/Palace/Reader2/ReaderSettings/TPPReaderSettingsView.swift +++ b/Palace/Reader2/ReaderSettings/TPPReaderSettingsView.swift @@ -6,18 +6,19 @@ // Copyright © 2022 The Palace Project. All rights reserved. // -import SwiftUI -import ReadiumShared -import ReadiumNavigator import CryptoKit +import ReadiumNavigator +import ReadiumShared +import SwiftUI /// Height of settings view controls -fileprivate let buttonHeight = 50.0 +private let buttonHeight = 50.0 + +// MARK: - TPPReaderSettingsView struct TPPReaderSettingsView: View { - @ObservedObject var settings: TPPReaderSettings - + var body: some View { VStack(spacing: 0) { fontButtons @@ -36,7 +37,7 @@ struct TPPReaderSettingsView: View { .foregroundColor(Color(settings.backgroundColor)) ) } - + /// Set of font family buttons @ViewBuilder var fontButtons: some View { @@ -51,14 +52,14 @@ struct TPPReaderSettingsView: View { .buttonStyle(SettingsButtonStyle(settings: settings)) .font(readerFont.font) - if (readerFont.propertyIndex != TPPReaderFont.allCases.last?.propertyIndex) { + if readerFont.propertyIndex != TPPReaderFont.allCases.last?.propertyIndex { Divider() .frame(height: buttonHeight) } } } } - + /// Set of reader appearance buttons @ViewBuilder var appearanceButtons: some View { @@ -77,14 +78,14 @@ struct TPPReaderSettingsView: View { .foregroundColor(Color(readerAppearance.associatedColors.backgroundColor)) ) - if (readerAppearance.propertyIndex != TPPReaderAppearance.allCases.last?.propertyIndex) { + if readerAppearance.propertyIndex != TPPReaderAppearance.allCases.last?.propertyIndex { Divider() .frame(height: buttonHeight) } } } } - + /// Buttons to decrease and increase the size of text font var fontSizeButtons: some View { HStack(alignment: .center, spacing: 0) { @@ -116,7 +117,7 @@ struct TPPReaderSettingsView: View { .accessibility(label: Text("IncreaseFontSize")) } } - + /// "A" text element for font size buttons /// /// Font size is number rather than style to avoid scaling. @@ -128,20 +129,20 @@ struct TPPReaderSettingsView: View { Text("A") .font(.system(size: size, weight: .medium, design: .rounded)) } - + /// Screen brightness control var brightnessControl: some View { HStack(spacing: 0) { Image(systemName: "sun.min") .foregroundColor(Color(settings.textColor)) - + Slider( value: $settings.screenBrightness, in: 0...1.0, step: 0.01 ) - .accentColor(Color(settings.textColor)) - .accessibility(label: Text("BrightnessSlider")) + .accentColor(Color(settings.textColor)) + .accessibility(label: Text("BrightnessSlider")) Image(systemName: "sun.max") .imageScale(.large) @@ -154,7 +155,7 @@ struct TPPReaderSettingsView: View { } } } - + /// Opacity for images depending on the model variable state /// - Parameter state: Boolean variable, enabled or disabled /// - Returns: opacity for the state @@ -163,12 +164,13 @@ struct TPPReaderSettingsView: View { } } +// MARK: - SettingsButtonStyle + struct SettingsButtonStyle: ButtonStyle { - @ObservedObject var settings: TPPReaderSettings - + var textColor: UIColor? - + func makeBody(configuration: Configuration) -> some View { configuration.label .foregroundColor(Color(textColor ?? settings.textColor)) @@ -177,6 +179,8 @@ struct SettingsButtonStyle: ButtonStyle { } } +// MARK: - TPPReaderSettingsView_Previews + struct TPPReaderSettingsView_Previews: PreviewProvider { static var previews: some View { TPPReaderSettingsView(settings: TPPReaderSettings()) diff --git a/Palace/Reader2/ReaderStackConfiguration/AdobeDRM/AdobeCertificate.swift b/Palace/Reader2/ReaderStackConfiguration/AdobeDRM/AdobeCertificate.swift index 5c4db7af5..af10b88c7 100644 --- a/Palace/Reader2/ReaderStackConfiguration/AdobeDRM/AdobeCertificate.swift +++ b/Palace/Reader2/ReaderStackConfiguration/AdobeDRM/AdobeCertificate.swift @@ -14,12 +14,11 @@ import Foundation /// /// Includes only fields Palace checks to verify the certificate is not expired. @objc class AdobeCertificate: NSObject, Codable { - /// Certificate expiration date, seconds since UNIX epoch. /// /// This field is not present in production certificates. let expireson: UInt? - + /// Initializes certificate data init(expireson: UInt?) { self.expireson = expireson @@ -27,7 +26,6 @@ import Foundation } extension AdobeCertificate { - /// Certificate expiration date. @objc var expirationDate: Date? { guard let expireson = expireson else { @@ -35,7 +33,7 @@ extension AdobeCertificate { } return Date(timeIntervalSince1970: Double(expireson)) } - + /// Returns `true` if certificate has already expired. /// /// If expiration date is not present in certificate data, returns `false` @@ -45,13 +43,15 @@ extension AdobeCertificate { } return expirationDate.timeIntervalSinceNow <= 0 } - + /// Default certificate for Palace app. @objc static var defaultCertificate: AdobeCertificate? = { - let bundle: Bundle = (ProcessInfo.processInfo.environment["XCTestConfigurationFilePath"] != nil) ? Bundle(for: TPPAppDelegate.self) : Bundle.main + let bundle: Bundle = (ProcessInfo.processInfo.environment["XCTestConfigurationFilePath"] != nil) ? + Bundle(for: TPPAppDelegate.self) : Bundle.main guard let adobeCertUrl = bundle.url(forResource: "ReaderClientCert", withExtension: "sig"), let adobeCertData = try? Data(contentsOf: adobeCertUrl), - !adobeCertData.isEmpty else { + !adobeCertData.isEmpty + else { return nil } return AdobeCertificate(data: adobeCertData) @@ -66,10 +66,10 @@ extension AdobeCertificate { return nil } } - + /// Period of notification for expired Adobe DRM certificate fileprivate static let notificationPeriod: TimeInterval = 60 * 60 - + /// Last expired DRM certificate notification date fileprivate static var notificationDate: Date? @@ -84,7 +84,6 @@ extension AdobeCertificate { return true } } - } #endif diff --git a/Palace/Reader2/ReaderStackConfiguration/AdobeDRM/AdobeContentProtectionService.swift b/Palace/Reader2/ReaderStackConfiguration/AdobeDRM/AdobeContentProtectionService.swift index ba49d91c1..e9a0db1d2 100644 --- a/Palace/Reader2/ReaderStackConfiguration/AdobeDRM/AdobeContentProtectionService.swift +++ b/Palace/Reader2/ReaderStackConfiguration/AdobeDRM/AdobeContentProtectionService.swift @@ -9,9 +9,9 @@ #if FEATURE_DRM_CONNECTOR import Foundation +import ReadiumNavigator import ReadiumShared import ReadiumStreamer -import ReadiumNavigator /// Provides information about a publication's content protection and manages user rights. final class AdobeContentProtectionService: ContentProtectionService { @@ -20,12 +20,12 @@ final class AdobeContentProtectionService: ContentProtectionService { init(context: PublicationServiceContext) { self.context = context - self.error = nil + error = nil // Remove epubDecodingError reference and check if the container is an AdobeDRMContainer. if let adobeContainer = context.container as? AdobeDRMContainer { if let drmError = adobeContainer.epubDecodingError { - self.error = NSError( + error = NSError( domain: "Adobe DRM decoding error", code: TPPErrorCode.adobeDRMFulfillmentFail.rawValue, userInfo: ["AdobeDRMContainer error msg": drmError] @@ -51,7 +51,6 @@ final class AdobeContentProtectionService: ContentProtectionService { var scheme: ContentProtectionScheme { .adept } - } #endif diff --git a/Palace/Reader2/ReaderStackConfiguration/AdobeDRM/AdobeDRMAlerts.swift b/Palace/Reader2/ReaderStackConfiguration/AdobeDRM/AdobeDRMAlerts.swift index f32d1ad87..c787438d6 100644 --- a/Palace/Reader2/ReaderStackConfiguration/AdobeDRM/AdobeDRMAlerts.swift +++ b/Palace/Reader2/ReaderStackConfiguration/AdobeDRM/AdobeDRMAlerts.swift @@ -10,9 +10,15 @@ import Foundation extension TPPAlertUtils { @objc class func expiredAdobeDRMAlert() -> UIAlertController { - return TPPAlertUtils.alert( - title: NSLocalizedString("Something went wrong with the Adobe DRM system", comment: "Expired DRM certificate title"), - message: NSLocalizedString("Some books will be unavailable in this version. Please try updating to the latest version of the application.", comment: "Expired DRM certificate message") + TPPAlertUtils.alert( + title: NSLocalizedString( + "Something went wrong with the Adobe DRM system", + comment: "Expired DRM certificate title" + ), + message: NSLocalizedString( + "Some books will be unavailable in this version. Please try updating to the latest version of the application.", + comment: "Expired DRM certificate message" + ) ) } } diff --git a/Palace/Reader2/ReaderStackConfiguration/AdobeDRM/AdobeDRMContentProtection.swift b/Palace/Reader2/ReaderStackConfiguration/AdobeDRM/AdobeDRMContentProtection.swift index df22110d6..2b5a8a724 100644 --- a/Palace/Reader2/ReaderStackConfiguration/AdobeDRM/AdobeDRMContentProtection.swift +++ b/Palace/Reader2/ReaderStackConfiguration/AdobeDRM/AdobeDRMContentProtection.swift @@ -13,19 +13,17 @@ import ReadiumShared import ReadiumZIPFoundation final class AdobeDRMContentProtection: ContentProtection, Loggable { - func open( asset: Asset, - credentials: String?, - allowUserInteraction: Bool, - sender: Any? + credentials _: String?, + allowUserInteraction _: Bool, + sender _: Any? ) async -> Result { - guard asset.format.conformsTo(.adept) else { return .failure(.assetNotSupported(DebugError("The asset is not protected by Adobe DRM"))) } - guard case .container(let container) = asset else { + guard case let .container(container) = asset else { return .failure(.assetNotSupported(DebugError("Only local file assets are supported with Adobe DRM"))) } @@ -64,15 +62,14 @@ extension Container { } } - private extension AdobeDRMContentProtection { - private func parseEncryptionData(in container: Container) async -> Result { let pathsToTry = ["META-INF/encryption.xml"] for path in pathsToTry { guard let resourceURL = container.url(forEntryPath: path), - let resource = container[resourceURL] else { + let resource = container[resourceURL] + else { log(.debug, "Failed to resolve resource at path: \(path)") continue } @@ -86,11 +83,11 @@ private extension AdobeDRMContentProtection { } } - extension AdobeDRMContainer: Container { - public var sourceURL: AbsoluteURL? { - guard let fileURL else { return nil } + guard let fileURL else { + return nil + } return FileURL(url: fileURL) } @@ -152,6 +149,7 @@ extension AdobeDRMContainer: Container { } // MARK: - Helpers + /// Retrieves encrypted data for the resource at a given path. private func retrieveData(for path: String) async throws -> Data { guard let rawData = try await readDataFromArchive(at: path) else { @@ -161,11 +159,13 @@ extension AdobeDRMContainer: Container { } private func listPathsFromArchive() -> [String]? { - return ["META-INF/container.xml", "OEBPS/content.opf"] + ["META-INF/container.xml", "OEBPS/content.opf"] } private func readDataFromArchive(at path: String) async throws -> Data? { - guard let fileURL else { return nil } + guard let fileURL else { + return nil + } let archive = try await Archive(url: fileURL, accessMode: .read) guard let entry = try await archive.get(path) else { @@ -182,7 +182,6 @@ extension AdobeDRMContainer: Container { } } - /// A DRM-enabled Resource that decrypts (decodes) its data once and then serves /// range requests (to support pagination) using the cached decrypted data. public actor DRMDataResource: Resource { @@ -251,8 +250,8 @@ public actor DRMDataResource: Resource { } } -extension ResourceProperties { - public var length: UInt64? { +public extension ResourceProperties { + var length: UInt64? { get { self["length"] } set { self["length"] = newValue } } diff --git a/Palace/Reader2/ReaderStackConfiguration/AdobeDRM/AdobeDRMError.swift b/Palace/Reader2/ReaderStackConfiguration/AdobeDRM/AdobeDRMError.swift index 8d1625c6f..79879bca3 100644 --- a/Palace/Reader2/ReaderStackConfiguration/AdobeDRM/AdobeDRMError.swift +++ b/Palace/Reader2/ReaderStackConfiguration/AdobeDRM/AdobeDRMError.swift @@ -11,13 +11,15 @@ import Foundation #if FEATURE_DRM_CONNECTOR enum AdobeDRMError: LocalizedError { - /// Indicates the item license has expired case expiredDisplayUntilDate - + public var errorDescription: String? { switch self { - case .expiredDisplayUntilDate: return NSLocalizedString("The book license has expired.", comment: "Expired license warning") + case .expiredDisplayUntilDate: NSLocalizedString( + "The book license has expired.", + comment: "Expired license warning" + ) } } } diff --git a/Palace/Reader2/ReaderStackConfiguration/AdobeDRM/AdobeDRMLibraryService.swift b/Palace/Reader2/ReaderStackConfiguration/AdobeDRM/AdobeDRMLibraryService.swift index d2b3840b9..cacb4b8ca 100644 --- a/Palace/Reader2/ReaderStackConfiguration/AdobeDRM/AdobeDRMLibraryService.swift +++ b/Palace/Reader2/ReaderStackConfiguration/AdobeDRM/AdobeDRMLibraryService.swift @@ -1,19 +1,18 @@ import Foundation -import UIKit import ReadiumShared import ReadiumStreamer +import UIKit #if FEATURE_DRM_CONNECTOR class AdobeDRMLibraryService: DRMLibraryService { - var contentProtection: ContentProtection? = AdobeDRMContentProtection() /// Returns whether this DRM can fulfill the given file into a protected publication. /// - Parameter file: file URL /// - Returns: `true` if file contains Adobe DRM license information. func canFulfill(_ file: URL) -> Bool { - return file.path.hasSuffix(RIGHTS_XML_SUFFIX) + file.path.hasSuffix(RIGHTS_XML_SUFFIX) } /// Fulfills the given file to the fully protected publication. @@ -22,7 +21,7 @@ class AdobeDRMLibraryService: DRMLibraryService { func fulfill(_ file: URL) async throws -> DRMFulfilledPublication { // Publications with Adobe DRM are fulfilled (license data already stored in _rights.xml file), // this step is always a success. - return DRMFulfilledPublication( + DRMFulfilledPublication( localURL: file, suggestedFilename: file.lastPathComponent ) diff --git a/Palace/Reader2/ReaderStackConfiguration/DRMLibraryService.swift b/Palace/Reader2/ReaderStackConfiguration/DRMLibraryService.swift index 89cc7958e..649b14878 100644 --- a/Palace/Reader2/ReaderStackConfiguration/DRMLibraryService.swift +++ b/Palace/Reader2/ReaderStackConfiguration/DRMLibraryService.swift @@ -10,21 +10,23 @@ import Foundation import ReadiumShared +// MARK: - DRMFulfilledPublication + struct DRMFulfilledPublication { let localURL: URL let suggestedFilename: String } +// MARK: - DRMLibraryService protocol DRMLibraryService { - /// Returns the `ContentProtection` which will be provided to the `Streamer`, to unlock /// publications. var contentProtection: ContentProtection? { get } - + /// Returns whether this DRM can fulfill the given file into a protected publication. func canFulfill(_ file: URL) -> Bool - + /// Fulfills the given file to the fully protected publication. func fulfill(_ file: URL) async throws -> DRMFulfilledPublication } diff --git a/Palace/Reader2/ReaderStackConfiguration/LCP/LCPLibraryService.swift b/Palace/Reader2/ReaderStackConfiguration/LCP/LCPLibraryService.swift index 15ba44352..eef009074 100644 --- a/Palace/Reader2/ReaderStackConfiguration/LCP/LCPLibraryService.swift +++ b/Palace/Reader2/ReaderStackConfiguration/LCP/LCPLibraryService.swift @@ -11,25 +11,24 @@ #if LCP import Foundation -import UIKit -import ReadiumShared -import ReadiumLCP import ReadiumAdapterLCPSQLite - +import ReadiumLCP +import ReadiumShared +import UIKit @objc class LCPLibraryService: NSObject, DRMLibraryService { - /// Readium licensee file extension @objc public let licenseExtension = "lcpl" - + private var lcpClient = TPPLCPClient() private var _lcpService: LCPService? private let serviceQueue = DispatchQueue(label: "com.palace.LCPLibraryService.serviceQueue", qos: .userInitiated) private let serviceLock = NSLock() /// ContentProtection unlocks protected publication, providing a custom `Fetcher` - lazy var contentProtection: ContentProtection? = lcpService?.contentProtection(with: LCPPassphraseAuthenticationService()) - + lazy var contentProtection: ContentProtection? = lcpService? + .contentProtection(with: LCPPassphraseAuthenticationService()) + /// [LicenseDocument.id: passphrase callback] private var authenticationCallbacks: [String: (String?) -> Void] = [:] @@ -42,7 +41,7 @@ import ReadiumAdapterLCPSQLite serviceLock.lock() defer { serviceLock.unlock() } - + _lcpService = LCPService( client: TPPLCPClient(), licenseRepository: licenseRepo, @@ -70,9 +69,9 @@ import ReadiumAdapterLCPSQLite /// - Parameter file: file URL /// - Returns: `true` if file contains LCP DRM license information. func canFulfill(_ file: URL) -> Bool { - return file.pathExtension.lowercased() == licenseExtension + file.pathExtension.lowercased() == licenseExtension } - + /// Fulfill LCP license publication. /// - Parameter file: LCP license file. /// - Returns: fulfilled publication as `Deferred` (`CancellableReesult` interenally) object. @@ -92,12 +91,12 @@ import ReadiumAdapterLCPSQLite let licenseSource = LicenseDocumentSource.file(fileURL) let result = await lcpService.acquirePublication(from: licenseSource) switch result { - case .success(let publication): + case let .success(publication): return DRMFulfilledPublication( localURL: publication.localURL.url, suggestedFilename: publication.suggestedFilename ) - case .failure(let error): + case let .failure(error): throw error } } @@ -110,7 +109,11 @@ import ReadiumAdapterLCPSQLite /// - localUrl: Downloaded publication URL. /// - downloadTask: `URLSessionDownloadTask` that downloaded the publication. /// - error: `NSError` if any. - @objc func fulfill(_ file: URL, progress: @escaping (_ progress: Double) -> Void, completion: @escaping (_ localUrl: URL?, _ error: NSError?) -> Void) -> URLSessionDownloadTask? { + @objc func fulfill( + _ file: URL, + progress: @escaping (_ progress: Double) -> Void, + completion: @escaping (_ localUrl: URL?, _ error: NSError?) -> Void + ) -> URLSessionDownloadTask? { guard let lcpService = lcpService else { let error = NSError(domain: "LCPLibraryService", code: -1, userInfo: [ NSLocalizedDescriptionKey: "LCPService not initialized" @@ -124,7 +127,8 @@ import ReadiumAdapterLCPSQLite guard error == nil else { let domain = "LCP fulfillment error" let code = TPPErrorCode.lcpDRMFulfillmentFail.rawValue - let errorDescription = (error as? LCPError)?.localizedDescription ?? (error as? TPPLicensesServiceError)?.description ?? error?.localizedDescription + let errorDescription = (error as? LCPError)?.localizedDescription ?? (error as? TPPLicensesServiceError)? + .description ?? error?.localizedDescription let nsError = NSError(domain: domain, code: code, userInfo: [ NSLocalizedDescriptionKey: errorDescription as Any ]) @@ -134,7 +138,7 @@ import ReadiumAdapterLCPSQLite completion(localUrl, nil) } } - + /// Decrypts data passed to LCP decryptor. /// - Parameter data: Encrypted data. /// - Returns: Decrypted data. diff --git a/Palace/Reader2/ReaderStackConfiguration/LCP/LCPPassphraseAuthenticationService.swift b/Palace/Reader2/ReaderStackConfiguration/LCP/LCPPassphraseAuthenticationService.swift index eceb74a9a..fd9769e04 100644 --- a/Palace/Reader2/ReaderStackConfiguration/LCP/LCPPassphraseAuthenticationService.swift +++ b/Palace/Reader2/ReaderStackConfiguration/LCP/LCPPassphraseAuthenticationService.swift @@ -7,8 +7,12 @@ import ReadiumLCP For Passphrase in License Document, see https://readium.org/lcp-specs/releases/lcp/latest#41-introduction */ class LCPPassphraseAuthenticationService: LCPAuthenticating { - - private func retrievePassphraseFromLoan(for license: LCPAuthenticatedLicense, reason: LCPAuthenticationReason, allowUserInteraction: Bool, sender: Any?) async -> String? { + private func retrievePassphraseFromLoan( + for license: LCPAuthenticatedLicense, + reason _: LCPAuthenticationReason, + allowUserInteraction _: Bool, + sender _: Any? + ) async -> String? { let licenseId = license.document.id let registry = TPPBookRegistry.shared guard let loansUrl = AccountsManager.shared.currentAccount?.loansUrl else { @@ -16,23 +20,29 @@ class LCPPassphraseAuthenticationService: LCPAuthenticating { } let logError = makeLogger(code: .lcpPassphraseRetrievalFail, urlKey: "loansUrl", urlValue: loansUrl) - guard let book = registry.myBooks.first(where: { registry.fulfillmentId(forIdentifier: $0.identifier) == licenseId }) else { + guard let book = registry.myBooks + .first(where: { registry.fulfillmentId(forIdentifier: $0.identifier) == licenseId }) + else { logError("LCP passphrase retrieval error: no book with fulfillment id found", "licenseId", licenseId) return nil } do { let (data, _) = try await TPPNetworkExecutor.shared.GET(loansUrl, useTokenIfAvailable: true) - + guard let xml = TPPXML(data: data), - let entries = xml.children(withName: "entry") as? [TPPXML] else { - logError("LCP passphrase retrieval error: loans XML parsing failed", "responseBody", String(data: data, encoding: .utf8) ?? "N/A") + let entries = xml.children(withName: "entry") as? [TPPXML] + else { + logError( + "LCP passphrase retrieval error: loans XML parsing failed", + "responseBody", + String(data: data, encoding: .utf8) ?? "N/A" + ) return nil } for entry in entries { if let entryId = entry.firstChild(withName: "id")?.value, entryId == book.identifier { - // Iterate through all 'link' elements in the entry let links = entry.children(withName: "link") as? [TPPXML] ?? [] if links.isEmpty { @@ -40,11 +50,9 @@ class LCPPassphraseAuthenticationService: LCPAuthenticating { } for link in links { - // Iterate through all children of the link to find 'hashed_passphrase' if let children = link.children as? [TPPXML], !children.isEmpty { for child in children { - if child.name == "hashed_passphrase", let passphrase = child.value { return passphrase } @@ -54,7 +62,11 @@ class LCPPassphraseAuthenticationService: LCPAuthenticating { } } - logError("LCP passphrase retrieval error: passphrase not found for \(book.identifier)", "responseBody", String(data: data, encoding: .utf8) ?? "N/A") + logError( + "LCP passphrase retrieval error: passphrase not found for \(book.identifier)", + "responseBody", + String(data: data, encoding: .utf8) ?? "N/A" + ) } catch { logError("LCP passphrase retrieval error", NSUnderlyingErrorKey, error) } @@ -63,9 +75,15 @@ class LCPPassphraseAuthenticationService: LCPAuthenticating { } /// Retrieves LCP passphrase from hint URL in the license (async version) - private func retrievePassphraseFromHint(for license: LCPAuthenticatedLicense, reason: LCPAuthenticationReason, allowUserInteraction: Bool, sender: Any?) async -> String? { + private func retrievePassphraseFromHint( + for license: LCPAuthenticatedLicense, + reason _: LCPAuthenticationReason, + allowUserInteraction _: Bool, + sender _: Any? + ) async -> String? { guard let hintLink = license.hintLink, - let hintURL = URL(string: hintLink.href) else { + let hintURL = URL(string: hintLink.href) + else { return nil } @@ -73,7 +91,8 @@ class LCPPassphraseAuthenticationService: LCPAuthenticating { do { let (data, _) = try await TPPNetworkExecutor.shared.GET(hintURL) if let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], - let passphrase = json["passphrase"] as? String { + let passphrase = json["passphrase"] as? String + { return passphrase } else { logError("LCP Passphrase JSON Parse Error", "responseBody", String(data: data, encoding: .utf8) ?? "N/A") @@ -85,10 +104,14 @@ class LCPPassphraseAuthenticationService: LCPAuthenticating { } /// Requests the passphrase from the user manually (async version) - func retrievePassphrase(for license: ReadiumLCP.LCPAuthenticatedLicense, reason: ReadiumLCP.LCPAuthenticationReason, allowUserInteraction: Bool, sender: Any?) async -> String? { - + func retrievePassphrase( + for license: ReadiumLCP.LCPAuthenticatedLicense, + reason: ReadiumLCP.LCPAuthenticationReason, + allowUserInteraction: Bool, + sender: Any? + ) async -> String? { if TPPSettings.shared.enterLCPPassphraseManually { - return await withCheckedContinuation { continuation in + await withCheckedContinuation { continuation in var passphraseField: UITextField? let ac = UIAlertController(title: "Enter LCP Passphrase", message: license.hint, preferredStyle: .alert) @@ -114,16 +137,29 @@ class LCPPassphraseAuthenticationService: LCPAuthenticating { passphraseField = textField } - - TPPAlertUtils.presentFromViewControllerOrNil(alertController: ac, viewController: nil, animated: true, completion: nil) + TPPAlertUtils.presentFromViewControllerOrNil( + alertController: ac, + viewController: nil, + animated: true, + completion: nil + ) } } else { - return await retrievePassphraseFromLoan(for: license, reason: reason, allowUserInteraction: allowUserInteraction, sender: sender) + await retrievePassphraseFromLoan( + for: license, + reason: reason, + allowUserInteraction: allowUserInteraction, + sender: sender + ) } } /// Creates a logger function - private func makeLogger(code: TPPErrorCode, urlKey: String, urlValue: URL) -> (_ summary: String, _ errorKey: String, _ errorValue: Any) -> Void { + private func makeLogger( + code: TPPErrorCode, + urlKey: String, + urlValue: URL + ) -> (_ summary: String, _ errorKey: String, _ errorValue: Any) -> Void { func logError(summary: String, errorKey: String, errorValue: Any) { TPPErrorLogger.logError( withCode: code, diff --git a/Palace/Reader2/ReaderStackConfiguration/LCP/LicensesService.swift b/Palace/Reader2/ReaderStackConfiguration/LCP/LicensesService.swift index 7e3789a19..94a3b5cc8 100644 --- a/Palace/Reader2/ReaderStackConfiguration/LCP/LicensesService.swift +++ b/Palace/Reader2/ReaderStackConfiguration/LCP/LicensesService.swift @@ -7,28 +7,35 @@ // import Foundation -import ReadiumShared import ReadiumLCP +import ReadiumShared import ReadiumZIPFoundation +// MARK: - TPPLicensesServiceError + enum TPPLicensesServiceError: Error { case licenseError(message: String) - + public var description: String { switch self { - case .licenseError(let message): return message + case let .licenseError(message): message } - } + } } +// MARK: - TPPLicensesService + class TPPLicensesService: NSObject { - var progressHandler: ((_ progress: Double) -> Void)? var completionHandler: ((_ localUrl: URL?, _ error: Error?) -> Void)? var lcpl: URL? var link: TPPLCPLicenseLink? - - func acquirePublication(from lcpl: URL, progress: @escaping (_ progress: Double) -> Void, completion: @escaping (_ localUrl: URL?, _ error: Error?) -> Void) -> URLSessionDownloadTask? { + + func acquirePublication( + from lcpl: URL, + progress: @escaping (_ progress: Double) -> Void, + completion: @escaping (_ localUrl: URL?, _ error: Error?) -> Void + ) -> URLSessionDownloadTask? { // Parse LCP license file guard let license = TPPLCPLicense(url: lcpl) else { completion(nil, TPPLicensesServiceError.licenseError(message: "Reading license file failed")) @@ -36,12 +43,15 @@ class TPPLicensesService: NSObject { } // Get publication download link guard let link = license.firstLink(withRel: .publication), let href = link.href, let url = URL(string: href) else { - completion(nil, TPPLicensesServiceError.licenseError(message: "Error parsing license file, publication href was not found")) + completion( + nil, + TPPLicensesServiceError.licenseError(message: "Error parsing license file, publication href was not found") + ) return nil } - self.progressHandler = progress - self.completionHandler = completion + progressHandler = progress + completionHandler = completion self.lcpl = lcpl self.link = link @@ -51,14 +61,15 @@ class TPPLicensesService: NSObject { // Otherwise, single download session calls one delegate class methods, // and only one book's status is updated. let request = URLRequest(url: url, applyingCustomUserAgent: true) - let backgroundIdentifier = (Bundle.main.bundleIdentifier ?? "").appending(".lcpBackgroundIdentifier.\(lcpl.hashValue)") + let backgroundIdentifier = (Bundle.main.bundleIdentifier ?? "") + .appending(".lcpBackgroundIdentifier.\(lcpl.hashValue)") let sessionConfiguration = URLSessionConfiguration.background(withIdentifier: backgroundIdentifier) let session = URLSession(configuration: sessionConfiguration, delegate: self, delegateQueue: .main) let task = session.downloadTask(with: request) task.resume() return task } - + /// Injects licens file into LCP-protected file /// - Parameters: /// - lcpl: license URL @@ -79,7 +90,7 @@ class TPPLicensesService: NSObject { type: .file, uncompressedSize: Int64(data.count), provider: { - (position, size) -> Data in + position, size -> Data in let start = Int(position) let end = min(start + Int(size), data.count) return data[start.. LCPClientContext { - var rawResult: LCPClientContext? - var caughtError: Error? - - contextQueue.sync { - do { - rawResult = try R2LCPClient.createContext( - jsonLicense: jsonLicense, - hashedPassphrase: hashedPassphrase, - pemCrl: pemCrl - ) - } catch { - caughtError = error - } - } - - if let error = caughtError { - throw error - } - - guard let newCtx = rawResult else { - throw LCPContextError.creationReturnedNil - } - - contextQueue.sync { - self._context = newCtx - } - - return newCtx - } + jsonLicense: String, + hashedPassphrase: String, + pemCrl: String + ) throws -> LCPClientContext { + var rawResult: LCPClientContext? + var caughtError: Error? + + contextQueue.sync { + do { + rawResult = try R2LCPClient.createContext( + jsonLicense: jsonLicense, + hashedPassphrase: hashedPassphrase, + pemCrl: pemCrl + ) + } catch { + caughtError = error + } + } + + if let error = caughtError { + throw error + } + + guard let newCtx = rawResult else { + throw LCPContextError.creationReturnedNil + } + + contextQueue.sync { + self._context = newCtx + } + + return newCtx + } func decrypt(data: Data, using context: LCPClientContext) -> Data? { - guard let drmContext = context as? DRMContext else { + guard let drmContext = context as? DRMContext else { ATLog(.error, "Invalid DRM context for decryption") - return nil + return nil } - + guard !data.isEmpty else { ATLog(.error, "Cannot decrypt empty data") return nil } - + do { let decrypted = R2LCPClient.decrypt(data: data, using: drmContext) if decrypted == nil { @@ -91,22 +90,22 @@ class TPPLCPClient: ReadiumLCP.LCPClient { } func findOneValidPassphrase(jsonLicense: String, hashedPassphrases: [String]) -> String? { - return R2LCPClient.findOneValidPassphrase(jsonLicense: jsonLicense, hashedPassphrases: hashedPassphrases) + R2LCPClient.findOneValidPassphrase(jsonLicense: jsonLicense, hashedPassphrases: hashedPassphrases) } } extension TPPLCPClient { func decrypt(data: Data) -> Data? { - guard let drmContext = context as? DRMContext else { + guard let drmContext = context as? DRMContext else { ATLog(.error, "No valid DRM context available for decryption") - return nil + return nil } - + guard !data.isEmpty else { ATLog(.error, "Cannot decrypt empty data") return nil } - + do { let result = R2LCPClient.decrypt(data: data, using: drmContext) if result == nil { diff --git a/Palace/Reader2/ReaderStackConfiguration/LCP/TPPLCPLicense.swift b/Palace/Reader2/ReaderStackConfiguration/LCP/TPPLCPLicense.swift index df48d008f..6e166cf84 100644 --- a/Palace/Reader2/ReaderStackConfiguration/LCP/TPPLCPLicense.swift +++ b/Palace/Reader2/ReaderStackConfiguration/LCP/TPPLCPLicense.swift @@ -31,30 +31,31 @@ enum TPPLCPLicenseRel: String { @objc class TPPLCPLicense: NSObject, Codable { /// License ID private var id: String - + let links: [TPPLCPLicenseLink] - + /// Objective-C visible identifier @objc var identifier: String { id } - + /// Initializes with license URL @objc init?(url: URL) { if let data = try? Data(contentsOf: url), - let license = try? JSONDecoder().decode(TPPLCPLicense.self, from: data) { - self.id = license.id - self.links = license.links + let license = try? JSONDecoder().decode(TPPLCPLicense.self, from: data) + { + id = license.id + links = license.links } else { return nil } } - + /// Returns first link with the specified `rel`. /// - Parameter rel: `rel` value. /// - Returns: First link, if available, `nil` if no link with the provided `rel` found. func firstLink(withRel rel: TPPLCPLicenseRel) -> TPPLCPLicenseLink? { - links.filter({ $0.rel == rel.rawValue }).first + links.filter { $0.rel == rel.rawValue }.first } } diff --git a/Palace/Reader2/ReaderStackConfiguration/LibraryService.swift b/Palace/Reader2/ReaderStackConfiguration/LibraryService.swift index a88a6c274..a6e5a51f4 100644 --- a/Palace/Reader2/ReaderStackConfiguration/LibraryService.swift +++ b/Palace/Reader2/ReaderStackConfiguration/LibraryService.swift @@ -1,8 +1,10 @@ import Foundation -import UIKit +import ReadiumAdapterGCDWebServer import ReadiumShared import ReadiumStreamer -import ReadiumAdapterGCDWebServer +import UIKit + +// MARK: - LibraryService /// The LibraryService makes a book ready for presentation without dealing /// with the specifics of how a book should be presented. @@ -10,7 +12,6 @@ import ReadiumAdapterGCDWebServer /// It sets up the various components necessary for presenting a book, /// such as the HTTP server, DRM systems, etc. final class LibraryService: Loggable { - private let assetRetriever: AssetRetriever private let publicationOpener: PublicationOpener private var drmLibraryServices = [DRMLibraryService]() @@ -23,77 +24,90 @@ final class LibraryService: Loggable { httpServer = GCDHTTPServer(assetRetriever: assetRetriever) // DRM configurations -#if LCP + #if LCP drmLibraryServices.append(LCPLibraryService()) -#endif + #endif -#if FEATURE_DRM_CONNECTOR + #if FEATURE_DRM_CONNECTOR drmLibraryServices.append(AdobeDRMLibraryService()) -#endif + #endif - let contentProtections = drmLibraryServices.compactMap { $0.contentProtection } + let contentProtections = drmLibraryServices.compactMap(\.contentProtection) let parser = CompositePublicationParser([ DefaultPublicationParser( httpClient: httpClient, assetRetriever: assetRetriever, pdfFactory: DefaultPDFDocumentFactory() - ), + ) ]) publicationOpener = PublicationOpener(parser: parser, contentProtections: contentProtections) } @MainActor - func openBook(_ book: TPPBook, sender: UIViewController, completion: @escaping (Result) -> Void) { - + func openBook( + _ book: TPPBook, + sender: UIViewController, + completion: @escaping (Result) -> Void + ) { guard let bookUrl = book.url else { completion(.failure(.invalidBook)) return } openPublication(at: bookUrl, allowUserInteraction: true, sender: sender) { [weak self] result in - guard let self = self else { return } + guard let self = self else { + return + } switch result { - case .success(let publication): - if !self.validatePublication(publication, for: book.identifier, completion: completion) { + case let .success(publication): + if !validatePublication(publication, for: book.identifier, completion: completion) { return } - self.preparePresentation(of: publication, book: book) + preparePresentation(of: publication, book: book) completion(.success(publication)) - case .failure(let error): - self.stopOpeningIndicator(identifier: book.identifier) + case let .failure(error): + stopOpeningIndicator(identifier: book.identifier) completion(.failure(.openFailed(error))) } } } @MainActor - func openSample(_ book: TPPBook, - sampleURL: URL, - sender: UIViewController, - completion: @escaping (Result) -> Void) { - + func openSample( + _ book: TPPBook, + sampleURL: URL, + sender: UIViewController, + completion: @escaping (Result) -> Void + ) { openPublication(at: sampleURL, allowUserInteraction: true, sender: sender) { [weak self] result in - guard let self = self else { return } + guard let self = self else { + return + } switch result { - case .success(let publication): - if !self.validatePublication(publication, for: book.identifier, completion: completion) { + case let .success(publication): + if !validatePublication(publication, for: book.identifier, completion: completion) { return } - self.preparePresentation(of: publication, book: book) + preparePresentation(of: publication, book: book) completion(.success(publication)) - case .failure(let error): - self.stopOpeningIndicator(identifier: book.identifier) + case let .failure(error): + stopOpeningIndicator(identifier: book.identifier) completion(.failure(.openFailed(error))) } } } @MainActor - private func openPublication(at url: URL, allowUserInteraction: Bool, sender: UIViewController?, completion: @escaping (Result) -> Void) { + private func openPublication( + at url: URL, + allowUserInteraction: Bool, + sender: UIViewController?, + completion: @escaping (Result) -> Void + ) { Task { guard let fileURL = FileURL(url: url) else { log(.error, "Failed to convert URL to FileURL: \(url.absoluteString)") @@ -102,10 +116,14 @@ final class LibraryService: Loggable { } switch await assetRetriever.retrieve(url: fileURL) { - case .success(let asset): - let result = await self.publicationOpener.open(asset: asset, allowUserInteraction: allowUserInteraction, sender: sender) + case let .success(asset): + let result = await self.publicationOpener.open( + asset: asset, + allowUserInteraction: allowUserInteraction, + sender: sender + ) completion(result.mapError { $0 as Error }) - case .failure(let error): + case let .failure(error): log(.error, "Asset retrieval failed: \(error.localizedDescription)") completion(.failure(error)) } @@ -130,7 +148,12 @@ final class LibraryService: Loggable { log(.error, "Malformed self link: \(selfLink.href)") } } - private func validatePublication(_ publication: Publication, for identifier: String, completion: (Result) -> Void) -> Bool { + + private func validatePublication( + _ publication: Publication, + for identifier: String, + completion: (Result) -> Void + ) -> Bool { guard !publication.isRestricted else { stopOpeningIndicator(identifier: identifier) if let error = publication.protectionError { diff --git a/Palace/Reader2/ReaderStackConfiguration/LibraryServiceError.swift b/Palace/Reader2/ReaderStackConfiguration/LibraryServiceError.swift index bd5ebe8fe..3646d4ade 100644 --- a/Palace/Reader2/ReaderStackConfiguration/LibraryServiceError.swift +++ b/Palace/Reader2/ReaderStackConfiguration/LibraryServiceError.swift @@ -14,20 +14,18 @@ import Foundation import ReadiumShared enum LibraryServiceError: LocalizedError { - case invalidBook case cancelled case openFailed(Error) - + var errorDescription: String? { switch self { case .invalidBook: Strings.Error.invalidBookError - case .openFailed(let error): - String(format: Strings.Error.openFailedError, error.localizedDescription) + case let .openFailed(error): + String(format: Strings.Error.openFailedError, error.localizedDescription) case .cancelled: nil } } - } diff --git a/Palace/Reader2/ReaderStackConfiguration/TPPR3Owner.swift b/Palace/Reader2/ReaderStackConfiguration/TPPR3Owner.swift index 946232a95..ae4dc0bfd 100644 --- a/Palace/Reader2/ReaderStackConfiguration/TPPR3Owner.swift +++ b/Palace/Reader2/ReaderStackConfiguration/TPPR3Owner.swift @@ -10,25 +10,28 @@ // import Foundation -import UIKit import ReadiumShared import ReadiumStreamer +import UIKit + +// MARK: - TPPR3Owner /// This class is the main root of R3 objects. It: /// - owns the sub-modules (library, reader, etc.) /// - orchestrates the communication between its sub-modules, through the /// modules' delegates. @objc public final class TPPR3Owner: NSObject { - - var libraryService: LibraryService! = nil - var readerModule: ReaderModuleAPI! = nil + var libraryService: LibraryService! + var readerModule: ReaderModuleAPI! override init() { super.init() libraryService = LibraryService() - readerModule = ReaderModule(delegate: self, - resourcesServer: libraryService.httpServer, - bookRegistry: TPPBookRegistry.shared) + readerModule = ReaderModule( + delegate: self, + resourcesServer: libraryService.httpServer, + bookRegistry: TPPBookRegistry.shared + ) ReadiumEnableLog(withMinimumSeverityLevel: .debug) } @@ -38,10 +41,14 @@ import ReadiumStreamer } } +// MARK: ModuleDelegate + extension TPPR3Owner: ModuleDelegate { - func presentAlert(_ title: String, - message: String, - from viewController: UIViewController) { + func presentAlert( + _ title: String, + message: String, + from viewController: UIViewController + ) { let alert = UIAlertController(title: title, message: message, preferredStyle: .alert) let dismissButton = UIAlertAction(title: Strings.Generic.ok, style: .cancel) alert.addAction(dismissButton) @@ -49,7 +56,9 @@ extension TPPR3Owner: ModuleDelegate { } func presentError(_ error: Error?, from viewController: UIViewController) { - guard let error = error else { return } + guard let error = error else { + return + } presentAlert( Strings.Generic.error, message: error.localizedDescription, diff --git a/Palace/Reader2/TTS/TPPPublicationSpeechSynthesizer.swift b/Palace/Reader2/TTS/TPPPublicationSpeechSynthesizer.swift index 742270ac4..fa19edef2 100644 --- a/Palace/Reader2/TTS/TPPPublicationSpeechSynthesizer.swift +++ b/Palace/Reader2/TTS/TPPPublicationSpeechSynthesizer.swift @@ -6,17 +6,22 @@ // Copyright © 2023 The Palace Project. All rights reserved. // -import Foundation -import Combine import AVFoundation -import ReadiumShared +import Combine +import Foundation import ReadiumNavigator +import ReadiumShared + +// MARK: - Direction /// Iterator direction private enum Direction { - case forward, backward + case forward + case backward } +// MARK: - Utterance + /// An utterance is an arbitrary text (e.g. sentence) extracted from the publication public struct Utterance { /// Text to be spoken. @@ -27,49 +32,55 @@ public struct Utterance { public let language: Language? } +// MARK: - TPPPublicationSpeechSynthesizerDelegate + public protocol TPPPublicationSpeechSynthesizerDelegate: AnyObject { /// Called when the synthesizer's state is updated. - func publicationSpeechSynthesizer(_ synthesizer: TPPPublicationSpeechSynthesizer, stateDidChange state: TPPPublicationSpeechSynthesizer.State) + func publicationSpeechSynthesizer( + _ synthesizer: TPPPublicationSpeechSynthesizer, + stateDidChange state: TPPPublicationSpeechSynthesizer.State + ) } +// MARK: - TPPPublicationSpeechSynthesizer + /// `PublicationSpeechSynthesizer` orchestrates the rendition of a `Publication` by iterating through its content, /// splitting it into individual utterances using a `ContentTokenizer` public class TPPPublicationSpeechSynthesizer: NSObject, Loggable { - public typealias TokenizerFactory = (_ defaultLanguage: Language?) -> ContentTokenizer - + /// Returns whether the `publication` can be played with a `PublicationSpeechSynthesizer`. public static func canSpeak(publication: Publication) -> Bool { publication.content() != nil } - + /// Represents a state of the `PublicationSpeechSynthesizer`. public enum State { /// The synthesizer is completely stopped and must be (re)started from a given locator. case stopped - + /// The synthesizer is paused at the given utterance. case paused(Utterance) - + /// The TTS engine is synthesizing the associated utterance. /// `range` will be regularly updated while the utterance is being played. case playing(Utterance, range: Locator?) } - + /// Current state of the `PublicationSpeechSynthesizer`. public private(set) var state: State = .stopped { didSet { delegate?.publicationSpeechSynthesizer(self, stateDidChange: state) } } - + public weak var delegate: TPPPublicationSpeechSynthesizerDelegate? - + private let publication: Publication private let tokenizerFactory: TokenizerFactory private let synthesizer: AVSpeechSynthesizer private var voiceOverAnnouncementCancellable: AnyCancellable? - + /// Creates a `PublicationSpeechSynthesizer` /// /// Returns null if the publication cannot be synthesized. @@ -87,22 +98,22 @@ public class TPPPublicationSpeechSynthesizer: NSObject, Loggable { guard Self.canSpeak(publication: publication) else { return nil } - + self.publication = publication self.tokenizerFactory = tokenizerFactory self.delegate = delegate - self.synthesizer = AVSpeechSynthesizer() + synthesizer = AVSpeechSynthesizer() super.init() - self.synthesizer.delegate = self - self.voiceOverAnnouncementCancellable = NotificationCenter.default.publisher(for: UIAccessibility.announcementDidFinishNotification) + synthesizer.delegate = self + voiceOverAnnouncementCancellable = NotificationCenter.default + .publisher(for: UIAccessibility.announcementDidFinishNotification) .receive(on: RunLoop.main) .sink { _ in self.didFinishUtterance() } - } - + /// The default content tokenizer will split the `Content.Element` items into individual sentences. public static let defaultTokenizerFactory: TokenizerFactory = { defaultLanguage in makeTextContentTokenizer( @@ -113,12 +124,12 @@ public class TPPPublicationSpeechSynthesizer: NSObject, Loggable { } ) } - + /// (Re)starts the synthesizer from the given locator or the beginning of the publication. public func start(from locator: Locator? = nil) { Task { publicationIterator = publication.content(from: locator)?.iterator() - + if let cssSelector = locator?.locations.cssSelector { var utteranceAtLocator: Utterance? while utterances.current()?.locator.locations.cssSelector != cssSelector { @@ -135,7 +146,7 @@ public class TPPPublicationSpeechSynthesizer: NSObject, Loggable { playNextUtterance(.forward) } } - + /// Stops the synthesizer. /// /// Use `start()` to restart it. @@ -148,7 +159,7 @@ public class TPPPublicationSpeechSynthesizer: NSObject, Loggable { state = .stopped publicationIterator = nil } - + /// Interrupts a played utterance. /// /// Use `resume()` to restart the playback from the same utterance. @@ -162,7 +173,7 @@ public class TPPPublicationSpeechSynthesizer: NSObject, Loggable { state = .paused(utterance) } } - + /// Resumes an utterance interrupted with `pause()`. public func resume() { Task { @@ -182,7 +193,7 @@ public class TPPPublicationSpeechSynthesizer: NSObject, Loggable { } } } - + /// Pauses or resumes the playback of the current utterance. public func pauseOrResume() { switch state { @@ -191,27 +202,27 @@ public class TPPPublicationSpeechSynthesizer: NSObject, Loggable { case .paused: resume() } } - + /// Skips to the previous utterance. public func previous() { playNextUtterance(.backward) } - + /// Skips to the next utterance. public func next() { playNextUtterance(.forward) } - + /// `Content.Iterator` used to iterate through the `publication`. - private var publicationIterator: ContentIterator? = nil { + private var publicationIterator: ContentIterator? { didSet { utterances = CursorList() } } - + /// Utterances for the current publication `ContentElement` item. private var utterances: CursorList = CursorList() - + /// Plays the next utterance in the given `direction`. private func playNextUtterance(_ direction: Direction) { Task { @@ -222,13 +233,12 @@ public class TPPPublicationSpeechSynthesizer: NSObject, Loggable { play(utterance) } } - + /// Plays the given `utterance` private func play(_ utterance: Utterance) { - // utterance.locator.copy crashes if highlight is nil - if let range = utterance.text.range(of: utterance.text), utterance.locator.text.highlight != nil { - state = .playing(utterance, range: utterance.locator.copy(text: { $0 = utterance.locator.text[range] } )) + if let range = utterance.text.range(of: utterance.text), utterance.locator.text.highlight != nil { + state = .playing(utterance, range: utterance.locator.copy(text: { $0 = utterance.locator.text[range] })) } else { state = .playing(utterance, range: nil) } @@ -240,7 +250,7 @@ public class TPPPublicationSpeechSynthesizer: NSObject, Loggable { synthesizer.speak(avUtterance) } } - + /// Gets the next utterance in the given `direction`, or null when reaching the beginning or the end. private func nextUtterance(_ direction: Direction) async -> Utterance? { guard let utterance = utterances.next(direction) else { @@ -251,39 +261,39 @@ public class TPPPublicationSpeechSynthesizer: NSObject, Loggable { } return utterance } - + /// Loads the utterances for the next publication `ContentElement` item in the given `direction`. private func loadNextUtterances(_ direction: Direction) async -> Bool { do { guard let content = try await publicationIterator?.next(direction) else { return false } - + let nextUtterances = try tokenize(content) .flatMap { utterances(for: $0) } - + if nextUtterances.isEmpty { return await loadNextUtterances(direction) } - + utterances = CursorList( list: nextUtterances, startIndex: { switch direction { - case .forward: return 0 - case .backward: return nextUtterances.count - 1 + case .forward: 0 + case .backward: nextUtterances.count - 1 } }() ) - + return true - + } catch { log(.error, error) return false } } - + /// Splits a publication `ContentElement` item into smaller chunks using the provided tokenizer. /// /// This is used to split a paragraph into sentences, for example. @@ -291,84 +301,88 @@ public class TPPPublicationSpeechSynthesizer: NSObject, Loggable { let tokenizer = tokenizerFactory(publication.metadata.language) return try tokenizer(element) } - + /// Splits a publication `ContentElement` item into the utterances to be spoken. private func utterances(for element: ContentElement) -> [Utterance] { func utterance(text: String, locator: Locator, language: Language? = nil) -> Utterance? { guard text.contains(where: { $0.isLetter || $0.isNumber }) else { return nil } - + return Utterance( text: text, locator: locator, language: language - // If the language is the same as the one declared globally in the publication, - // we omit it. This way, the app can customize the default language used in the - // configuration. + // If the language is the same as the one declared globally in the publication, + // we omit it. This way, the app can customize the default language used in the + // configuration. .takeIf { $0 != publication.metadata.language } ) } - + switch element { case let element as TextContentElement: return element.segments .compactMap { segment in utterance(text: segment.text, locator: segment.locator, language: segment.language) } - + case let element as TextualContentElement: guard let text = element.text.takeIf({ !$0.isEmpty }) else { return [] } return Array(ofNotNil: utterance(text: text, locator: element.locator)) - + default: return [] } } - + private func didFinishUtterance() { - switch self.state { - case .playing(_, range: _): self.playNextUtterance(.forward) + switch state { + case .playing: playNextUtterance(.forward) default: break } } } +// MARK: AVSpeechSynthesizerDelegate + extension TPPPublicationSpeechSynthesizer: AVSpeechSynthesizerDelegate { - public func speechSynthesizer(_ synthesizer: AVSpeechSynthesizer, didFinish utterance: AVSpeechUtterance) { - self.didFinishUtterance() + public func speechSynthesizer(_: AVSpeechSynthesizer, didFinish _: AVSpeechUtterance) { + didFinishUtterance() } } +// MARK: - CursorList + /// A `List` with a mutable cursor index. struct CursorList { private let list: [Element] private let startIndex: Int - + init(list: [Element] = [], startIndex: Int = 0) { self.list = list self.startIndex = startIndex } - - private var index: Int? = nil - + + private var index: Int? + /// Returns the current element. mutating func current() -> Element? { moveAndGet(index ?? startIndex) } - + /// Moves the cursor backward and returns the element, or null when reaching the beginning. mutating func previous() -> Element? { moveAndGet(index.map { $0 - 1 } ?? startIndex) } - + /// Moves the cursor forward and returns the element, or null when reaching the end. mutating func next() -> Element? { moveAndGet(index.map { $0 + 1 } ?? startIndex) } - + private mutating func moveAndGet(_ index: Int) -> Element? { guard list.indices.contains(index) else { return nil @@ -382,9 +396,9 @@ private extension CursorList { mutating func next(_ direction: Direction) -> Element? { switch direction { case .forward: - return next() + next() case .backward: - return previous() + previous() } } } @@ -393,9 +407,9 @@ private extension ContentIterator { func next(_ direction: Direction) async throws -> ContentElement? { switch direction { case .forward: - return try await next() + try await next() case .backward: - return try await previous() + try await previous() } } } diff --git a/Palace/Reader2/TTS/TPPTextToSpeech.swift b/Palace/Reader2/TTS/TPPTextToSpeech.swift index 1ac52fed7..b76e70d49 100644 --- a/Palace/Reader2/TTS/TPPTextToSpeech.swift +++ b/Palace/Reader2/TTS/TPPTextToSpeech.swift @@ -3,8 +3,9 @@ import Foundation import ReadiumNavigator import ReadiumShared -class TPPTextToSpeech: ObservableObject { +// MARK: - TPPTextToSpeech +class TPPTextToSpeech: ObservableObject { private let publication: Publication private let navigator: Navigator private let synthesizer: TPPPublicationSpeechSynthesizer @@ -101,23 +102,27 @@ class TPPTextToSpeech: ObservableObject { } } -extension TPPTextToSpeech: TPPPublicationSpeechSynthesizerDelegate { +// MARK: TPPPublicationSpeechSynthesizerDelegate - public func publicationSpeechSynthesizer(_ synthesizer: TPPPublicationSpeechSynthesizer, stateDidChange synthesizerState: TPPPublicationSpeechSynthesizer.State) { +extension TPPTextToSpeech: TPPPublicationSpeechSynthesizerDelegate { + public func publicationSpeechSynthesizer( + _: TPPPublicationSpeechSynthesizer, + stateDidChange synthesizerState: TPPPublicationSpeechSynthesizer.State + ) { switch synthesizerState { case .stopped: - self.isPlaying = false + isPlaying = false playingUtterance = nil case let .playing(utterance, range: wordRange): - self.isPlaying = true + isPlaying = true playingUtterance = utterance.locator if let wordRange = wordRange { playingWordRangeSubject.send(wordRange) } case let .paused(utterance): - self.isPlaying = false + isPlaying = false playingUtterance = utterance.locator } } diff --git a/Palace/Reader2/UI/EpubSearchView/EPUBSearchView.swift b/Palace/Reader2/UI/EpubSearchView/EPUBSearchView.swift index e48b8900e..3f062f59b 100644 --- a/Palace/Reader2/UI/EpubSearchView/EPUBSearchView.swift +++ b/Palace/Reader2/UI/EpubSearchView/EPUBSearchView.swift @@ -6,11 +6,11 @@ // Copyright © 2023 The Palace Project. All rights reserved. // -import SwiftUI import Combine -import ReadiumShared -import ReadiumNavigator import PalaceUIKit +import ReadiumNavigator +import ReadiumShared +import SwiftUI struct EPUBSearchView: View { @ObservedObject var viewModel: EPUBSearchViewModel @@ -113,7 +113,8 @@ struct EPUBSearchView: View { private func shouldFetchMoreResults(for locator: Locator) -> Bool { if let lastSection = viewModel.sections.last, - let lastLocator = lastSection.locators.last { + let lastLocator = lastSection.locators.last + { return locator.href.isEquivalentTo(lastLocator.href) } return false @@ -129,7 +130,7 @@ struct EPUBSearchView: View { @ViewBuilder private var footer: some View { switch viewModel.state { - case .failure(let error): + case let .failure(error): Text("\(Strings.Generic.error.capitalized) \(error.localizedDescription)") default: EmptyView() @@ -158,8 +159,8 @@ struct EPUBSearchView: View { } else { return VStack { Text(text.before ?? "") + - Text(text.highlight ?? "").foregroundColor(Color.red).fontWeight(.medium) + - Text(text.after ?? "") + Text(text.highlight ?? "").foregroundColor(Color.red).fontWeight(.medium) + + Text(text.after ?? "") } .palaceFont(.body) .onTapGesture { diff --git a/Palace/Reader2/UI/EpubSearchView/EPUBSearchViewModel.swift b/Palace/Reader2/UI/EpubSearchView/EPUBSearchViewModel.swift index 5a9288c24..d33a58ea8 100644 --- a/Palace/Reader2/UI/EpubSearchView/EPUBSearchViewModel.swift +++ b/Palace/Reader2/UI/EpubSearchView/EPUBSearchViewModel.swift @@ -7,8 +7,10 @@ // import Foundation -import ReadiumShared import ReadiumNavigator +import ReadiumShared + +// MARK: - EPUBSearchDelegate protocol EPUBSearchDelegate: AnyObject { func didSelect(location: Locator) @@ -16,6 +18,8 @@ protocol EPUBSearchDelegate: AnyObject { typealias SearchViewSection = (id: String, title: String, locators: [Locator]) +// MARK: - EPUBSearchViewModel + @MainActor final class EPUBSearchViewModel: ObservableObject { enum State { @@ -29,9 +33,9 @@ final class EPUBSearchViewModel: ObservableObject { var isLoadingState: Bool { switch self { case .starting, .loadingNext: - return true + true default: - return false + false } } } @@ -53,10 +57,10 @@ final class EPUBSearchViewModel: ObservableObject { state = .starting let result = await publication.search(query: query) switch result { - case .success(let iterator): + case let .success(iterator): state = .idle(iterator, isFetching: false) await fetchNextBatch() - case .failure(let error): + case let .failure(error): state = .failure(error) } } @@ -70,9 +74,9 @@ final class EPUBSearchViewModel: ObservableObject { let result = await iterator.next() switch result { - case .success(let collection): + case let .success(collection): handleNewCollection(iterator, collection: collection) - case .failure(let error): + case let .failure(error): state = .end state = .failure(error) } @@ -106,14 +110,14 @@ final class EPUBSearchViewModel: ObservableObject { if !groupedResults[titleKey]!.contains(where: { existingLocator in existingLocator.href.isEquivalentTo(locator.href) && - existingLocator.locations.progression == locator.locations.progression && - existingLocator.locations.totalProgression == locator.locations.totalProgression + existingLocator.locations.progression == locator.locations.progression && + existingLocator.locations.totalProgression == locator.locations.totalProgression }) { groupedResults[titleKey]!.append(locator) } } - self.sections = groupedResults + sections = groupedResults .map { (id: UUID().uuidString, title: $0.value.first?.title ?? "", locators: $0.value) } .sorted { section1, section2 in let href1 = section1.locators.first?.href.string ?? "" @@ -123,10 +127,10 @@ final class EPUBSearchViewModel: ObservableObject { } private func isDuplicate(_ locator: Locator) -> Bool { - return results.contains { existingLocator in + results.contains { existingLocator in existingLocator.href.isEquivalentTo(locator.href) && - existingLocator.locations.progression == locator.locations.progression && - existingLocator.locations.totalProgression == locator.locations.totalProgression + existingLocator.locations.progression == locator.locations.progression && + existingLocator.locations.totalProgression == locator.locations.totalProgression } } diff --git a/Palace/Reader2/UI/TPPBaseReaderViewController.swift b/Palace/Reader2/UI/TPPBaseReaderViewController.swift index a977cb149..ef6acca64 100644 --- a/Palace/Reader2/UI/TPPBaseReaderViewController.swift +++ b/Palace/Reader2/UI/TPPBaseReaderViewController.swift @@ -8,11 +8,13 @@ // LICENSE file present in the project repository where this source code is maintained. // -import SafariServices -import UIKit +import Combine import ReadiumNavigator import ReadiumShared -import Combine +import SafariServices +import UIKit + +// MARK: - TPPBaseReaderViewController /// This class is meant to be subclassed by each publication format view controller. It contains the shared behavior, eg. navigation bar toggling. class TPPBaseReaderViewController: UIViewController, Loggable { @@ -54,39 +56,46 @@ class TPPBaseReaderViewController: UIViewController, Loggable { /// - publication: The R2 model for a publication. /// - book: The SimplyE model for a book. /// - drm: Information about the DRM associated with the publication. - init(navigator: UIViewController & Navigator, - publication: Publication, - book: TPPBook, - forSample: Bool = false, - initialLocation: Locator? = nil) { - + init( + navigator: UIViewController & Navigator, + publication: Publication, + book: TPPBook, + forSample: Bool = false, + initialLocation: Locator? = nil + ) { self.navigator = navigator self.publication = publication - self.isShowingSample = forSample + isShowingSample = forSample self.initialLocation = initialLocation lastReadPositionPoster = TPPLastReadPositionPoster( book: book, publication: publication, - bookRegistryProvider: TPPBookRegistry.shared) + bookRegistryProvider: TPPBookRegistry.shared + ) bookmarksBusinessLogic = TPPReaderBookmarksBusinessLogic( book: book, r2Publication: publication, drmDeviceID: TPPUserAccount.sharedAccount().deviceID, bookRegistryProvider: TPPBookRegistry.shared, - currentLibraryAccountProvider: AccountsManager.shared) + currentLibraryAccountProvider: AccountsManager.shared + ) - bookmarksBusinessLogic.syncBookmarks { (_, _) in } + bookmarksBusinessLogic.syncBookmarks { _, _ in } super.init(nibName: nil, bundle: nil) - NotificationCenter.default.addObserver(self, selector: #selector(voiceOverStatusDidChange), name: Notification.Name(UIAccessibility.voiceOverStatusDidChangeNotification.rawValue), object: nil) - + NotificationCenter.default.addObserver( + self, + selector: #selector(voiceOverStatusDidChange), + name: Notification.Name(UIAccessibility.voiceOverStatusDidChangeNotification.rawValue), + object: nil + ) } @available(*, unavailable) - required init?(coder aDecoder: NSCoder) { + required init?(coder _: NSCoder) { fatalError("init(coder:) has not been implemented") } @@ -119,9 +128,18 @@ class TPPBaseReaderViewController: UIViewController, Loggable { positionLabel.textColor = .darkGray view.addSubview(positionLabel) NSLayoutConstraint.activate([ - positionLabel.bottomAnchor.constraint(equalTo: navigator.view.bottomAnchor, constant: -TPPBaseReaderViewController.overlayLabelMargin), - positionLabel.leftAnchor.constraint(equalTo: navigator.view.leftAnchor, constant: TPPBaseReaderViewController.overlayLabelMargin), - positionLabel.rightAnchor.constraint(equalTo: navigator.view.rightAnchor, constant: -TPPBaseReaderViewController.overlayLabelMargin) + positionLabel.bottomAnchor.constraint( + equalTo: navigator.view.bottomAnchor, + constant: -TPPBaseReaderViewController.overlayLabelMargin + ), + positionLabel.leftAnchor.constraint( + equalTo: navigator.view.leftAnchor, + constant: TPPBaseReaderViewController.overlayLabelMargin + ), + positionLabel.rightAnchor.constraint( + equalTo: navigator.view.rightAnchor, + constant: -TPPBaseReaderViewController.overlayLabelMargin + ) ]) bookTitleLabel.translatesAutoresizingMaskIntoConstraints = false @@ -131,13 +149,25 @@ class TPPBaseReaderViewController: UIViewController, Loggable { bookTitleLabel.textColor = .darkGray view.addSubview(bookTitleLabel) var layoutConstraints: [NSLayoutConstraint] = [ - bookTitleLabel.leftAnchor.constraint(equalTo: navigator.view.leftAnchor, constant: TPPBaseReaderViewController.overlayLabelMargin), - bookTitleLabel.rightAnchor.constraint(equalTo: navigator.view.rightAnchor, constant: -TPPBaseReaderViewController.overlayLabelMargin) + bookTitleLabel.leftAnchor.constraint( + equalTo: navigator.view.leftAnchor, + constant: TPPBaseReaderViewController.overlayLabelMargin + ), + bookTitleLabel.rightAnchor.constraint( + equalTo: navigator.view.rightAnchor, + constant: -TPPBaseReaderViewController.overlayLabelMargin + ) ] if #available(iOS 11.0, *) { - layoutConstraints.append(bookTitleLabel.topAnchor.constraint(equalTo: navigator.view.safeAreaLayoutGuide.topAnchor, constant: TPPBaseReaderViewController.overlayLabelMargin / 2)) + layoutConstraints.append(bookTitleLabel.topAnchor.constraint( + equalTo: navigator.view.safeAreaLayoutGuide.topAnchor, + constant: TPPBaseReaderViewController.overlayLabelMargin / 2 + )) } else { - layoutConstraints.append(bookTitleLabel.topAnchor.constraint(equalTo: navigator.view.topAnchor, constant: TPPBaseReaderViewController.overlayLabelMargin)) + layoutConstraints.append(bookTitleLabel.topAnchor.constraint( + equalTo: navigator.view.topAnchor, + constant: TPPBaseReaderViewController.overlayLabelMargin + )) } NSLayoutConstraint.activate(layoutConstraints) @@ -162,15 +192,11 @@ class TPPBaseReaderViewController: UIViewController, Loggable { stackView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor), stackView.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor), stackView.leadingAnchor.constraint(equalTo: view.leadingAnchor), - stackView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + stackView.trailingAnchor.constraint(equalTo: view.trailingAnchor) ]) } } - override func willMove(toParent parent: UIViewController?) { - super.willMove(toParent: parent) - } - override func viewDidAppear(_ animated: Bool) { super.viewDidAppear(animated) accessibilityToolbar.accessibilityElementsHidden = false @@ -183,7 +209,6 @@ class TPPBaseReaderViewController: UIViewController, Loggable { } } - // MARK: - Navigation bar private var navigationBarHidden: Bool = true { @@ -196,15 +221,20 @@ class TPPBaseReaderViewController: UIViewController, Loggable { var buttons: [UIBarButtonItem] = [] let img = UIImage(named: TPPBaseReaderViewController.bookmarkOffImageName) - let bookmarkBtn = UIBarButtonItem(image: img, - style: .plain, - target: self, - action: #selector(toggleBookmark)) - bookmarkBtn.accessibilityLabel = currentLocationIsBookmarked ? Strings.TPPBaseReaderViewController.removeBookmark : Strings.TPPBaseReaderViewController.addBookmark - let tocButton = UIBarButtonItem(image: UIImage(named: "TOC"), - style: .plain, - target: self, - action: #selector(presentPositionsVC)) + let bookmarkBtn = UIBarButtonItem( + image: img, + style: .plain, + target: self, + action: #selector(toggleBookmark) + ) + bookmarkBtn.accessibilityLabel = currentLocationIsBookmarked ? Strings.TPPBaseReaderViewController + .removeBookmark : Strings.TPPBaseReaderViewController.addBookmark + let tocButton = UIBarButtonItem( + image: UIImage(named: "TOC"), + style: .plain, + target: self, + action: #selector(presentPositionsVC) + ) tocButton.accessibilityLabel = Strings.Accessibility.viewBookmarksAndTocButton if !isShowingSample { @@ -244,26 +274,28 @@ class TPPBaseReaderViewController: UIViewController, Loggable { } override var preferredStatusBarUpdateAnimation: UIStatusBarAnimation { - return .slide + .slide } override var prefersStatusBarHidden: Bool { - return navigationBarHidden + navigationBarHidden } - //---------------------------------------------------------------------------- + // ---------------------------------------------------------------------------- // MARK: - TOC / Bookmarks private func shouldPresentAsPopover() -> Bool { - return UIDevice.current.userInterfaceIdiom == .pad + UIDevice.current.userInterfaceIdiom == .pad } @objc func presentPositionsVC() { let currentLocation = navigator.currentLocation let positionsVC = TPPReaderPositionsVC.newInstance() - positionsVC.tocBusinessLogic = TPPReaderTOCBusinessLogic(r2Publication: publication, - currentLocation: currentLocation) + positionsVC.tocBusinessLogic = TPPReaderTOCBusinessLogic( + r2Publication: publication, + currentLocation: currentLocation + ) positionsVC.bookmarksBusinessLogic = bookmarksBusinessLogic positionsVC.delegate = self @@ -296,12 +328,16 @@ class TPPBaseReaderViewController: UIViewController, Loggable { private func addBookmark(at location: TPPBookmarkR3Location) { Task { guard let bookmark = await bookmarksBusinessLogic.addBookmark(location) else { - let alert = TPPAlertUtils.alert(title: "Bookmarking Error", - message: "A bookmark could not be created on the current page.") - TPPAlertUtils.presentFromViewControllerOrNil(alertController: alert, - viewController: self, - animated: true, - completion: nil) + let alert = TPPAlertUtils.alert( + title: "Bookmarking Error", + message: "A bookmark could not be created on the current page." + ) + TPPAlertUtils.presentFromViewControllerOrNil( + alertController: alert, + viewController: self, + animated: true, + completion: nil + ) return } @@ -316,23 +352,27 @@ class TPPBaseReaderViewController: UIViewController, Loggable { didDeleteBookmark(bookmark) } - private func didDeleteBookmark(_ bookmark: TPPReadiumBookmark) { + private func didDeleteBookmark(_: TPPReadiumBookmark) { // at this point the bookmark has already been removed, so we just need // to verify that the user is not at the same location of another bookmark, // in which case the bookmark icon will be lit up and should stay lit up. if let loc = bookmarksBusinessLogic.currentLocation(in: navigator), - bookmarksBusinessLogic.isBookmarkExisting(at: loc) == nil { - + bookmarksBusinessLogic.isBookmarkExisting(at: loc) == nil + { updateBookmarkButton(withState: false) } } - //---------------------------------------------------------------------------- + // ---------------------------------------------------------------------------- // MARK: - Accessibility private lazy var accessibilityToolbar: UIToolbar = { - func makeItem(_ item: UIBarButtonItem.SystemItem, label: String? = nil, action: UIKit.Selector? = nil) -> UIBarButtonItem { + func makeItem( + _ item: UIBarButtonItem.SystemItem, + label: String? = nil, + action: UIKit.Selector? = nil + ) -> UIBarButtonItem { let button = UIBarButtonItem(barButtonSystemItem: item, target: (action != nil) ? self : nil, action: action) button.accessibilityLabel = label return button @@ -345,7 +385,7 @@ class TPPBaseReaderViewController: UIViewController, Loggable { toolbar.items = [ backButton, makeItem(.flexibleSpace), - forwardButton, + forwardButton ] toolbar.isHidden = !isVoiceOverRunning toolbar.tintColor = UIColor.black @@ -371,7 +411,9 @@ class TPPBaseReaderViewController: UIViewController, Loggable { bookTitleLabel.isHidden = isRunning // Adjust bottom inset for accessibility toolbar - if let scrollView = (navigator.view as? UIScrollView) ?? navigator.view.subviews.compactMap({ $0 as? UIScrollView }).first { + if let scrollView = (navigator.view as? UIScrollView) ?? navigator.view.subviews.compactMap({ $0 as? UIScrollView }) + .first + { if isRunning { // Ensure layout is up to date to get correct toolbar height view.layoutIfNeeded() @@ -408,11 +450,11 @@ class TPPBaseReaderViewController: UIViewController, Loggable { } } -//------------------------------------------------------------------------------ -// MARK: - NavigatorDelegate +// MARK: NavigatorDelegate +// ------------------------------------------------------------------------------ extension TPPBaseReaderViewController: NavigatorDelegate { - func navigator(_ navigator: Navigator, locationDidChange locator: Locator) { + func navigator(_: Navigator, locationDidChange locator: Locator) { Task { Log.info(#function, "R3 locator changed to: \(locator)") @@ -431,14 +473,15 @@ extension TPPBaseReaderViewController: NavigatorDelegate { let result = await publication.positions() switch result { - case .success(let locators): + case let .success(locators): positions = locators - case .failure(let error): + case let .failure(error): moduleDelegate?.presentError(error, from: self) } if let position = locator.locations.position { - return String(format: Strings.TPPBaseReaderViewController.pageOf, position) + "\(positions.count)" + chapterTitle + return String(format: Strings.TPPBaseReaderViewController.pageOf, position) + "\(positions.count)" + + chapterTitle } else if let progression = locator.locations.totalProgression { return "\(progression)%" + chapterTitle } else { @@ -449,7 +492,11 @@ extension TPPBaseReaderViewController: NavigatorDelegate { bookTitleLabel.text = publication.metadata.title if let resourceIndex = publication.resourceIndex(forLocator: locator), - let _ = bookmarksBusinessLogic.isBookmarkExisting(at: TPPBookmarkR3Location(resourceIndex: resourceIndex, locator: locator)) { + let _ = bookmarksBusinessLogic.isBookmarkExisting(at: TPPBookmarkR3Location( + resourceIndex: resourceIndex, + locator: locator + )) + { updateBookmarkButton(withState: true) } else { updateBookmarkButton(withState: false) @@ -457,7 +504,7 @@ extension TPPBaseReaderViewController: NavigatorDelegate { } } - func navigator(_ navigator: Navigator, presentExternalURL url: URL) { + func navigator(_: Navigator, presentExternalURL url: URL) { // SFSafariViewController crashes when given an URL without an HTTP scheme. guard ["http", "https"].contains(url.scheme?.lowercased() ?? "") else { return @@ -465,20 +512,23 @@ extension TPPBaseReaderViewController: NavigatorDelegate { present(SFSafariViewController(url: url), animated: true) } - func navigator(_ navigator: Navigator, presentError error: NavigatorError) { + func navigator(_: Navigator, presentError error: NavigatorError) { moduleDelegate?.presentError(error, from: self) } - func navigator(_ navigator: any ReadiumNavigator.Navigator, didFailToLoadResourceAt href: ReadiumShared.RelativeURL, withError error: ReadiumShared.ReadError) { + func navigator( + _: any ReadiumNavigator.Navigator, + didFailToLoadResourceAt _: ReadiumShared.RelativeURL, + withError error: ReadiumShared.ReadError + ) { moduleDelegate?.presentError(error, from: self) } } -//------------------------------------------------------------------------------ -// MARK: - VisualNavigatorDelegate +// MARK: VisualNavigatorDelegate +// ------------------------------------------------------------------------------ extension TPPBaseReaderViewController: VisualNavigatorDelegate { - func navigator(_ navigator: VisualNavigator, didTapAt point: CGPoint) { let viewport = navigator.view.bounds let thresholdRange = 0...(0.2 * viewport.width) @@ -498,9 +548,9 @@ extension TPPBaseReaderViewController: VisualNavigatorDelegate { } } -//------------------------------------------------------------------------------ -// MARK: - TPPReaderPositionsDelegate +// MARK: TPPReaderPositionsDelegate +// ------------------------------------------------------------------------------ extension TPPBaseReaderViewController: TPPReaderPositionsDelegate { func positionsVC(_ positionsVC: TPPReaderPositionsVC, didSelectTOCLocation loc: Any) { if shouldPresentAsPopover() { @@ -516,9 +566,10 @@ extension TPPBaseReaderViewController: TPPReaderPositionsDelegate { } } - func positionsVC(_ positionsVC: TPPReaderPositionsVC, - didSelectBookmark bookmark: TPPReadiumBookmark) { - + func positionsVC( + _: TPPReaderPositionsVC, + didSelectBookmark bookmark: TPPReadiumBookmark + ) { if shouldPresentAsPopover() { dismiss(animated: true) } else { @@ -533,13 +584,20 @@ extension TPPBaseReaderViewController: TPPReaderPositionsDelegate { } } - func positionsVC(_ positionsVC: TPPReaderPositionsVC, - didDeleteBookmark bookmark: TPPReadiumBookmark) { + func positionsVC( + _: TPPReaderPositionsVC, + didDeleteBookmark bookmark: TPPReadiumBookmark + ) { didDeleteBookmark(bookmark) } - func positionsVC(_ positionsVC: TPPReaderPositionsVC, - didRequestSyncBookmarksWithCompletion completion: @escaping (_ success: Bool, _ bookmarks: [TPPReadiumBookmark]) -> Void) { + func positionsVC( + _: TPPReaderPositionsVC, + didRequestSyncBookmarksWithCompletion completion: @escaping ( + _ success: Bool, + _ bookmarks: [TPPReadiumBookmark] + ) -> Void + ) { bookmarksBusinessLogic.syncBookmarks(completion: completion) } } diff --git a/Palace/Reader2/UI/TPPEPUBViewController.swift b/Palace/Reader2/UI/TPPEPUBViewController.swift index 25ad6863b..0395f0c8b 100644 --- a/Palace/Reader2/UI/TPPEPUBViewController.swift +++ b/Palace/Reader2/UI/TPPEPUBViewController.swift @@ -1,9 +1,11 @@ -import UIKit -import SwiftUI -import ReadiumShared import ReadiumNavigator -import WebKit +import ReadiumShared import SwiftSoup +import SwiftUI +import UIKit +import WebKit + +// MARK: - TPPEPUBViewController class TPPEPUBViewController: TPPBaseReaderViewController { var popoverUserconfigurationAnchor: UIBarButtonItem? @@ -16,24 +18,26 @@ class TPPEPUBViewController: TPPBaseReaderViewController { private var safeAreaInsets: UIEdgeInsets = { guard let window = UIApplication.shared.connectedScenes .compactMap({ $0 as? UIWindowScene }) - .flatMap({ $0.windows }) - .first(where: { $0.isKeyWindow }) else { + .flatMap(\.windows) + .first(where: { $0.isKeyWindow }) + else { return UIEdgeInsets() } return window.safeAreaInsets }() - init(publication: Publication, - book: TPPBook, - initialLocation: Locator?, - resourcesServer: HTTPServer, - preferences: EPUBPreferences = TPPReaderPreferencesLoad(), - forSample: Bool = false) throws { - - self.systemUserInterfaceStyle = UITraitCollection.current.userInterfaceStyle + init( + publication: Publication, + book: TPPBook, + initialLocation: Locator?, + resourcesServer: HTTPServer, + preferences: EPUBPreferences = TPPReaderPreferencesLoad(), + forSample: Bool = false + ) throws { + systemUserInterfaceStyle = UITraitCollection.current.userInterfaceStyle self.preferences = preferences - self.searchButton = UIBarButtonItem(barButtonSystemItem: .search, target: nil, action: #selector(presentEPUBSearch)) + searchButton = UIBarButtonItem(barButtonSystemItem: .search, target: nil, action: #selector(presentEPUBSearch)) let overlayLabelInset = 80.0 let contentInset: [UIUserInterfaceSizeClass: EPUBContentInsets] = [ .compact: (top: safeAreaInsets.top + overlayLabelInset, bottom: safeAreaInsets.bottom + overlayLabelInset), @@ -44,7 +48,8 @@ class TPPEPUBViewController: TPPBaseReaderViewController { preferences: preferences, editingActions: EditingAction.defaultActions.appending(EditingAction( title: "Highlight", - action: #selector(highlightSelection))), + action: #selector(highlightSelection) + )), contentInset: contentInset, decorationTemplates: HTMLDecorationTemplate.defaultTemplates(), debugState: false @@ -57,16 +62,22 @@ class TPPEPUBViewController: TPPBaseReaderViewController { httpServer: resourcesServer ) - super.init(navigator: navigator, publication: publication, book: book, forSample: forSample, initialLocation: initialLocation) + super.init( + navigator: navigator, + publication: publication, + book: book, + forSample: forSample, + initialLocation: initialLocation + ) navigator.delegate = self - self.searchButton.target = self + searchButton.target = self setUIColor(for: preferences) log(.info, "TPPEPUBViewController initialized with publication: \(publication.metadata.title ?? "Unknown Title").") } var epubNavigator: EPUBNavigatorViewController { - self.navigator as! EPUBNavigatorViewController + navigator as! EPUBNavigatorViewController } override func willMove(toParent parent: UIViewController?) { @@ -82,7 +93,12 @@ class TPPEPUBViewController: TPPBaseReaderViewController { epubNavigator.submitPreferences(preferences) if navigationItem.leftBarButtonItem == nil { - let backItem = UIBarButtonItem(image: UIImage(systemName: "chevron.left"), style: .plain, target: self, action: #selector(closeEPUB)) + let backItem = UIBarButtonItem( + image: UIImage(systemName: "chevron.left"), + style: .plain, + target: self, + action: #selector(closeEPUB) + ) navigationItem.leftBarButtonItem = backItem } } @@ -108,10 +124,12 @@ class TPPEPUBViewController: TPPBaseReaderViewController { override func makeNavigationBarButtons() -> [UIBarButtonItem] { var buttons = super.makeNavigationBarButtons() - let userSettingsButton = UIBarButtonItem(image: UIImage(named: "Format"), - style: .plain, - target: self, - action: #selector(presentUserSettings)) + let userSettingsButton = UIBarButtonItem( + image: UIImage(named: "Format"), + style: .plain, + target: self, + action: #selector(presentUserSettings) + ) userSettingsButton.accessibilityLabel = Strings.TPPEPUBViewController.readerSettings buttons.insert(userSettingsButton, at: 1) popoverUserconfigurationAnchor = userSettingsButton @@ -140,11 +158,14 @@ class TPPEPUBViewController: TPPBaseReaderViewController { } } +// MARK: EPUBSearchDelegate + extension TPPEPUBViewController: EPUBSearchDelegate { func didSelect(location: ReadiumShared.Locator) { - presentedViewController?.dismiss(animated: true) { [weak self] in - guard let self = self else { return } + guard let self = self else { + return + } Task { await self.navigator.go(to: location) @@ -154,7 +175,8 @@ extension TPPEPUBViewController: EPUBSearchDelegate { decorations.append(Decoration( id: "search", locator: location, - style: .highlight(tint: .red))) + style: .highlight(tint: .red) + )) await decorableNavigator.applyDecorationsAsync(decorations, in: "search") } } @@ -162,14 +184,15 @@ extension TPPEPUBViewController: EPUBSearchDelegate { } } -// MARK: - TPPReaderSettingsDelegate +// MARK: TPPReaderSettingsDelegate + extension TPPEPUBViewController: TPPReaderSettingsDelegate { func getUserPreferences() -> EPUBPreferences { - return preferences + preferences } func updateUserPreferencesStyle(for appearance: EPUBPreferences) { - self.preferences = appearance + preferences = appearance DispatchQueue.main.async { self.epubNavigator.submitPreferences(appearance) self.setUIColor(for: appearance) @@ -185,31 +208,43 @@ extension TPPEPUBViewController: TPPReaderSettingsDelegate { } } +// MARK: EPUBNavigatorDelegate + extension TPPEPUBViewController: EPUBNavigatorDelegate { - func navigator(_ navigator: Navigator, didFailWithError error: NavigatorError) { + func navigator(_: Navigator, didFailWithError error: NavigatorError) { log(.error, "Navigator error: \(error.localizedDescription)") } } +// MARK: UIPopoverPresentationControllerDelegate + extension TPPEPUBViewController: UIPopoverPresentationControllerDelegate { - func adaptivePresentationStyle(for controller: UIPresentationController) -> UIModalPresentationStyle { - return .none + func adaptivePresentationStyle(for _: UIPresentationController) -> UIModalPresentationStyle { + .none } } +// MARK: DecorableNavigator + extension TPPEPUBViewController: DecorableNavigator { func apply(decorations: [Decoration], in group: String) { - guard let navigator = navigator as? DecorableNavigator else { return } + guard let navigator = navigator as? DecorableNavigator else { + return + } navigator.apply(decorations: decorations, in: group) } func supports(decorationStyle style: Decoration.Style.Id) -> Bool { - guard let navigator = navigator as? DecorableNavigator else { return false } + guard let navigator = navigator as? DecorableNavigator else { + return false + } return navigator.supports(decorationStyle: style) } func observeDecorationInteractions(inGroup group: String, onActivated: @escaping OnActivatedCallback) { - guard let navigator = navigator as? DecorableNavigator else { return } + guard let navigator = navigator as? DecorableNavigator else { + return + } navigator.observeDecorationInteractions(inGroup: group, onActivated: onActivated) } @@ -230,7 +265,9 @@ extension TPPEPUBViewController: DecorableNavigator { } @objc private func highlightSelection() { - guard let selection = epubNavigator.currentSelection else { return } + guard let selection = epubNavigator.currentSelection else { + return + } addHighlight(for: selection.locator, color: .yellow) epubNavigator.clearSelection() } @@ -243,7 +280,7 @@ extension TPPEPUBViewController: DecorableNavigator { } } - private func handleHighlightInteraction(_ event: OnDecorationActivatedEvent) {} + private func handleHighlightInteraction(_: OnDecorationActivatedEvent) {} } public extension DecorableNavigator { @@ -253,4 +290,3 @@ public extension DecorableNavigator { } } } - diff --git a/Palace/Reader2/UI/TPPReaderBookmarkCell.swift b/Palace/Reader2/UI/TPPReaderBookmarkCell.swift index cab50da85..60d0b2545 100644 --- a/Palace/Reader2/UI/TPPReaderBookmarkCell.swift +++ b/Palace/Reader2/UI/TPPReaderBookmarkCell.swift @@ -1,8 +1,8 @@ import UIKit @objc class TPPReaderBookmarkCell: UITableViewCell { - @IBOutlet weak var chapterLabel: UILabel! - @IBOutlet weak var pageNumberLabel: UILabel! + @IBOutlet var chapterLabel: UILabel! + @IBOutlet var pageNumberLabel: UILabel! private static var dateFormatter: DateFormatter = { let formatter = DateFormatter() @@ -12,19 +12,27 @@ import UIKit }() @objc - func config(withChapterName chapterName: String, - percentInChapter: String, - rfc3339DateString: String) { + func config( + withChapterName chapterName: String, + percentInChapter: String, + rfc3339DateString: String + ) { backgroundColor = .clear chapterLabel.text = chapterName let formattedBookmarkDate = prettyDate(forRFC3339String: rfc3339DateString) - let progress = String.localizedStringWithFormat(NSLocalizedString("%@ through chapter", comment: "A concise string that expreses the percent progress, where %@ is the percentage"), percentInChapter) + let progress = String.localizedStringWithFormat( + NSLocalizedString( + "%@ through chapter", + comment: "A concise string that expreses the percent progress, where %@ is the percentage" + ), + percentInChapter + ) pageNumberLabel.text = "\(formattedBookmarkDate) - \(progress)" let textColor = TPPAssociatedColors.shared.appearanceColors.textColor - chapterLabel.textColor = textColor; - pageNumberLabel.textColor = textColor; + chapterLabel.textColor = textColor + pageNumberLabel.textColor = textColor } private func prettyDate(forRFC3339String dateStr: String) -> String { diff --git a/Palace/Reader2/UI/TPPReaderPositionsVC.swift b/Palace/Reader2/UI/TPPReaderPositionsVC.swift index 4b717436c..eb3e99887 100644 --- a/Palace/Reader2/UI/TPPReaderPositionsVC.swift +++ b/Palace/Reader2/UI/TPPReaderPositionsVC.swift @@ -6,8 +6,10 @@ // Copyright © 2020 NYPL Labs. All rights reserved. // -import UIKit import PureLayout +import UIKit + +// MARK: - TPPReaderPositionsDelegate /// A protocol describing callbacks for the possible user actions related /// to TOC items and bookmarks (aka positions). @@ -16,10 +18,14 @@ protocol TPPReaderPositionsDelegate: class { func positionsVC(_ positionsVC: TPPReaderPositionsVC, didSelectTOCLocation loc: Any) func positionsVC(_ positionsVC: TPPReaderPositionsVC, didSelectBookmark bookmark: TPPReadiumBookmark) func positionsVC(_ positionsVC: TPPReaderPositionsVC, didDeleteBookmark bookmark: TPPReadiumBookmark) - func positionsVC(_ positionsVC: TPPReaderPositionsVC, didRequestSyncBookmarksWithCompletion completion: @escaping (_ success: Bool, _ bookmarks: [TPPReadiumBookmark]) -> Void) + func positionsVC( + _ positionsVC: TPPReaderPositionsVC, + didRequestSyncBookmarksWithCompletion completion: @escaping (_ success: Bool, _ bookmarks: [TPPReadiumBookmark]) + -> Void + ) } -// MARK: - +// MARK: - TPPReaderPositionsVC /// A view controller for displaying "positions" inside a publication, /// where a position is either an element inside the Table of Contents or @@ -29,9 +35,9 @@ protocol TPPReaderPositionsDelegate: class { /// business logic, and `TPPReaderBookmarksBusinessLogic` for bookmarks logic. class TPPReaderPositionsVC: UIViewController, UITableViewDataSource, UITableViewDelegate { typealias DisplayStrings = Strings.TPPReaderPositionsVC - @IBOutlet weak var tableView: UITableView! - @IBOutlet weak var segmentedControl: UISegmentedControl! - @IBOutlet weak var noBookmarksLabel: UILabel! + @IBOutlet var tableView: UITableView! + @IBOutlet var segmentedControl: UISegmentedControl! + @IBOutlet var noBookmarksLabel: UILabel! private var bookmarksRefreshControl: UIRefreshControl? private let reuseIdentifierTOC = "contentCell" @@ -45,19 +51,19 @@ class TPPReaderPositionsVC: UIViewController, UITableViewDataSource, UITableView private enum Tab: Int, CaseIterable { case toc = 0 case bookmarks - + var title: String { switch self { case .toc: - return DisplayStrings.contents + DisplayStrings.contents case .bookmarks: - return DisplayStrings.bookmarks + DisplayStrings.bookmarks } } } private var currentTab: Tab { - return Tab(rawValue: segmentedControl.selectedSegmentIndex) ?? .toc + Tab(rawValue: segmentedControl.selectedSegmentIndex) ?? .toc } /// Uses default storyboard. @@ -66,7 +72,7 @@ class TPPReaderPositionsVC: UIViewController, UITableViewDataSource, UITableView return storyboard.instantiateViewController(withIdentifier: "TPPReaderPositionsVC") as! TPPReaderPositionsVC } - @objc func didSelectSegment(_ segmentedControl: UISegmentedControl) { + @objc func didSelectSegment(_: UISegmentedControl) { tableView.reloadData() configRefreshControl() @@ -110,10 +116,12 @@ class TPPReaderPositionsVC: UIViewController, UITableViewDataSource, UITableView segmentedControl.selectedSegmentTintColor = readerColors.tintColor segmentedControl.setTitleTextAttributes( [NSAttributedString.Key.foregroundColor: readerColors.tintColor], - for: .normal) + for: .normal + ) segmentedControl.setTitleTextAttributes( [NSAttributedString.Key.foregroundColor: readerColors.selectedForegroundColor], - for: .selected) + for: .selected + ) } else { segmentedControl.tintColor = readerColors.tintColor } @@ -133,41 +141,50 @@ class TPPReaderPositionsVC: UIViewController, UITableViewDataSource, UITableView // MARK: - UITableViewDataSource - func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + func tableView(_: UITableView, numberOfRowsInSection _: Int) -> Int { switch currentTab { case .toc: - return tocBusinessLogic?.tocElements.count ?? 0 + tocBusinessLogic?.tocElements.count ?? 0 case .bookmarks: - return bookmarksBusinessLogic?.bookmarks.count ?? 0 + bookmarksBusinessLogic?.bookmarks.count ?? 0 } } - @objc func tableView(_ tableView: UITableView, - cellForRowAt indexPath: IndexPath) -> UITableViewCell { + @objc func tableView( + _ tableView: UITableView, + cellForRowAt indexPath: IndexPath + ) -> UITableViewCell { switch currentTab { - case .toc: - let cell = tableView.dequeueReusableCell(withIdentifier: reuseIdentifierTOC, - for: indexPath) + let cell = tableView.dequeueReusableCell( + withIdentifier: reuseIdentifierTOC, + for: indexPath + ) if let cell = cell as? TPPReaderTOCCell, - let (title, level) = tocBusinessLogic?.titleAndLevel(forItemAt: indexPath.row) { - - cell.config(withTitle: title, - nestingLevel: level, - isForCurrentChapter: tocBusinessLogic?.isCurrentChapterTitled(title) ?? false) + let (title, level) = tocBusinessLogic?.titleAndLevel(forItemAt: indexPath.row) + { + cell.config( + withTitle: title, + nestingLevel: level, + isForCurrentChapter: tocBusinessLogic?.isCurrentChapterTitled(title) ?? false + ) } return cell case .bookmarks: - let cell = tableView.dequeueReusableCell(withIdentifier: reuseIdentifierBookmark, - for: indexPath) - let bookmark = self.bookmarksBusinessLogic?.bookmark(at: indexPath.row) + let cell = tableView.dequeueReusableCell( + withIdentifier: reuseIdentifierBookmark, + for: indexPath + ) + let bookmark = bookmarksBusinessLogic?.bookmark(at: indexPath.row) let chapter = tocBusinessLogic?.title(for: bookmark?.href ?? "") ?? bookmark?.chapter if let cell = cell as? TPPReaderBookmarkCell, let bookmark = bookmark { - cell.config(withChapterName: chapter ?? "", - percentInChapter: bookmark.percentInChapter, - rfc3339DateString: bookmark.time) + cell.config( + withChapterName: chapter ?? "", + percentInChapter: bookmark.percentInChapter, + rfc3339DateString: bookmark.time + ) } return cell } @@ -175,19 +192,23 @@ class TPPReaderPositionsVC: UIViewController, UITableViewDataSource, UITableView // MARK: - UITableViewDelegate - func tableView(_ tableView: UITableView, - estimatedHeightForRowAt indexPath: IndexPath) -> CGFloat { + func tableView( + _: UITableView, + estimatedHeightForRowAt _: IndexPath + ) -> CGFloat { switch currentTab { case .toc: - return TPPConfiguration.defaultTOCRowHeight() + TPPConfiguration.defaultTOCRowHeight() case .bookmarks: - return TPPConfiguration.defaultBookmarkRowHeight() + TPPConfiguration.defaultBookmarkRowHeight() } } - func tableView(_ tableView: UITableView, - heightForRowAt indexPath: IndexPath) -> CGFloat { - return UITableView.automaticDimension + func tableView( + _: UITableView, + heightForRowAt _: IndexPath + ) -> CGFloat { + UITableView.automaticDimension } func tableView(_ tv: UITableView, willSelectRowAt indexPath: IndexPath) -> IndexPath? { @@ -227,7 +248,9 @@ class TPPReaderPositionsVC: UIViewController, UITableViewDataSource, UITableView switch currentTab { case .toc: - guard let bizLogic = tocBusinessLogic else { return } + guard let bizLogic = tocBusinessLogic else { + return + } Task { if let locator = await bizLogic.tocLocator(at: indexPath.row) { @@ -238,7 +261,9 @@ class TPPReaderPositionsVC: UIViewController, UITableViewDataSource, UITableView } case .bookmarks: - guard let bizLogic = bookmarksBusinessLogic else { return } + guard let bizLogic = bookmarksBusinessLogic else { + return + } if let bookmark = bizLogic.bookmark(at: indexPath.row) { delegate?.positionsVC(self, didSelectBookmark: bookmark) @@ -246,36 +271,40 @@ class TPPReaderPositionsVC: UIViewController, UITableViewDataSource, UITableView } } - func tableView(_ tableView: UITableView, - editingStyleForRowAt indexPath: IndexPath) -> UITableViewCell.EditingStyle { + func tableView( + _: UITableView, + editingStyleForRowAt _: IndexPath + ) -> UITableViewCell.EditingStyle { switch currentTab { case .toc: - return .none + .none case .bookmarks: - return .delete + .delete } } - func tableView(_ tv: UITableView, canEditRowAt indexPath: IndexPath) -> Bool { + func tableView(_: UITableView, canEditRowAt _: IndexPath) -> Bool { switch currentTab { case .toc: - return false + false case .bookmarks: - return true + true } } - func tableView(_ tv: UITableView, - commit editingStyle: UITableViewCell.EditingStyle, - forRowAt indexPath: IndexPath) { + func tableView( + _: UITableView, + commit editingStyle: UITableViewCell.EditingStyle, + forRowAt indexPath: IndexPath + ) { switch currentTab { case .toc: - break; + break case .bookmarks: guard editingStyle == .delete else { return } - + if let removedBookmark = bookmarksBusinessLogic?.deleteBookmark(at: indexPath.row) { delegate?.positionsVC(self, didDeleteBookmark: removedBookmark) tableView.deleteRows(at: [indexPath], with: .fade) @@ -286,14 +315,16 @@ class TPPReaderPositionsVC: UIViewController, UITableViewDataSource, UITableView // MARK: - Helpers @objc(userDidRefreshBookmarksWith:) - private func userDidRefreshBookmarks(with refreshControl: UIRefreshControl) { - delegate?.positionsVC(self, didRequestSyncBookmarksWithCompletion: { (success, bookmarks) in + private func userDidRefreshBookmarks(with _: UIRefreshControl) { + delegate?.positionsVC(self, didRequestSyncBookmarksWithCompletion: { success, _ in TPPMainThreadRun.asyncIfNeeded { [weak self] in self?.tableView.reloadData() self?.bookmarksRefreshControl?.endRefreshing() if !success { - let alert = TPPAlertUtils.alert(title: "Error Syncing Bookmarks", - message: "There was an error syncing bookmarks to the server. Ensure your device is connected to the internet or try again later.") + let alert = TPPAlertUtils.alert( + title: "Error Syncing Bookmarks", + message: "There was an error syncing bookmarks to the server. Ensure your device is connected to the internet or try again later." + ) self?.present(alert, animated: true) } } @@ -310,9 +341,11 @@ class TPPReaderPositionsVC: UIViewController, UITableViewDataSource, UITableView if bookmarksBusinessLogic?.shouldAllowRefresh() ?? false { let refreshCtrl = UIRefreshControl() bookmarksRefreshControl = refreshCtrl - refreshCtrl.addTarget(self, - action: #selector(userDidRefreshBookmarks(with:)), - for: .valueChanged) + refreshCtrl.addTarget( + self, + action: #selector(userDidRefreshBookmarks(with:)), + for: .valueChanged + ) tableView.addSubview(refreshCtrl) } } diff --git a/Palace/Reader2/UI/TPPReaderTOCCell.swift b/Palace/Reader2/UI/TPPReaderTOCCell.swift index bef5bb98e..a8382a1fb 100644 --- a/Palace/Reader2/UI/TPPReaderTOCCell.swift +++ b/Palace/Reader2/UI/TPPReaderTOCCell.swift @@ -9,10 +9,9 @@ import UIKit @objc class TPPReaderTOCCell: UITableViewCell { - @objc @IBOutlet weak var titleLabel: UILabel! - @objc @IBOutlet weak var leadingEdgeConstraint: NSLayoutConstraint! - @objc @IBOutlet weak var background: UIView! - + @IBOutlet var titleLabel: UILabel! + @IBOutlet var leadingEdgeConstraint: NSLayoutConstraint! + @IBOutlet var background: UIView! /// Configure the cell's visual appearance. /// - Parameters: @@ -20,9 +19,11 @@ import UIKit /// - nestingLevel: How nested this chapter should look. /// - isForCurrentChapter: `true` if the cell represents the current /// chapter the user is on. - @objc func config(withTitle title: String, - nestingLevel: Int, - isForCurrentChapter: Bool) { + @objc func config( + withTitle title: String, + nestingLevel: Int, + isForCurrentChapter: Bool + ) { leadingEdgeConstraint?.constant = 0 leadingEdgeConstraint?.constant = CGFloat(nestingLevel * 20 + 10) diff --git a/Palace/Samples/AudiobookSamplePlayer/AudiobookSamplePlayer.swift b/Palace/Samples/AudiobookSamplePlayer/AudiobookSamplePlayer.swift index e03a5215e..352e660dd 100644 --- a/Palace/Samples/AudiobookSamplePlayer/AudiobookSamplePlayer.swift +++ b/Palace/Samples/AudiobookSamplePlayer/AudiobookSamplePlayer.swift @@ -9,6 +9,8 @@ import AVFoundation import Combine +// MARK: - AudiobookSamplePlayerState + enum AudiobookSamplePlayerState { case initialized case loading @@ -16,8 +18,9 @@ enum AudiobookSamplePlayerState { case playing } -class AudiobookSamplePlayer: NSObject, ObservableObject { +// MARK: - AudiobookSamplePlayer +class AudiobookSamplePlayer: NSObject, ObservableObject { @Published var remainingTime = 0.0 @Published var isLoading = false @Published var state: AudiobookSamplePlayerState = .initialized { @@ -39,10 +42,14 @@ class AudiobookSamplePlayer: NSObject, ObservableObject { configureAudioSession() downloadFile() } - + private func configureAudioSession() { do { - try AVAudioSession.sharedInstance().setCategory(.playback, mode: .spokenAudio, options: [.allowBluetooth, .allowAirPlay]) + try AVAudioSession.sharedInstance().setCategory( + .playback, + mode: .spokenAudio, + options: [.allowBluetooth, .allowAirPlay] + ) try AVAudioSession.sharedInstance().setActive(true) } catch { TPPErrorLogger.logError(error, summary: "Failed to set audio session category") @@ -77,7 +84,9 @@ class AudiobookSamplePlayer: NSObject, ObservableObject { } func goBack() { - guard let player = player, player.currentTime > 0 else { return } + guard let player = player, player.currentTime > 0 else { + return + } timer?.invalidate() self.player?.pause() @@ -91,7 +100,9 @@ class AudiobookSamplePlayer: NSObject, ObservableObject { @objc private func setDuration() { guard let player = player, player.currentTime < player.duration - else { return } + else { + return + } DispatchQueue.main.async { self.remainingTime = player.duration - player.currentTime @@ -114,8 +125,10 @@ class AudiobookSamplePlayer: NSObject, ObservableObject { private func downloadFile() { state = .loading - let _ = sample.fetchSample { [weak self] result in - guard let self = self else { return } + _ = sample.fetchSample { [weak self] result in + guard let self = self else { + return + } switch result { case let .failure(error, _): @@ -130,8 +143,10 @@ class AudiobookSamplePlayer: NSObject, ObservableObject { } } +// MARK: AVAudioPlayerDelegate + extension AudiobookSamplePlayer: AVAudioPlayerDelegate { - func audioPlayerDidFinishPlaying(_ player: AVAudioPlayer, successfully flag: Bool) { + func audioPlayerDidFinishPlaying(_: AVAudioPlayer, successfully _: Bool) { state = .paused } } diff --git a/Palace/Samples/EpubSamplePlayer/EpubSampleFactory.swift b/Palace/Samples/EpubSamplePlayer/EpubSampleFactory.swift index b27efd750..6af10fc6c 100644 --- a/Palace/Samples/EpubSamplePlayer/EpubSampleFactory.swift +++ b/Palace/Samples/EpubSamplePlayer/EpubSampleFactory.swift @@ -8,16 +8,22 @@ import Foundation +// MARK: - EpubLocationSampleURL + @objc class EpubLocationSampleURL: NSObject { @objc var url: URL - + init(url: URL) { self.url = url } } +// MARK: - EpubSampleWebURL + @objc class EpubSampleWebURL: EpubLocationSampleURL {} +// MARK: - EpubSampleFactory + @objc class EpubSampleFactory: NSObject { private static let samplePath = "TestApp.epub" @@ -27,14 +33,13 @@ import Foundation completion(nil, SamplePlayerError.noSampleAvailable) return } - + if epubSample.type.needsDownload { epubSample.fetchSample { result in switch result { - case .failure(let error, _): + case let .failure(error, _): completion(nil, error) - case .success(let data, _): - + case let .success(data, _): do { guard let location = try save(data: data) else { completion(nil, SamplePlayerError.fileSaveFailed(nil)) @@ -62,7 +67,7 @@ import Foundation // Create parent directory if it doesn't exist let parentDirectory = url.deletingLastPathComponent() try FileManager.default.createDirectory(at: parentDirectory, withIntermediateDirectories: true, attributes: nil) - + try data.write(to: url) Log.info(#file, "Successfully saved sample EPUB to: \(url.path)") } catch { @@ -77,11 +82,11 @@ import Foundation for: .documentDirectory, in: .userDomainMask )[0] - + // Create samples subdirectory to avoid root directory access issues let samplesDirectory = documentDirectory.appendingPathComponent("Samples") try? FileManager.default.createDirectory(at: samplesDirectory, withIntermediateDirectories: true, attributes: nil) - + return samplesDirectory.appendingPathComponent(samplePath) } } diff --git a/Palace/Samples/Sample.swift b/Palace/Samples/Sample.swift index 1400af889..d2fb01434 100644 --- a/Palace/Samples/Sample.swift +++ b/Palace/Samples/Sample.swift @@ -8,6 +8,8 @@ import Foundation +// MARK: - SampleType + enum SampleType: String { case contentTypeEpubZip = "application/epub+zip" case overdriveWeb = "text/html" @@ -18,13 +20,15 @@ enum SampleType: String { var needsDownload: Bool { switch self { case .contentTypeEpubZip, .overdriveAudiobookMpeg, .overdriveAudiobookWaveFile: - return true + true default: - return false + false } } } +// MARK: - Sample + protocol Sample { var url: URL { get } var type: SampleType { get } @@ -35,8 +39,8 @@ extension Sample { var needsDownload: Bool { type.needsDownload } func fetchSample(completion: @escaping (NYPLResult) -> Void) { - let _ = TPPNetworkExecutor.shared.GET(url, useTokenIfAvailable: false) { result in - completion(result) + _ = TPPNetworkExecutor.shared.GET(url, useTokenIfAvailable: false) { result in + completion(result) } } } diff --git a/Palace/Samples/SamplePreviewManager.swift b/Palace/Samples/SamplePreviewManager.swift index 4b8cdcb26..3199d3879 100644 --- a/Palace/Samples/SamplePreviewManager.swift +++ b/Palace/Samples/SamplePreviewManager.swift @@ -1,11 +1,13 @@ import SwiftUI +// MARK: - SamplePreviewManager + @MainActor final class SamplePreviewManager: ObservableObject { static let shared = SamplePreviewManager() - @Published private(set) var currentBookID: String? = nil - @Published private(set) var toolbar: AudiobookSampleToolbar? = nil + @Published private(set) var currentBookID: String? + @Published private(set) var toolbar: AudiobookSampleToolbar? private init() {} @@ -38,6 +40,8 @@ final class SamplePreviewManager: ObservableObject { } } +// MARK: - SamplePreviewBarView + struct SamplePreviewBarView: View { @ObservedObject private var manager = SamplePreviewManager.shared @@ -54,5 +58,3 @@ struct SamplePreviewBarView: View { } } } - - diff --git a/Palace/Settings/AccountList/TPPAccountList.swift b/Palace/Settings/AccountList/TPPAccountList.swift index f54c163d1..f8c5dd149 100644 --- a/Palace/Settings/AccountList/TPPAccountList.swift +++ b/Palace/Settings/AccountList/TPPAccountList.swift @@ -1,11 +1,11 @@ import Foundation +// MARK: - TPPAccountList /// List of available Libraries/Accounts to select as patron's primary /// when going through Welcome Screen flow. @objc final class TPPAccountList: UIViewController { - - private let completion: (Account) -> () + private let completion: (Account) -> Void private var loadingView: UIActivityIndicatorView? var datasource = TPPAccountListDataSource() @@ -18,13 +18,13 @@ import Foundation var requiresSelectionBeforeDismiss: Bool = false - @objc required init(completion: @escaping (Account) -> ()) { + @objc required init(completion: @escaping (Account) -> Void) { self.completion = completion super.init(nibName: nil, bundle: nil) } @available(*, unavailable) - required init?(coder aDecoder: NSCoder) { + required init?(coder _: NSCoder) { fatalError("init(coder:) has not been implemented") } @@ -111,34 +111,38 @@ import Foundation } } -// MARK: - UITableViewDelegate/DataSource +// MARK: UITableViewDelegate, UITableViewDataSource + extension TPPAccountList: UITableViewDelegate, UITableViewDataSource { - func numberOfSections(in tableView: UITableView) -> Int { + func numberOfSections(in _: UITableView) -> Int { numberOfSections } - func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat { + func tableView(_: UITableView, heightForRowAt _: IndexPath) -> CGFloat { UITableView.automaticDimension } - func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + func tableView(_: UITableView, didSelectRowAt indexPath: IndexPath) { completion(datasource.account(at: indexPath)) } - func tableView(_ tableView: UITableView, viewForHeaderInSection section: Int) -> UIView? { + func tableView(_: UITableView, viewForHeaderInSection _: Int) -> UIView? { UIView() } - func tableView(_ tableView: UITableView, heightForHeaderInSection section: Int) -> CGFloat { + func tableView(_: UITableView, heightForHeaderInSection section: Int) -> CGFloat { section == 0 ? 0 : sectionHeaderSize } - func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + func tableView(_: UITableView, numberOfRowsInSection section: Int) -> Int { datasource.accounts(in: section) } func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { - guard let cell = tableView.dequeueReusableCell(withIdentifier: TPPAccountListCell.reuseIdentifier, for: indexPath) as? TPPAccountListCell else { + guard let cell = tableView.dequeueReusableCell( + withIdentifier: TPPAccountListCell.reuseIdentifier, + for: indexPath + ) as? TPPAccountListCell else { return UITableViewCell() } cell.configure(for: datasource.account(at: indexPath)) @@ -146,15 +150,18 @@ extension TPPAccountList: UITableViewDelegate, UITableViewDataSource { } } -// MARK: - DataSourceDelegate +// MARK: DataSourceDelegate + extension TPPAccountList: DataSourceDelegate { func refresh() { tableView.reloadData() } } +// MARK: AccountLogoDelegate + extension TPPAccountList: AccountLogoDelegate { - func logoDidUpdate(in account: Account, to newLogo: UIImage) { + func logoDidUpdate(in account: Account, to _: UIImage) { if let indexPath = datasource.indexPath(for: account) { DispatchQueue.main.async { self.tableView.reloadRows(at: [indexPath], with: .automatic) diff --git a/Palace/Settings/AccountList/TPPAccountListCell.swift b/Palace/Settings/AccountList/TPPAccountListCell.swift index 175f7320f..361dfdcd4 100644 --- a/Palace/Settings/AccountList/TPPAccountListCell.swift +++ b/Palace/Settings/AccountList/TPPAccountListCell.swift @@ -1,66 +1,60 @@ import UIKit class TPPAccountListCell: UITableViewCell { - static let reuseIdentifier = "AccountListCell" var customImageView = UIImageView() var customTextlabel = UILabel() var customDetailLabel = UILabel() - - override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + + override init(style _: UITableViewCell.CellStyle, reuseIdentifier: String?) { super.init(style: .default, reuseIdentifier: reuseIdentifier) setup() } - + required init?(coder: NSCoder) { super.init(coder: coder) } - - override func awakeFromNib() { - super.awakeFromNib() - } func setup() { let container = UIView() let textContainer = UIView() - + accessoryType = .disclosureIndicator customImageView.contentMode = .scaleAspectFit - + customTextlabel.font = UIFont.palaceFont(ofSize: 14) customTextlabel.numberOfLines = 0 - + customDetailLabel.font = UIFont.palaceFont(ofSize: 12) customDetailLabel.numberOfLines = 0 - + textContainer.addSubview(customTextlabel) textContainer.addSubview(customDetailLabel) - + container.addSubview(customImageView) container.addSubview(textContainer) contentView.addSubview(container) - + customImageView.autoAlignAxis(toSuperviewAxis: .horizontal) customImageView.autoPinEdge(toSuperviewEdge: .left) customImageView.autoSetDimensions(to: CGSize(width: 45, height: 45)) - + textContainer.autoPinEdge(.left, to: .right, of: customImageView, withOffset: contentView.layoutMargins.left) textContainer.autoPinEdge(toSuperviewMargin: .right) textContainer.autoAlignAxis(toSuperviewAxis: .horizontal) - + NSLayoutConstraint.autoSetPriority(UILayoutPriority.defaultLow) { textContainer.autoPinEdge(toSuperviewEdge: .top, withInset: 0, relation: .greaterThanOrEqual) textContainer.autoPinEdge(toSuperviewEdge: .bottom, withInset: 0, relation: .greaterThanOrEqual) } - + customTextlabel.autoPinEdgesToSuperviewEdges(with: .zero, excludingEdge: .bottom) - + customDetailLabel.autoPinEdge(.top, to: .bottom, of: customTextlabel) customDetailLabel.autoPinEdgesToSuperviewEdges(with: .zero, excludingEdge: .top) - + container.autoPinEdgesToSuperviewMargins() container.autoSetDimension(.height, toSize: 55, relation: .greaterThanOrEqual) - } func configure(for account: Account) { diff --git a/Palace/Settings/AccountList/TPPAccountListDataSource.swift b/Palace/Settings/AccountList/TPPAccountListDataSource.swift index 331642156..2be852fbf 100644 --- a/Palace/Settings/AccountList/TPPAccountListDataSource.swift +++ b/Palace/Settings/AccountList/TPPAccountListDataSource.swift @@ -1,41 +1,50 @@ import Foundation +// MARK: - DataSourceDelegate + protocol DataSourceDelegate: AnyObject { func refresh() } -class TPPAccountListDataSource: NSObject { +// MARK: - TPPAccountListDataSource +class TPPAccountListDataSource: NSObject { weak var delegate: DataSourceDelegate? var title: String = Strings.TPPAccountListDataSource.addLibrary - + private var accounts: [Account]! private var nationalAccounts: [Account]! - + override init() { super.init() loadData() } - + func loadData(_ filterString: String? = nil) { accounts = AccountsManager.shared.accounts() accounts.sort { $0.name < $1.name } - + if let filter = filterString, !filter.isEmpty { - nationalAccounts = self.accounts.filter { AccountsManager.TPPNationalAccountUUIDs.contains($0.uuid) && $0.name.range(of: filter, options: .caseInsensitive) != nil } - accounts = self.accounts.filter { !AccountsManager.TPPNationalAccountUUIDs.contains($0.uuid) && $0.name.range(of: filter, options: .caseInsensitive) != nil } + nationalAccounts = accounts.filter { AccountsManager.TPPNationalAccountUUIDs.contains($0.uuid) && $0.name.range( + of: filter, + options: .caseInsensitive + ) != nil } + accounts = accounts.filter { !AccountsManager.TPPNationalAccountUUIDs.contains($0.uuid) && $0.name.range( + of: filter, + options: .caseInsensitive + ) != nil } } else { - nationalAccounts = self.accounts.filter { AccountsManager.TPPNationalAccountUUIDs.contains($0.uuid) } - accounts = self.accounts.filter { !AccountsManager.TPPNationalAccountUUIDs.contains($0.uuid) } + nationalAccounts = accounts.filter { AccountsManager.TPPNationalAccountUUIDs.contains($0.uuid) } + accounts = accounts.filter { !AccountsManager.TPPNationalAccountUUIDs.contains($0.uuid) } } - + delegate?.refresh() } - + func accounts(in section: Int) -> Int { section == .zero ? nationalAccounts.count : accounts.count } - + func account(at indexPath: IndexPath) -> Account { indexPath.section == .zero ? nationalAccounts[indexPath.row] : accounts[indexPath.row] } @@ -55,17 +64,18 @@ extension TPPAccountListDataSource { } } +// MARK: UISearchBarDelegate extension TPPAccountListDataSource: UISearchBarDelegate { - func searchBar(_ searchBar: UISearchBar, textDidChange searchText: String) { + func searchBar(_: UISearchBar, textDidChange searchText: String) { loadData(searchText) } - + func searchBarSearchButtonClicked(_ searchBar: UISearchBar) { loadData(searchBar.text) searchBar.resignFirstResponder() } - + func searchBarCancelButtonClicked(_ searchBar: UISearchBar) { searchBar.text = "" loadData() diff --git a/Palace/Settings/BarcodeScanning/BarcodeScanner.swift b/Palace/Settings/BarcodeScanning/BarcodeScanner.swift index cb1e6858f..c29da47fc 100644 --- a/Palace/Settings/BarcodeScanning/BarcodeScanner.swift +++ b/Palace/Settings/BarcodeScanning/BarcodeScanner.swift @@ -9,58 +9,61 @@ import AVFoundation import UIKit -fileprivate extension CGRect { +private extension CGRect { func contains(_ rect: CGRect) -> Bool { - self.minX <= rect.minX && self.maxX >= rect.maxX && self.minY <= rect.minY && self.maxY >= rect.maxY + minX <= rect.minX && maxX >= rect.maxX && minY <= rect.minY && maxY >= rect.maxY } } +// MARK: - BarcodeScanner + class BarcodeScanner: UIViewController, AVCaptureMetadataOutputObjectsDelegate { private var captureSession: AVCaptureSession! private var previewLayer: AVCaptureVideoPreviewLayer! private var scannerView: UIView! private var previewView = UIView(frame: .zero) - + private var completion: (_ barcode: String?) -> Void - - + init(completion: @escaping (_ barcode: String?) -> Void) { self.completion = completion super.init(nibName: nil, bundle: nil) } - + @available(*, unavailable) - required init?(coder: NSCoder) { + required init?(coder _: NSCoder) { fatalError("init(coder:) has not been implemented") } - + override func viewDidLoad() { super.viewDidLoad() - + view.backgroundColor = UIColor.black captureSession = AVCaptureSession() - - guard let videoCaptureDevice = AVCaptureDevice.default(for: .video) else { return } + + guard let videoCaptureDevice = AVCaptureDevice.default(for: .video) else { + return + } let videoInput: AVCaptureDeviceInput - + do { videoInput = try AVCaptureDeviceInput(device: videoCaptureDevice) } catch { return } - - if (captureSession.canAddInput(videoInput)) { + + if captureSession.canAddInput(videoInput) { captureSession.addInput(videoInput) } else { showError() return } - + let metadataOutput = AVCaptureMetadataOutput() - - if (captureSession.canAddOutput(metadataOutput)) { + + if captureSession.canAddOutput(metadataOutput) { captureSession.addOutput(metadataOutput) - + metadataOutput.setMetadataObjectsDelegate(self, queue: DispatchQueue.main) if #available(iOS 15.4, *) { metadataOutput.metadataObjectTypes = [.code39, .code128, .qr, .codabar] @@ -71,13 +74,13 @@ class BarcodeScanner: UIViewController, AVCaptureMetadataOutputObjectsDelegate { showError() return } - + // Camera view previewLayer = AVCaptureVideoPreviewLayer(session: captureSession) previewLayer.frame = view.layer.bounds previewLayer.videoGravity = .resizeAspectFill view.layer.addSublayer(previewLayer) - + // Scanner view scannerView = UIView(frame: .zero) scannerView.layer.borderColor = UIColor.systemRed.cgColor @@ -101,12 +104,12 @@ class BarcodeScanner: UIViewController, AVCaptureMetadataOutputObjectsDelegate { self.dismiss(animated: true) })) navigationItem.rightBarButtonItem = cancelButton - + startCaptureSession() } - + private func startCaptureSession() { - //-[AVCaptureSession startRunning] should be called from background thread. Calling it on the main thread can lead to UI unresponsiveness + // -[AVCaptureSession startRunning] should be called from background thread. Calling it on the main thread can lead to UI unresponsiveness DispatchQueue.global(qos: .background).async { if !self.captureSession.isRunning { self.captureSession.startRunning() @@ -115,7 +118,7 @@ class BarcodeScanner: UIViewController, AVCaptureMetadataOutputObjectsDelegate { } private func stopCaptureSession() { - //-[AVCaptureSession startRunning] should be called from background thread. Calling it on the main thread can lead to UI unresponsiveness + // -[AVCaptureSession startRunning] should be called from background thread. Calling it on the main thread can lead to UI unresponsiveness DispatchQueue.global(qos: .background).async { if self.captureSession.isRunning { self.captureSession.stopRunning() @@ -127,29 +130,36 @@ class BarcodeScanner: UIViewController, AVCaptureMetadataOutputObjectsDelegate { let ac = UIAlertController( title: Strings.TPPBarCode.cameraAccessDisabledTitle, message: Strings.TPPBarCode.cameraAccessDisabledBody, - preferredStyle: .alert) + preferredStyle: .alert + ) ac.addAction(UIAlertAction(title: Strings.Generic.ok, style: .default)) present(ac, animated: true) captureSession = nil } - + override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) startCaptureSession() } - + override func viewWillDisappear(_ animated: Bool) { super.viewWillDisappear(animated) stopCaptureSession() } - - func metadataOutput(_ output: AVCaptureMetadataOutput, didOutput metadataObjects: [AVMetadataObject], from connection: AVCaptureConnection) { - + + func metadataOutput( + _: AVCaptureMetadataOutput, + didOutput metadataObjects: [AVMetadataObject], + from _: AVCaptureConnection + ) { let barcodes = metadataObjects .compactMap { $0 as? AVMetadataMachineReadableCodeObject } .filter { metadataObject in // transforms coordinates - let barcodeObject = previewLayer.transformedMetadataObject(for: metadataObject) as! AVMetadataMachineReadableCodeObject + guard let barcodeObject = previewLayer + .transformedMetadataObject(for: metadataObject) as? AVMetadataMachineReadableCodeObject else { + return false + } return scannerView.frame.contains(barcodeObject.bounds) } if barcodes.count == 1, let value = barcodes.first?.stringValue { @@ -157,7 +167,5 @@ class BarcodeScanner: UIViewController, AVCaptureMetadataOutputObjectsDelegate { stopCaptureSession() dismiss(animated: true) } - } - } diff --git a/Palace/Settings/BarcodeScanning/TPPBarcode.swift b/Palace/Settings/BarcodeScanning/TPPBarcode.swift index 3ce943f68..93cffee79 100644 --- a/Palace/Settings/BarcodeScanning/TPPBarcode.swift +++ b/Palace/Settings/BarcodeScanning/TPPBarcode.swift @@ -1,8 +1,10 @@ -import Foundation import AVFoundation +import Foundation + +private let barcodeHeight: CGFloat = 100 +private let maxBarcodeWidth: CGFloat = 414 -fileprivate let barcodeHeight: CGFloat = 100 -fileprivate let maxBarcodeWidth: CGFloat = 414 +// MARK: - TPPBarcode /// Manage creation and scanning of barcodes on library cards. /// Keep any third party dependency abstracted out of the main app. @@ -11,17 +13,16 @@ fileprivate let maxBarcodeWidth: CGFloat = 414 var libraryName: String? - init (library: String) { - self.libraryName = library + init(library: String) { + libraryName = library } - func image(fromString stringToEncode: String) -> UIImage? - { + func image(fromString stringToEncode: String) -> UIImage? { let data = stringToEncode.data(using: String.Encoding.ascii) if let filter = CIFilter(name: "CICode128BarcodeGenerator") { filter.setValue(data, forKey: "inputMessage") let transform = CGAffineTransform(scaleX: 3, y: 3) - + if let output = filter.outputImage?.transformed(by: transform) { return UIImage(ciImage: output) } @@ -29,13 +30,12 @@ fileprivate let maxBarcodeWidth: CGFloat = 414 return nil } - class func presentScanner(withCompletion completion: @escaping (String?) -> ()) - { + class func presentScanner(withCompletion completion: @escaping (String?) -> Void) { AVCaptureDevice.requestAccess(for: .video) { granted in DispatchQueue.main.async { if granted { let scannerVC = BarcodeScanner(completion: completion) - let navController = UINavigationController.init(rootViewController: scannerVC) + let navController = UINavigationController(rootViewController: scannerVC) TPPPresentationUtils.safelyPresent(navController, animated: true, completion: nil) } else { presentCameraPrivacyAlert() @@ -44,33 +44,39 @@ fileprivate let maxBarcodeWidth: CGFloat = 414 } } - private class func presentCameraPrivacyAlert() - { + private class func presentCameraPrivacyAlert() { let alertController = UIAlertController( title: DisplayStrings.cameraAccessDisabledTitle, message: DisplayStrings.cameraAccessDisabledBody, - preferredStyle: .alert) + preferredStyle: .alert + ) alertController.addAction(UIAlertAction( title: DisplayStrings.openSettings, style: .default, - handler: {_ in - UIApplication.shared.open(URL(string:UIApplication.openSettingsURLString)!) - })) + handler: { _ in + UIApplication.shared.open(URL(string: UIApplication.openSettingsURLString)!) + } + )) alertController.addAction(UIAlertAction( title: Strings.Generic.cancel, style: .cancel, - handler: nil)) + handler: nil + )) - TPPAlertUtils.presentFromViewControllerOrNil(alertController: alertController, viewController: nil, animated: true, completion: nil) + TPPAlertUtils.presentFromViewControllerOrNil( + alertController: alertController, + viewController: nil, + animated: true, + completion: nil + ) } - private func imageWidthFor(_ superviewWidth: CGFloat) -> CGFloat - { + private func imageWidthFor(_ superviewWidth: CGFloat) -> CGFloat { if superviewWidth > maxBarcodeWidth { - return maxBarcodeWidth + maxBarcodeWidth } else { - return superviewWidth + superviewWidth } } } diff --git a/Palace/Settings/BundleExtension.swift b/Palace/Settings/BundleExtension.swift index 2146e2c6d..750398458 100644 --- a/Palace/Settings/BundleExtension.swift +++ b/Palace/Settings/BundleExtension.swift @@ -7,9 +7,13 @@ // import Foundation +// MARK: - TPPEnvironment + @objc enum TPPEnvironment: NSInteger { - case debug, testFlight, production + case debug + case testFlight + case production } /** @@ -17,13 +21,12 @@ enum TPPEnvironment: NSInteger { https://stackoverflow.com/questions/26081543/how-to-tell-at-runtime-whether-an-ios-app-is-running-through-a-testflight-beta-i */ extension Bundle { - @objc var applicationEnvironment: TPPEnvironment { #if DEBUG return .debug #else - guard let path = self.appStoreReceiptURL?.path else { + guard let path = appStoreReceiptURL?.path else { return .production } // Sandbox receipt means the app is in TestFlight environment. diff --git a/Palace/Settings/DeveloperSettings/TPPDeveloperSettingsTableViewController.swift b/Palace/Settings/DeveloperSettings/TPPDeveloperSettingsTableViewController.swift index 0d2431607..ecb41ccd8 100644 --- a/Palace/Settings/DeveloperSettings/TPPDeveloperSettingsTableViewController.swift +++ b/Palace/Settings/DeveloperSettings/TPPDeveloperSettingsTableViewController.swift @@ -1,190 +1,208 @@ import MessageUI +// MARK: - TPPDeveloperSettingsTableViewController + @objcMembers -class TPPDeveloperSettingsTableViewController: UIViewController, UITableViewDelegate, UITableViewDataSource, MFMailComposeViewControllerDelegate { - +class TPPDeveloperSettingsTableViewController: UIViewController, UITableViewDelegate, UITableViewDataSource, + MFMailComposeViewControllerDelegate +{ weak var tableView: UITableView! var loadingView: UIView? - + enum Section: Int, CaseIterable { case librarySettings = 0 case libraryRegistryDebugging case dataManagement case developerTools } - + private let betaLibraryCellIdentifier = "betaLibraryCell" private let lcpPassphraseCellIdentifier = "lcpPassphraseCell" private let clearCacheCellIdentifier = "clearCacheCell" private let emailLogsCellIdentifier = "emailLogsCell" - + private var pushNotificationsStatus = false - + required init() { super.init(nibName: nil, bundle: nil) } - + @available(*, unavailable) - required init?(coder aDecoder: NSCoder) { + required init?(coder _: NSCoder) { fatalError("init(coder:) has not been implemented") } - - @objc func librarySwitchDidChange(sender: UISwitch!) { + + func librarySwitchDidChange(sender: UISwitch!) { TPPSettings.shared.useBetaLibraries = sender.isOn } - - @objc func enterLCPPassphraseSwitchDidChange(sender: UISwitch) { + + func enterLCPPassphraseSwitchDidChange(sender: UISwitch) { TPPSettings.shared.enterLCPPassphraseManually = sender.isOn } - - // MARK:- UIViewController - + + // MARK: - UIViewController + override func loadView() { - self.view = UITableView(frame: CGRect.zero, style: .grouped) - self.tableView = self.view as? UITableView - self.tableView.delegate = self - self.tableView.dataSource = self - - self.title = Strings.TPPDeveloperSettingsTableViewController.developerSettingsTitle - self.view.backgroundColor = TPPConfiguration.backgroundColor() - - self.tableView.register(UITableViewCell.self, forCellReuseIdentifier: betaLibraryCellIdentifier) - self.tableView.register(UITableViewCell.self, forCellReuseIdentifier: lcpPassphraseCellIdentifier) - self.tableView.register(UITableViewCell.self, forCellReuseIdentifier: clearCacheCellIdentifier) - self.tableView.register(UITableViewCell.self, forCellReuseIdentifier: emailLogsCellIdentifier) - } - - // MARK:- UITableViewDataSource - - func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + view = UITableView(frame: CGRect.zero, style: .grouped) + tableView = view as? UITableView + tableView.delegate = self + tableView.dataSource = self + + title = Strings.TPPDeveloperSettingsTableViewController.developerSettingsTitle + view.backgroundColor = TPPConfiguration.backgroundColor() + + tableView.register(UITableViewCell.self, forCellReuseIdentifier: betaLibraryCellIdentifier) + tableView.register(UITableViewCell.self, forCellReuseIdentifier: lcpPassphraseCellIdentifier) + tableView.register(UITableViewCell.self, forCellReuseIdentifier: clearCacheCellIdentifier) + tableView.register(UITableViewCell.self, forCellReuseIdentifier: emailLogsCellIdentifier) + } + + // MARK: - UITableViewDataSource + + func tableView(_: UITableView, numberOfRowsInSection section: Int) -> Int { switch Section(rawValue: section)! { - case .librarySettings: return 2 - case .developerTools: return 1 - default: return 1 + case .librarySettings: 2 + case .developerTools: 1 + default: 1 } } - - func numberOfSections(in tableView: UITableView) -> Int { - return Section.allCases.count + + func numberOfSections(in _: UITableView) -> Int { + Section.allCases.count } - - func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + + func tableView(_: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { switch Section(rawValue: indexPath.section)! { case .librarySettings: switch indexPath.row { - case 0: return cellForBetaLibraries() - default: return cellForLCPPassphrase() + case 0: cellForBetaLibraries() + default: cellForLCPPassphrase() } - case .libraryRegistryDebugging: return cellForCustomRegsitry() - case .dataManagement: return cellForClearCache() - case .developerTools: return cellForEmailLogs() + case .libraryRegistryDebugging: cellForCustomRegsitry() + case .dataManagement: cellForClearCache() + case .developerTools: cellForEmailLogs() } } - - func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? { + + func tableView(_: UITableView, titleForHeaderInSection section: Int) -> String? { switch Section(rawValue: section)! { case .librarySettings: - return "Library Settings" + "Library Settings" case .libraryRegistryDebugging: - return "Library Registry Debugging" + "Library Registry Debugging" case .dataManagement: - return "Data Management" + "Data Management" case .developerTools: - return "Developer Tools" + "Developer Tools" } } - + private func createSwitch(isOn: Bool, action: Selector) -> UISwitch { let switchControl = UISwitch() switchControl.isOn = isOn switchControl.addTarget(self, action: action, for: .valueChanged) return switchControl } - + private func cellForBetaLibraries() -> UITableViewCell { let cell = tableView.dequeueReusableCell(withIdentifier: betaLibraryCellIdentifier)! cell.selectionStyle = .none cell.textLabel?.text = "Enable Hidden Libraries" - cell.accessoryView = createSwitch(isOn: TPPSettings.shared.useBetaLibraries, action: #selector(librarySwitchDidChange)) + cell.accessoryView = createSwitch( + isOn: TPPSettings.shared.useBetaLibraries, + action: #selector(librarySwitchDidChange) + ) return cell } - + private func cellForLCPPassphrase() -> UITableViewCell { let cell = tableView.dequeueReusableCell(withIdentifier: lcpPassphraseCellIdentifier)! cell.selectionStyle = .none cell.textLabel?.text = "Enter LCP Passphrase Manually" cell.textLabel?.adjustsFontSizeToFitWidth = true cell.textLabel?.minimumScaleFactor = 0.5 - cell.accessoryView = createSwitch(isOn: TPPSettings.shared.enterLCPPassphraseManually, action: #selector(enterLCPPassphraseSwitchDidChange)) + cell.accessoryView = createSwitch( + isOn: TPPSettings.shared.enterLCPPassphraseManually, + action: #selector(enterLCPPassphraseSwitchDidChange) + ) return cell } - + private func cellForCustomRegsitry() -> UITableViewCell { let cell = TPPRegistryDebuggingCell() cell.delegate = self return cell } - + private func cellForClearCache() -> UITableViewCell { let cell = tableView.dequeueReusableCell(withIdentifier: clearCacheCellIdentifier)! cell.selectionStyle = .none cell.textLabel?.text = "Clear Cached Data" return cell } - + private func cellForEmailLogs() -> UITableViewCell { let cell = tableView.dequeueReusableCell(withIdentifier: emailLogsCellIdentifier)! cell.selectionStyle = .none cell.textLabel?.text = "Email Logs" return cell } - - // MARK:- UITableViewDelegate - - func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { - self.tableView.deselectRow(at: indexPath, animated: true) - + + // MARK: - UITableViewDelegate + + func tableView(_: UITableView, didSelectRowAt indexPath: IndexPath) { + tableView.deselectRow(at: indexPath, animated: true) + if Section(rawValue: indexPath.section) == .dataManagement { AccountsManager.shared.clearCache() ImageCache.shared.clear() let alert = TPPAlertUtils.alert(title: "Data Management", message: "Cache Cleared") - self.present(alert, animated: true, completion: nil) + present(alert, animated: true, completion: nil) } else if Section(rawValue: indexPath.section) == .developerTools { emailLogs() } } - + private func emailLogs() { guard MFMailComposeViewController.canSendMail() else { - let alert = TPPAlertUtils.alert(title: "Mail Unavailable", message: "Cannot send email. Please configure an email account.") - self.present(alert, animated: true, completion: nil) + let alert = TPPAlertUtils.alert( + title: "Mail Unavailable", + message: "Cannot send email. Please configure an email account." + ) + present(alert, animated: true, completion: nil) return } - + let mailComposer = MFMailComposeViewController() mailComposer.mailComposeDelegate = self mailComposer.setSubject("Audiobook Logs") mailComposer.setToRecipients(["maurice.carrier@outlook.com"]) mailComposer.setPreferredSendingEmailAddress("LyrasisDebugging@email.com") - + let logger = AudiobookFileLogger() if let logsDirectoryUrl = logger.getLogsDirectoryUrl() { let fileManager = FileManager.default let logFiles = try? fileManager.contentsOfDirectory(at: logsDirectoryUrl, includingPropertiesForKeys: nil) - + logFiles?.forEach { logFileUrl in if let logData = try? Data(contentsOf: logFileUrl) { mailComposer.addAttachmentData(logData, mimeType: "text/plain", fileName: logFileUrl.lastPathComponent) } } } - - self.present(mailComposer, animated: true, completion: nil) + + present(mailComposer, animated: true, completion: nil) } - - func mailComposeController(_ controller: MFMailComposeViewController, didFinishWith result: MFMailComposeResult, error: Error?) { + + func mailComposeController( + _ controller: MFMailComposeViewController, + didFinishWith _: MFMailComposeResult, + error _: Error? + ) { controller.dismiss(animated: true, completion: nil) } } +// MARK: TPPRegistryDebugger + extension TPPDeveloperSettingsTableViewController: TPPRegistryDebugger {} diff --git a/Palace/Settings/DeveloperSettings/TPPRegistryDebuggingCell.swift b/Palace/Settings/DeveloperSettings/TPPRegistryDebuggingCell.swift index 01e85cb1c..51ade32ae 100644 --- a/Palace/Settings/DeveloperSettings/TPPRegistryDebuggingCell.swift +++ b/Palace/Settings/DeveloperSettings/TPPRegistryDebuggingCell.swift @@ -1,18 +1,21 @@ import UIKit +// MARK: - TPPRegistryDebugger + protocol TPPRegistryDebugger: TPPLoadingViewController {} +// MARK: - TPPRegistryDebuggingCell + class TPPRegistryDebuggingCell: UITableViewCell { - private var inputField = UITextField() weak var delegate: TPPLoadingViewController? - + private var reloadInProgress: Bool = false { didSet { - reloadInProgress ? delegate?.startLoading() : delegate?.stopLoading() + reloadInProgress ? delegate?.startLoading() : delegate?.stopLoading() } } - + lazy var horizontalStackView: UIStackView = { let stack = UIStackView() stack.axis = .horizontal @@ -21,7 +24,7 @@ class TPPRegistryDebuggingCell: UITableViewCell { stack.alignment = .center return stack }() - + lazy var prefixLabel: UILabel = { let label = UILabel() label.text = "https://" @@ -30,7 +33,7 @@ class TPPRegistryDebuggingCell: UITableViewCell { label.setContentHuggingPriority(.defaultHigh, for: .horizontal) return label }() - + lazy var postfixLabel: UILabel = { let label = UILabel() label.text = "/libraries/qa" @@ -39,45 +42,45 @@ class TPPRegistryDebuggingCell: UITableViewCell { label.setContentHuggingPriority(.defaultHigh, for: .horizontal) return label }() - + lazy var inputStackView: UIStackView = { let stackView = UIStackView() stackView.axis = .vertical - + inputField.placeholder = "Input custom server" inputField.text = TPPSettings.shared.customLibraryRegistryServer inputField.autocapitalizationType = .none inputField.autocorrectionType = .no - + stackView.addArrangedSubview(inputField) inputField.autoSetDimension(.width, toSize: 200, relation: .greaterThanOrEqual) - + let underline = UIView() underline.backgroundColor = .gray stackView.addArrangedSubview(underline) underline.autoSetDimension(.height, toSize: 1) return stackView }() - + lazy var buttonStackView: UIStackView = { let stackView = UIStackView() stackView.axis = .horizontal stackView.spacing = 30 stackView.distribution = .equalSpacing - + let clearButton = UIButton() clearButton.layer.cornerRadius = 5 clearButton.layer.borderWidth = 1 clearButton.layer.borderColor = UIColor.defaultLabelColor().cgColor - + clearButton.setTitle("Clear", for: .normal) clearButton.setTitleColor(UIColor.defaultLabelColor(), for: .normal) clearButton.addTarget(self, action: #selector(clear), for: .touchUpInside) - + clearButton.autoSetDimension(.width, toSize: 125) stackView.addArrangedSubview(clearButton) - + let setButton = UIButton() setButton.layer.cornerRadius = 5 setButton.layer.borderWidth = 1 @@ -91,50 +94,50 @@ class TPPRegistryDebuggingCell: UITableViewCell { stackView.addArrangedSubview(setButton) return stackView }() - + init() { super.init(style: .default, reuseIdentifier: "CustomRegistryCell") configure() } - + required init?(coder: NSCoder) { super.init(coder: coder) } - + private func configure() { selectionStyle = .none - + let containerStack = UIStackView() containerStack.axis = .vertical containerStack.spacing = 10 inputField.delegate = self - + horizontalStackView.addArrangedSubview(prefixLabel) horizontalStackView.addArrangedSubview(inputStackView) horizontalStackView.addArrangedSubview(postfixLabel) containerStack.addArrangedSubview(horizontalStackView) containerStack.addArrangedSubview(buttonStackView) contentView.addSubview(containerStack) - + containerStack.autoSetDimension(.height, toSize: 100, relation: .greaterThanOrEqual) containerStack.autoPinEdgesToSuperviewMargins() } - + @objc private func clear() { inputField.text = nil TPPSettings.shared.customLibraryRegistryServer = nil AccountsManager.shared.clearCache() - self.showAlert(title: "Configuration Updated", message: "Registry has been reset to default") + showAlert(title: "Configuration Updated", message: "Registry has been reset to default") } - + @objc private func set() { AccountsManager.shared.clearCache() guard let text = inputField.text, !text.isEmpty else { - self.showAlert(title: "Configuration Update Failed", message: "Please enter a valid server URL") + showAlert(title: "Configuration Update Failed", message: "Please enter a valid server URL") return } - + TPPSettings.shared.customLibraryRegistryServer = text let message = String(format: "Registry server: %@", text) reloadRegistry { isSuccess in @@ -145,26 +148,34 @@ class TPPRegistryDebuggingCell: UITableViewCell { } } } - + private func reloadRegistry(completion: @escaping (Bool) -> Void) { - guard !reloadInProgress else { return } + guard !reloadInProgress else { + return + } reloadInProgress.toggle() - + AccountsManager.shared.clearCache() AccountsManager.shared.updateAccountSet { isSuccess in self.reloadInProgress.toggle() completion(isSuccess) } } - + private func showAlert(title: String, message: String) { let alert = TPPAlertUtils.alert(title: title, message: message) DispatchQueue.main.async { - (UIApplication.shared.delegate as? TPPAppDelegate)?.topViewController()?.present(alert, animated: true, completion: nil) + (UIApplication.shared.delegate as? TPPAppDelegate)?.topViewController()?.present( + alert, + animated: true, + completion: nil + ) } } } +// MARK: UITextFieldDelegate + extension TPPRegistryDebuggingCell: UITextFieldDelegate { func textFieldDidChangeSelection(_ textField: UITextField) { textField.text = textField.text?.trimmingCharacters(in: .whitespacesAndNewlines) diff --git a/Palace/Settings/DeveloperSettings/TPPReloadView.swift b/Palace/Settings/DeveloperSettings/TPPReloadView.swift index fc454640a..28bf8c101 100644 --- a/Palace/Settings/DeveloperSettings/TPPReloadView.swift +++ b/Palace/Settings/DeveloperSettings/TPPReloadView.swift @@ -5,6 +5,7 @@ final class TPPReloadView: UIView { var handler: (() -> Void)? // MARK: - UI + private let titleLabel: UILabel = { let label = UILabel() label.font = UIFont.boldPalaceFont(ofSize: 17) @@ -29,7 +30,8 @@ final class TPPReloadView: UIView { }() // MARK: - Init - override init(frame: CGRect) { + + override init(frame _: CGRect) { super.init(frame: CGRect(x: 0, y: 0, width: 280, height: 0)) commonInit() } @@ -51,6 +53,7 @@ final class TPPReloadView: UIView { } // MARK: - Layout + override func layoutSubviews() { super.layoutSubviews() @@ -77,12 +80,14 @@ final class TPPReloadView: UIView { } // MARK: - Actions + @objc private func didTapReload() { handler?() setDefaultMessage() } // MARK: - Public (ObjC) + func setDefaultMessage() { messageLabel.text = NSLocalizedString("Check Connection", comment: "") setNeedsLayout() @@ -93,5 +98,3 @@ final class TPPReloadView: UIView { setNeedsLayout() } } - - diff --git a/Palace/Settings/NewSettings/TPPSettingsView.swift b/Palace/Settings/NewSettings/TPPSettingsView.swift index 4009a401c..35b388682 100644 --- a/Palace/Settings/NewSettings/TPPSettingsView.swift +++ b/Palace/Settings/NewSettings/TPPSettingsView.swift @@ -6,8 +6,8 @@ // Copyright © 2021 The Palace Project. All rights reserved. // -import SwiftUI import PalaceUIKit +import SwiftUI struct TPPSettingsView: View { typealias DisplayStrings = Strings.Settings @@ -16,12 +16,12 @@ struct TPPSettingsView: View { @State private var selectedView: Int? = 0 @State private var orientation: UIDeviceOrientation = UIDevice.current.orientation @State private var showAddLibrarySheet: Bool = false - @State private var librariesRefreshToken: UUID = UUID() + @State private var librariesRefreshToken: UUID = .init() private var sideBarEnabled: Bool { UIDevice.current.userInterfaceIdiom == .pad - && UIDevice.current.orientation != .portrait - && UIDevice.current.orientation != .portraitUpsideDown + && UIDevice.current.orientation != .portrait + && UIDevice.current.orientation != .portraitUpsideDown } var body: some View { @@ -49,7 +49,7 @@ struct TPPSettingsView: View { .navigationBarTitle(DisplayStrings.settings) .listStyle(GroupedListStyle()) .onReceive(NotificationCenter.default.publisher(for: UIDevice.orientationDidChangeNotification)) { _ in - self.orientation = UIDevice.current.orientation + orientation = UIDevice.current.orientation } .onReceive(NotificationCenter.default.publisher(for: .TPPCurrentAccountDidChange)) { _ in librariesRefreshToken = UUID() @@ -80,18 +80,18 @@ struct TPPSettingsView: View { .id(librariesRefreshToken) Section { - row(title: DisplayStrings.libraries, index: 1, selection: self.$selectedView, destination: wrapper.anyView()) + row(title: DisplayStrings.libraries, index: 1, selection: $selectedView, destination: wrapper.anyView()) } } @ViewBuilder private var infoSection: some View { let view: AnyView = showDeveloperSettings ? EmptyView().anyView() : versionInfo.anyView() - Section(footer: view) { - aboutRow - privacyRow - userAgreementRow - softwareLicenseRow - } + Section(footer: view) { + aboutRow + privacyRow + userAgreementRow + softwareLicenseRow + } } @ViewBuilder private var aboutRow: some View { @@ -100,11 +100,11 @@ struct TPPSettingsView: View { title: Strings.Settings.aboutApp, failureMessage: Strings.Error.loadFailedError ) - + let wrapper = UIViewControllerWrapper(viewController, updater: { _ in }) .navigationBarTitle(Text(DisplayStrings.aboutApp)) - row(title: DisplayStrings.aboutApp, index: 2, selection: self.$selectedView, destination: wrapper.anyView()) + row(title: DisplayStrings.aboutApp, index: 2, selection: $selectedView, destination: wrapper.anyView()) } @ViewBuilder private var privacyRow: some View { @@ -117,8 +117,7 @@ struct TPPSettingsView: View { let wrapper = UIViewControllerWrapper(viewController, updater: { _ in }) .navigationBarTitle(Text(DisplayStrings.privacyPolicy)) - row(title: DisplayStrings.privacyPolicy, index: 3, selection: self.$selectedView, destination: wrapper.anyView()) - + row(title: DisplayStrings.privacyPolicy, index: 3, selection: $selectedView, destination: wrapper.anyView()) } @ViewBuilder private var userAgreementRow: some View { @@ -127,11 +126,11 @@ struct TPPSettingsView: View { title: Strings.Settings.eula, failureMessage: Strings.Error.loadFailedError ) - + let wrapper = UIViewControllerWrapper(viewController, updater: { _ in }) .navigationBarTitle(Text(DisplayStrings.eula)) - row(title: DisplayStrings.eula, index: 4, selection: self.$selectedView, destination: wrapper.anyView()) + row(title: DisplayStrings.eula, index: 4, selection: $selectedView, destination: wrapper.anyView()) } @ViewBuilder private var softwareLicenseRow: some View { @@ -140,43 +139,43 @@ struct TPPSettingsView: View { title: Strings.Settings.softwareLicenses, failureMessage: Strings.Error.loadFailedError ) - + let wrapper = UIViewControllerWrapper(viewController, updater: { _ in }) .navigationBarTitle(Text(DisplayStrings.softwareLicenses)) - row(title: DisplayStrings.softwareLicenses, index: 5, selection: self.$selectedView, destination: wrapper.anyView()) + row(title: DisplayStrings.softwareLicenses, index: 5, selection: $selectedView, destination: wrapper.anyView()) } @ViewBuilder private var developerSettingsSection: some View { - if (TPPSettings.shared.customMainFeedURL == nil && showDeveloperSettings) { + if TPPSettings.shared.customMainFeedURL == nil && showDeveloperSettings { Section(footer: versionInfo) { let viewController = TPPDeveloperSettingsTableViewController() - + let wrapper = UIViewControllerWrapper(viewController, updater: { _ in }) .navigationBarTitle(Text(DisplayStrings.developerSettings)) - - row(title: DisplayStrings.developerSettings, index: 6, selection: self.$selectedView, destination: wrapper.anyView()) + + row(title: DisplayStrings.developerSettings, index: 6, selection: $selectedView, destination: wrapper.anyView()) } } } @ViewBuilder private var versionInfo: some View { - let productName = Bundle.main.object(forInfoDictionaryKey: "CFBundleDisplayName") as! String - let version = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as! String - let build = Bundle.main.object(forInfoDictionaryKey: (kCFBundleVersionKey as String)) as! String - + let productName = Bundle.main.object(forInfoDictionaryKey: "CFBundleDisplayName") as? String ?? "Palace" + let version = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "Unknown" + let build = Bundle.main.object(forInfoDictionaryKey: kCFBundleVersionKey as String) as? String ?? "Unknown" + Text("\(productName) version \(version) (\(build))") .palaceFont(size: 12) .gesture( LongPressGesture(minimumDuration: 5.0) .onEnded { _ in - self.showDeveloperSettings.toggle() + showDeveloperSettings.toggle() } ) .frame(height: 40) .horizontallyCentered() } - + private func row(title: String, index: Int, selection: Binding, destination: AnyView) -> some View { NavigationLink( destination: destination, diff --git a/Palace/Settings/NewSettings/TPPSettingsViewController.swift b/Palace/Settings/NewSettings/TPPSettingsViewController.swift index f5c346512..19ca36081 100644 --- a/Palace/Settings/NewSettings/TPPSettingsViewController.swift +++ b/Palace/Settings/NewSettings/TPPSettingsViewController.swift @@ -10,7 +10,7 @@ import Foundation import SwiftUI class TPPSettingsViewController: NSObject { - @objc static func makeSwiftUIView(dismissHandler: @escaping (() -> Void)) -> UIViewController { + @objc static func makeSwiftUIView(dismissHandler _: @escaping (() -> Void)) -> UIViewController { let controller = UIHostingController(rootView: TPPSettingsView()) controller.title = Strings.Settings.settings controller.tabBarItem.image = UIImage(named: "Settings") diff --git a/Palace/Settings/SettingsCells/TPPLibraryDescriptionCell.swift b/Palace/Settings/SettingsCells/TPPLibraryDescriptionCell.swift index a937ea169..542e5feae 100644 --- a/Palace/Settings/SettingsCells/TPPLibraryDescriptionCell.swift +++ b/Palace/Settings/SettingsCells/TPPLibraryDescriptionCell.swift @@ -10,7 +10,6 @@ import UIKit @objcMembers class TPPLibraryDescriptionCell: UITableViewCell { - let descriptionLabel: UILabel = { let label = UILabel() label.numberOfLines = 0 @@ -30,7 +29,8 @@ class TPPLibraryDescriptionCell: UITableViewCell { descriptionLabel.heightAnchor.constraint(equalTo: contentView.heightAnchor, constant: -16).isActive = true } - required init?(coder aDecoder: NSCoder) { + @available(*, unavailable) + required init?(coder _: NSCoder) { fatalError("init(coder:) has not been implemented") } } diff --git a/Palace/Settings/SettingsCells/TPPLoginCellTypes.swift b/Palace/Settings/SettingsCells/TPPLoginCellTypes.swift index 0105e4bbe..39f171348 100644 --- a/Palace/Settings/SettingsCells/TPPLoginCellTypes.swift +++ b/Palace/Settings/SettingsCells/TPPLoginCellTypes.swift @@ -8,6 +8,8 @@ import Foundation +// MARK: - TPPAuthMethodCellType + @objcMembers class TPPAuthMethodCellType: NSObject { let authenticationMethod: AccountDetails.Authentication @@ -17,6 +19,8 @@ class TPPAuthMethodCellType: NSObject { } } +// MARK: - TPPInfoHeaderCellType + @objcMembers class TPPInfoHeaderCellType: NSObject { let information: String @@ -26,6 +30,8 @@ class TPPInfoHeaderCellType: NSObject { } } +// MARK: - TPPSamlIdpCellType + @objcMembers class TPPSamlIdpCellType: NSObject { let idp: OPDS2SamlIDP diff --git a/Palace/Settings/SettingsCells/TPPSamlIDPCell.swift b/Palace/Settings/SettingsCells/TPPSamlIDPCell.swift index e2b8ec248..7a62ea661 100644 --- a/Palace/Settings/SettingsCells/TPPSamlIDPCell.swift +++ b/Palace/Settings/SettingsCells/TPPSamlIDPCell.swift @@ -10,11 +10,10 @@ import UIKit @objcMembers class TPPSamlIDPCell: UITableViewCell { - let idpName: UILabel = { let label = UILabel() label.textAlignment = .right - label.font = UIFont.customFont(forTextStyle: .subheadline) + label.font = UIFont.customFont(forTextStyle: .subheadline) label.textColor = UIColor.systemBlue return label }() @@ -31,7 +30,8 @@ class TPPSamlIDPCell: UITableViewCell { idpName.heightAnchor.constraint(equalTo: contentView.heightAnchor, constant: -16).isActive = true } - required init?(coder aDecoder: NSCoder) { + @available(*, unavailable) + required init?(coder _: NSCoder) { fatalError("init(coder:) has not been implemented") } } diff --git a/Palace/Settings/TPPSettings+SE.swift b/Palace/Settings/TPPSettings+SE.swift index 6c09cb0fa..398573244 100644 --- a/Palace/Settings/TPPSettings+SE.swift +++ b/Palace/Settings/TPPSettings+SE.swift @@ -6,20 +6,22 @@ // Copyright © 2020 NYPL Labs. All rights reserved. // +// MARK: - TPPSettings + NYPLUniversalLinksSettings + extension TPPSettings: NYPLUniversalLinksSettings { /// Used to handle Clever and SAML sign-ins in SimplyE. @objc var universalLinksURL: URL { - return URL(string: "https://librarysimplified.org/callbacks/SimplyE")! + URL(string: "https://librarysimplified.org/callbacks/SimplyE")! } } extension TPPSettings { static let userHasSeenWelcomeScreenKey = "NYPLUserHasSeenWelcomeScreenKey" - + var settingsAccountIdsList: [String] { get { - if let libraryAccounts = UserDefaults.standard.array(forKey: TPPSettings.settingsLibraryAccountsKey) { - return libraryAccounts as! [String] + if let libraryAccounts = UserDefaults.standard.array(forKey: TPPSettings.settingsLibraryAccountsKey) as? [String] { + return libraryAccounts } // Avoid crash in case currentLibrary isn't set yet @@ -36,7 +38,7 @@ extension TPPSettings { UserDefaults.standard.synchronize() } } - + var settingsAccountsList: [Account] { settingsAccountIdsList .compactMap { AccountsManager.shared.account($0) } diff --git a/Palace/Settings/TPPSettings.swift b/Palace/Settings/TPPSettings.swift index 917d892d9..b2171749c 100644 --- a/Palace/Settings/TPPSettings.swift +++ b/Palace/Settings/TPPSettings.swift @@ -1,5 +1,7 @@ import Foundation +// MARK: - NYPLUniversalLinksSettings + @objc protocol NYPLUniversalLinksSettings: NSObjectProtocol { /// The URL that will be used to redirect an external authentication flow /// back to the our app. This URL will need to be provided to the external @@ -8,15 +10,19 @@ import Foundation var universalLinksURL: URL { get } } +// MARK: - NYPLFeedURLProvider + @objc protocol NYPLFeedURLProvider { var accountMainFeedURL: URL? { get set } } +// MARK: - TPPSettings + @objcMembers class TPPSettings: NSObject, NYPLFeedURLProvider, TPPAgeCheckChoiceStorage { static let shared = TPPSettings() - @objc class func sharedSettings() -> TPPSettings { - return TPPSettings.shared + class func sharedSettings() -> TPPSettings { + TPPSettings.shared } static let TPPAboutPalaceURLString = "http://thepalaceproject.org/" @@ -24,38 +30,38 @@ import Foundation static let TPPPrivacyPolicyURLString = "https://legal.palaceproject.io/Privacy%20Policy.html" static let TPPSoftwareLicensesURLString = "https://legal.palaceproject.io/software-licenses.html" - static private let customMainFeedURLKey = "NYPLSettingsCustomMainFeedURL" - static private let accountMainFeedURLKey = "NYPLSettingsAccountMainFeedURL" - static private let userPresentedAgeCheckKey = "NYPLUserPresentedAgeCheckKey" + private static let customMainFeedURLKey = "NYPLSettingsCustomMainFeedURL" + private static let accountMainFeedURLKey = "NYPLSettingsAccountMainFeedURL" + private static let userPresentedAgeCheckKey = "NYPLUserPresentedAgeCheckKey" static let userHasAcceptedEULAKey = "NYPLSettingsUserAcceptedEULA" - static private let userSeenFirstTimeSyncMessageKey = "userSeenFirstTimeSyncMessageKey" - static private let useBetaLibrariesKey = "NYPLUseBetaLibrariesKey" + private static let userSeenFirstTimeSyncMessageKey = "userSeenFirstTimeSyncMessageKey" + private static let useBetaLibrariesKey = "NYPLUseBetaLibrariesKey" static let settingsLibraryAccountsKey = "NYPLSettingsLibraryAccountsKey" - static private let versionKey = "NYPLSettingsVersionKey" - static private let customLibraryRegistryKey = "TPPSettingsCustomLibraryRegistryKey" - static private let enterLCPPassphraseManually = "TPPSettingsEnterLCPPassphraseManually" + private static let versionKey = "NYPLSettingsVersionKey" + private static let customLibraryRegistryKey = "TPPSettingsCustomLibraryRegistryKey" + private static let enterLCPPassphraseManually = "TPPSettingsEnterLCPPassphraseManually" static let showDeveloperSettingsKey = "showDeveloperSettings" - + // Set to nil (the default) if no custom feed should be used. var customMainFeedURL: URL? { get { - return UserDefaults.standard.url(forKey: TPPSettings.customMainFeedURLKey) + UserDefaults.standard.url(forKey: TPPSettings.customMainFeedURLKey) } set(customUrl) { - if (customUrl == self.customMainFeedURL) { + if customUrl == self.customMainFeedURL { return } UserDefaults.standard.set(customUrl, forKey: TPPSettings.customMainFeedURLKey) NotificationCenter.default.post(name: Notification.Name.TPPSettingsDidChange, object: self) } } - + var accountMainFeedURL: URL? { get { - return UserDefaults.standard.url(forKey: TPPSettings.accountMainFeedURLKey) + UserDefaults.standard.url(forKey: TPPSettings.accountMainFeedURLKey) } set(mainFeedUrl) { - if (mainFeedUrl == self.accountMainFeedURL) { + if mainFeedUrl == self.accountMainFeedURL { return } UserDefaults.standard.set(mainFeedUrl, forKey: TPPSettings.accountMainFeedURLKey) @@ -72,7 +78,7 @@ import Foundation UserDefaults.standard.set(newValue, forKey: TPPSettings.userHasSeenWelcomeScreenKey) } } - + var userPresentedAgeCheck: Bool { get { UserDefaults.standard.bool(forKey: TPPSettings.userPresentedAgeCheckKey) @@ -81,7 +87,7 @@ import Foundation UserDefaults.standard.set(newValue, forKey: TPPSettings.userPresentedAgeCheckKey) } } - + var userHasAcceptedEULA: Bool { get { UserDefaults.standard.bool(forKey: TPPSettings.userHasAcceptedEULAKey) @@ -97,8 +103,10 @@ import Foundation } set { UserDefaults.standard.set(newValue, forKey: TPPSettings.useBetaLibrariesKey) - NotificationCenter.default.post(name: NSNotification.Name.TPPUseBetaDidChange, - object: self) + NotificationCenter.default.post( + name: NSNotification.Name.TPPUseBetaDidChange, + object: self + ) } } @@ -110,7 +118,7 @@ import Foundation UserDefaults.standard.set(versionString, forKey: TPPSettings.versionKey) } } - + var customLibraryRegistryServer: String? { get { UserDefaults.standard.string(forKey: TPPSettings.customLibraryRegistryKey) @@ -128,5 +136,4 @@ import Foundation UserDefaults.standard.set(newValue, forKey: TPPSettings.enterLCPPassphraseManually) } } - } diff --git a/Palace/Settings/TPPSettingsAccountsList.swift b/Palace/Settings/TPPSettingsAccountsList.swift index 69727e6eb..e962d463a 100644 --- a/Palace/Settings/TPPSettingsAccountsList.swift +++ b/Palace/Settings/TPPSettingsAccountsList.swift @@ -1,102 +1,108 @@ /// UITableView to display or add library accounts that the user /// can then log in and adjust settings after selecting Accounts. -@objcMembers class TPPSettingsAccountsTableViewController: UIViewController, UITableViewDelegate, UITableViewDataSource, TPPLoadingViewController { - +@objcMembers class TPPSettingsAccountsTableViewController: UIViewController, UITableViewDelegate, UITableViewDataSource, + TPPLoadingViewController +{ enum LoadState { case loading case failure case success } - + weak var tableView: UITableView! var reloadView: TPPReloadView! var spinner: UIActivityIndicatorView! var loadingView: UIView? - + fileprivate var accounts: [Account] { didSet { - //update TPPSettings + // update TPPSettings } } + fileprivate var libraryAccounts: [Account] fileprivate var userAddedSecondaryAccounts: [Account]! fileprivate let manager: AccountsManager - + required init(accounts: [Account]) { self.accounts = accounts - self.manager = AccountsManager.shared - self.libraryAccounts = manager.accounts() - - super.init(nibName:nil, bundle:nil) + manager = AccountsManager.shared + libraryAccounts = manager.accounts() + + super.init(nibName: nil, bundle: nil) } - + deinit { NotificationCenter.default.removeObserver(self) } - + @available(*, unavailable) - required init(coder aDecoder: NSCoder) { + required init(coder _: NSCoder) { fatalError("init(coder:) has not been implemented") } - + // MARK: UIViewController - + override func loadView() { - self.view = UITableView(frame: CGRect.zero, style: .grouped) - self.tableView = self.view as? UITableView - self.tableView.delegate = self - self.tableView.dataSource = self - self.tableView.register(TPPAccountListCell.self, forCellReuseIdentifier: TPPAccountListCell.reuseIdentifier) + view = UITableView(frame: CGRect.zero, style: .grouped) + tableView = view as? UITableView + tableView.delegate = self + tableView.dataSource = self + tableView.register(TPPAccountListCell.self, forCellReuseIdentifier: TPPAccountListCell.reuseIdentifier) spinner = UIActivityIndicatorView(style: .medium) view.addSubview(spinner) - + reloadView = TPPReloadView() reloadView.handler = { [weak self] in guard let self = self else { return } - self.reloadAccounts() + reloadAccounts() } view.addSubview(reloadView) - + // cleanup accounts, remove demo account or accounts not supported through accounts.json // will be refactored when implementing librsry registry var accountsToRemove = [String]() - + for account in accounts { - if (AccountsManager.shared.account(account.uuid) == nil) { + if AccountsManager.shared.account(account.uuid) == nil { accountsToRemove.append(account.uuid) } } - + for remove in accountsToRemove { accounts = accounts.filter { $0.uuid == remove } } - self.userAddedSecondaryAccounts = accounts.filter { $0.uuid != AccountsManager.shared.currentAccount?.uuid } + userAddedSecondaryAccounts = accounts.filter { $0.uuid != AccountsManager.shared.currentAccount?.uuid } updateSettingsAccountList() - NotificationCenter.default.addObserver(self, - selector: #selector(reloadAfterAccountChange), - name: NSNotification.Name.TPPCurrentAccountDidChange, - object: nil) - NotificationCenter.default.addObserver(self, - selector: #selector(catalogChangeHandler), - name: NSNotification.Name.TPPCatalogDidLoad, - object: nil) - - self.libraryAccounts = manager.accounts() + NotificationCenter.default.addObserver( + self, + selector: #selector(reloadAfterAccountChange), + name: NSNotification.Name.TPPCurrentAccountDidChange, + object: nil + ) + NotificationCenter.default.addObserver( + self, + selector: #selector(catalogChangeHandler), + name: NSNotification.Name.TPPCatalogDidLoad, + object: nil + ) + + libraryAccounts = manager.accounts() updateNavBar() } - + override func viewWillLayoutSubviews() { super.viewWillLayoutSubviews() spinner.centerInSuperview(withOffset: tableView.contentOffset) reloadView.centerInSuperview(withOffset: tableView.contentOffset) } - + // MARK: - - + func showLoadingUI(loadState: LoadState) { switch loadState { case .loading: @@ -113,69 +119,71 @@ reloadView.isHidden = true } } - + func reloadAccounts() { showLoadingUI(loadState: .loading) - + manager.updateAccountSet { [weak self] success in TPPMainThreadRun.asyncIfNeeded { [weak self] in guard let self = self else { return } if success { - self.showLoadingUI(loadState: .success) + showLoadingUI(loadState: .success) } else { - self.showLoadingUI(loadState: .failure) - TPPErrorLogger.logError(withCode: .apiCall, - summary: "Accounts list failed to load") + showLoadingUI(loadState: .failure) + TPPErrorLogger.logError( + withCode: .apiCall, + summary: "Accounts list failed to load" + ) } } } } - + func reloadAfterAccountChange() { accounts = TPPSettings.shared.settingsAccountsList - self.userAddedSecondaryAccounts = accounts.filter { $0.uuid != manager.currentAccount?.uuid } + userAddedSecondaryAccounts = accounts.filter { $0.uuid != manager.currentAccount?.uuid } DispatchQueue.main.async { self.tableView.reloadData() } } - + func catalogChangeHandler() { - self.libraryAccounts = AccountsManager.shared.accounts() + libraryAccounts = AccountsManager.shared.accounts() DispatchQueue.main.async { self.updateNavBar() } } - + private func updateNavBar() { - var enable = self.userAddedSecondaryAccounts.count + 1 < self.libraryAccounts.count - + var enable = userAddedSecondaryAccounts.count + 1 < libraryAccounts.count + if TPPSettings.shared.customLibraryRegistryServer != nil { - enable = self.userAddedSecondaryAccounts.count < self.libraryAccounts.count + enable = userAddedSecondaryAccounts.count < libraryAccounts.count } - - self.navigationItem.rightBarButtonItem?.isEnabled = enable + + navigationItem.rightBarButtonItem?.isEnabled = enable } - + private func updateList(withAccount account: Account) { if userAddedSecondaryAccounts.filter({ $0.uuid == account.uuid }).isEmpty { userAddedSecondaryAccounts.append(account) } - + updateSettingsAccountList() // Return from search screen to the list of libraries navigationController?.popViewController(animated: false) // Switch to the selected library AccountsManager.shared.currentAccount = account - self.tableView.reloadData() - + tableView.reloadData() + NotificationCenter.default.post(name: .TPPCurrentAccountDidChange, object: nil) - self.tabBarController?.selectedIndex = 0 + tabBarController?.selectedIndex = 0 (navigationController?.parent as? UINavigationController)?.popToRootViewController(animated: false) } - - @objc func addAccount() { + + func addAccount() { let listVC = TPPAccountList { [weak self] account in if account.details != nil { self?.updateList(withAccount: account) @@ -188,7 +196,7 @@ } navigationController?.pushViewController(listVC, animated: true) } - + private func authenticateAccount(_ account: Account, completion: @escaping () -> Void) { startLoading() account.loadAuthenticationDocument { [weak self] success in @@ -198,50 +206,56 @@ self?.showLoadingFailureAlert() return } - + completion() } } } - + private func showLoadingFailureAlert() { - let alert = TPPAlertUtils.alert(title:nil, message:"We can’t get your library right now. Please close and reopen the app to try again.", style: .cancel) + let alert = TPPAlertUtils.alert( + title: nil, + message: "We can’t get your library right now. Please close and reopen the app to try again.", + style: .cancel + ) present(alert, animated: true, completion: nil) } - + private func updateSettingsAccountList() { guard let uuid = manager.currentAccount?.uuid else { showLoadingUI(loadState: .failure) return } showLoadingUI(loadState: .success) - var array = userAddedSecondaryAccounts!.map { $0.uuid } + var array = userAddedSecondaryAccounts!.map(\.uuid) array.append(uuid) TPPSettings.shared.settingsAccountIdsList = array } - + // MARK: UITableViewDataSource - - func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { - + + func tableView(_: UITableView, numberOfRowsInSection section: Int) -> Int { if section == 0 { - return self.manager.currentAccount != nil ? 1 : 0 + return manager.currentAccount != nil ? 1 : 0 } - + return userAddedSecondaryAccounts.count } - - func numberOfSections(in tableView: UITableView) -> Int { - return 2 + + func numberOfSections(in _: UITableView) -> Int { + 2 } - + func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { - guard let account = self.manager.currentAccount, let cell = tableView.dequeueReusableCell(withIdentifier: TPPAccountListCell.reuseIdentifier, for: indexPath) as? TPPAccountListCell else { + guard let account = manager.currentAccount, let cell = tableView.dequeueReusableCell( + withIdentifier: TPPAccountListCell.reuseIdentifier, + for: indexPath + ) as? TPPAccountListCell else { // Should never happen, but better than crashing return UITableViewCell() } - - if (indexPath.section == 0) { + + if indexPath.section == 0 { cell.configure(for: account) } else { // The app crashes here when we switch registry accounts @@ -249,38 +263,41 @@ cell.configure(for: userAddedSecondaryAccounts[indexPath.row]) } } - + return cell } // MARK: UITableViewDelegate - - func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { - var account: Account? - if (indexPath.section == 0) { - account = self.manager.currentAccount + + func tableView(_: UITableView, didSelectRowAt indexPath: IndexPath) { + var account: Account? = if indexPath.section == 0 { + manager.currentAccount } else { - account = userAddedSecondaryAccounts[indexPath.row] + userAddedSecondaryAccounts[indexPath.row] } - + let vc = TPPSettingsAccountDetailViewController(libraryAccountID: account?.uuid ?? "") - self.tableView.deselectRow(at: indexPath, animated: true) - self.navigationController?.pushViewController(vc, animated: true) + tableView.deselectRow(at: indexPath, animated: true) + navigationController?.pushViewController(vc, animated: true) } - - func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat { - return UITableView.automaticDimension + + func tableView(_: UITableView, heightForRowAt _: IndexPath) -> CGFloat { + UITableView.automaticDimension } - - func tableView(_ tableView: UITableView, estimatedHeightForRowAt indexPath: IndexPath) -> CGFloat { - return 80 + + func tableView(_: UITableView, estimatedHeightForRowAt _: IndexPath) -> CGFloat { + 80 } - - func tableView(_ tableView: UITableView, canEditRowAt indexPath: IndexPath) -> Bool { - return indexPath.section != 0 + + func tableView(_: UITableView, canEditRowAt indexPath: IndexPath) -> Bool { + indexPath.section != 0 } - - func tableView(_ tableView: UITableView, commit editingStyle: UITableViewCell.EditingStyle, forRowAt indexPath: IndexPath) { + + func tableView( + _ tableView: UITableView, + commit editingStyle: UITableViewCell.EditingStyle, + forRowAt indexPath: IndexPath + ) { if editingStyle == .delete { if userAddedSecondaryAccounts.count > indexPath.row { let account = userAddedSecondaryAccounts[indexPath.row] diff --git a/Palace/Settings/TPPSettingsAdvancedViewController.swift b/Palace/Settings/TPPSettingsAdvancedViewController.swift index 56f2ca9b7..1646cedf3 100644 --- a/Palace/Settings/TPPSettingsAdvancedViewController.swift +++ b/Palace/Settings/TPPSettingsAdvancedViewController.swift @@ -7,70 +7,81 @@ import UIKit var account: Account init(account id: String) { - self.account = AccountsManager.shared.account(id)! + account = AccountsManager.shared.account(id)! super.init(nibName: nil, bundle: nil) } - - required init?(coder aDecoder: NSCoder) { + + @available(*, unavailable) + required init?(coder _: NSCoder) { fatalError("init(coder:) has not been implemented") } - + override func viewDidLoad() { super.viewDidLoad() - + title = DisplayStrings.advanced - - let tableView = UITableView.init(frame: .zero, style: .grouped) + + let tableView = UITableView(frame: .zero, style: .grouped) tableView.delegate = self tableView.dataSource = self tableView.backgroundColor = TPPConfiguration.backgroundColor() - self.view.addSubview(tableView) + view.addSubview(tableView) tableView.autoPinEdgesToSuperviewEdges() } - + // MARK: - UITableViewDelegate - - func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { - if (indexPath.row == 0) { + func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + if indexPath.row == 0 { let cell = tableView.cellForRow(at: indexPath) cell?.setSelected(false, animated: true) - - let message = String.localizedStringWithFormat(NSLocalizedString("Selecting \"Delete\" will remove all bookmarks from the server for %@.", comment: "Message warning alert for removing all bookmarks from the server"), account.name) - let alert = UIAlertController.init(title: nil, message: message, preferredStyle: .alert) + let message = String.localizedStringWithFormat( + NSLocalizedString( + "Selecting \"Delete\" will remove all bookmarks from the server for %@.", + comment: "Message warning alert for removing all bookmarks from the server" + ), + account.name + ) + + let alert = UIAlertController(title: nil, message: message, preferredStyle: .alert) - let deleteAction = UIAlertAction.init(title: Strings.Generic.delete, style: .destructive, handler: { (action) in + let deleteAction = UIAlertAction(title: Strings.Generic.delete, style: .destructive, handler: { _ in self.disableSync() }) - - let cancelAction = UIAlertAction.init(title: Strings.Generic.cancel, style: .cancel, handler: { (action) in + + let cancelAction = UIAlertAction(title: Strings.Generic.cancel, style: .cancel, handler: { _ in Log.info(#file, "User cancelled bookmark server delete.") }) - + alert.addAction(deleteAction) alert.addAction(cancelAction) - - TPPAlertUtils.presentFromViewControllerOrNil(alertController: alert, viewController: nil, animated: true, completion: nil) + + TPPAlertUtils.presentFromViewControllerOrNil( + alertController: alert, + viewController: nil, + animated: true, + completion: nil + ) } } - + private func disableSync() { - self.account.details?.syncPermissionGranted = false; - self.navigationController?.popViewController(animated: true) + account.details?.syncPermissionGranted = false + navigationController?.popViewController(animated: true) } - + // MARK: - UITableViewDataSource - - func numberOfSections(in tableView: UITableView) -> Int { - return 1 + + func numberOfSections(in _: UITableView) -> Int { + 1 } - - func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { - return 1 + + func tableView(_: UITableView, numberOfRowsInSection _: Int) -> Int { + 1 } - - func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + + func tableView(_: UITableView, cellForRowAt _: IndexPath) -> UITableViewCell { let cell = UITableViewCell() cell.textLabel?.text = DisplayStrings.deleteServerData cell.textLabel?.font = UIFont.customFont(forTextStyle: .body) @@ -78,7 +89,7 @@ import UIKit return cell } - func tableView(_ tableView: UITableView, titleForFooterInSection section: Int) -> String? { + func tableView(_: UITableView, titleForFooterInSection _: Int) -> String? { nil } } diff --git a/Palace/SignInLogic/TPPBasicAuth.swift b/Palace/SignInLogic/TPPBasicAuth.swift index c31fefa87..fc7304b05 100644 --- a/Palace/SignInLogic/TPPBasicAuth.swift +++ b/Palace/SignInLogic/TPPBasicAuth.swift @@ -8,51 +8,59 @@ import Foundation +// MARK: - NYPLBasicAuthCredentialsProvider + /// Defines the interface required by the various pieces of the sign-in logic /// to obtain the credentials for performing basic authentication. @objc protocol NYPLBasicAuthCredentialsProvider: NSObjectProtocol { - var username: String? {get} - var pin: String? {get} + var username: String? { get } + var pin: String? { get } } +// MARK: - TPPBasicAuth + @objc class TPPBasicAuth: NSObject { typealias BasicAuthCompletionHandler = (URLSession.AuthChallengeDisposition, URLCredential?) -> Void - + /// The object providing the credentials to respond to the authentication /// challenge. private var credentialsProvider: NYPLBasicAuthCredentialsProvider - + @objc(initWithCredentialsProvider:) init(credentialsProvider: NYPLBasicAuthCredentialsProvider) { self.credentialsProvider = credentialsProvider super.init() } - + /// Responds to the authentication challenge synchronously. /// - Parameters: /// - challenge: The authentication challenge to respond to. /// - completion: Always called, synchronously. - @objc func handleChallenge(_ challenge: URLAuthenticationChallenge, - completion: BasicAuthCompletionHandler) - { + @objc func handleChallenge( + _ challenge: URLAuthenticationChallenge, + completion: BasicAuthCompletionHandler + ) { switch challenge.protectionSpace.authenticationMethod { case NSURLAuthenticationMethodHTTPBasic: guard let username = credentialsProvider.username, let password = credentialsProvider.pin, - challenge.previousFailureCount == 0 else { - completion(.cancelAuthenticationChallenge, nil) - return + challenge.previousFailureCount == 0 + else { + completion(.cancelAuthenticationChallenge, nil) + return } - - let credentials = URLCredential(user: username, - password: password, - persistence: .none) + + let credentials = URLCredential( + user: username, + password: password, + persistence: .none + ) completion(.useCredential, credentials) - + case NSURLAuthenticationMethodServerTrust: completion(.performDefaultHandling, nil) - + default: completion(.rejectProtectionSpace, nil) } diff --git a/Palace/SignInLogic/TPPCookiesWebViewController.swift b/Palace/SignInLogic/TPPCookiesWebViewController.swift index 6248c6a88..de91f5158 100644 --- a/Palace/SignInLogic/TPPCookiesWebViewController.swift +++ b/Palace/SignInLogic/TPPCookiesWebViewController.swift @@ -9,6 +9,8 @@ import UIKit import WebKit +// MARK: - TPPCookiesWebViewModel + // WARNING: This does not work well for iOS versions lower than 11 @objcMembers class TPPCookiesWebViewModel: NSObject { @@ -17,21 +19,31 @@ class TPPCookiesWebViewModel: NSObject { let loginCompletionHandler: ((URL, [HTTPCookie]) -> Void)? let loginCancelHandler: (() -> Void)? let bookFoundHandler: ((URLRequest?, [HTTPCookie]) -> Void)? - let problemFound: (((TPPProblemDocument?)) -> Void)? + let problemFound: ((TPPProblemDocument?) -> Void)? let autoPresentIfNeeded: Bool - init(cookies: [HTTPCookie], request: URLRequest, loginCompletionHandler: ((URL, [HTTPCookie]) -> Void)?, loginCancelHandler: (() -> Void)?, bookFoundHandler: ((URLRequest?, [HTTPCookie]) -> Void)?, problemFoundHandler: ((TPPProblemDocument?) -> Void)?, autoPresentIfNeeded: Bool = false) { + init( + cookies: [HTTPCookie], + request: URLRequest, + loginCompletionHandler: ((URL, [HTTPCookie]) -> Void)?, + loginCancelHandler: (() -> Void)?, + bookFoundHandler: ((URLRequest?, [HTTPCookie]) -> Void)?, + problemFoundHandler: ((TPPProblemDocument?) -> Void)?, + autoPresentIfNeeded: Bool = false + ) { self.cookies = cookies self.request = request self.loginCompletionHandler = loginCompletionHandler self.loginCancelHandler = loginCancelHandler self.bookFoundHandler = bookFoundHandler - self.problemFound = problemFoundHandler + problemFound = problemFoundHandler self.autoPresentIfNeeded = autoPresentIfNeeded super.init() } } +// MARK: - TPPCookiesWebViewController + @objcMembers class TPPCookiesWebViewController: UIViewController, WKNavigationDelegate { private let uuid: String = UUID().uuidString @@ -40,8 +52,9 @@ class TPPCookiesWebViewController: UIViewController, WKNavigationDelegate { private var domainCookies: [String: HTTPCookie] = [:] // () is a key, use for ios < 11 only private var rawCookies: [HTTPCookie] { // use for ios < 11 only - domainCookies.map { $0.value } + domainCookies.map(\.value) } + private let webView = WKWebView() private var previousRequest: URLRequest? private var wasBookFound = false @@ -59,7 +72,8 @@ class TPPCookiesWebViewController: UIViewController, WKNavigationDelegate { webView.configuration.websiteDataStore = WKWebsiteDataStore.nonPersistent() } - required init?(coder: NSCoder) { + @available(*, unavailable) + required init?(coder _: NSCoder) { fatalError("init(coder:) has not been implemented") } @@ -75,10 +89,17 @@ class TPPCookiesWebViewController: UIViewController, WKNavigationDelegate { TPPCookiesWebViewController.automaticBrowserStorage[uuid] = self } - navigationItem.leftBarButtonItem = UIBarButtonItem(title: Strings.Generic.cancel, style: .plain, target: self, action: #selector(didSelectCancel)) + navigationItem.leftBarButtonItem = UIBarButtonItem( + title: Strings.Generic.cancel, + style: .plain, + target: self, + action: #selector(didSelectCancel) + ) webView.navigationDelegate = self - guard let model = model else { return } + guard let model = model else { + return + } if !model.cookies.isEmpty { // if there are cookies to inject var cookiesLeft = model.cookies.count @@ -94,7 +115,7 @@ class TPPCookiesWebViewController: UIViewController, WKNavigationDelegate { } else { // Fallback on earlier versions // add them to a local cookies dictionary stored with domain + cookie name keys - self.domainCookies[cookie.domain + cookie.name] = cookie + domainCookies[cookie.domain + cookie.name] = cookie cookiesLeft -= 1 if cookiesLeft == 0 { @@ -113,25 +134,32 @@ class TPPCookiesWebViewController: UIViewController, WKNavigationDelegate { } @objc private func didSelectCancel() { - (navigationController?.presentingViewController ?? presentingViewController)?.dismiss(animated: true, completion: { [model] in model?.loginCancelHandler?() }) + (navigationController?.presentingViewController ?? presentingViewController)?.dismiss( + animated: true, + completion: { [model] in model?.loginCancelHandler?() } + ) } - func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) { - + func webView( + _ webView: WKWebView, + decidePolicyFor navigationAction: WKNavigationAction, + decisionHandler: @escaping (WKNavigationActionPolicy) -> Void + ) { // save this request, in case a response will contain a book previousRequest = navigationAction.request // if model has some way of procesing login completion if let loginHandler = model?.loginCompletionHandler { // and login process just did complete - if let destination = navigationAction.request.url, destination.absoluteString.hasPrefix(TPPSettings.shared.universalLinksURL.absoluteString) { - + if let destination = navigationAction.request.url, + destination.absoluteString.hasPrefix(TPPSettings.shared.universalLinksURL.absoluteString) + { // cancel further webview redirections and loading decisionHandler(.cancel) if #available(iOS 11.0, *) { // dump all the cookies from the webview - webView.configuration.websiteDataStore.httpCookieStore.getAllCookies { [uuid] (cookies) in + webView.configuration.websiteDataStore.httpCookieStore.getAllCookies { [uuid] cookies in loginHandler(destination, cookies) TPPCookiesWebViewController.automaticBrowserStorage[uuid] = nil } @@ -173,7 +201,7 @@ class TPPCookiesWebViewController: UIViewController, WKNavigationDelegate { /// Injects cookies into the given request. /// - Important: Use only on iOS < 11. - private func loadWebPage(request: URLRequest) { + private func loadWebPage(request: URLRequest) { var mutableRequest = request // add a marker that we already customized this request, so that we don't fall into infinite redirections loop @@ -188,9 +216,12 @@ class TPPCookiesWebViewController: UIViewController, WKNavigationDelegate { // load customized request webView.load(mutableRequest) } - - func webView(_ webView: WKWebView, decidePolicyFor navigationResponse: WKNavigationResponse, decisionHandler: @escaping (WKNavigationResponsePolicy) -> Void) { + func webView( + _ webView: WKWebView, + decidePolicyFor navigationResponse: WKNavigationResponse, + decisionHandler: @escaping (WKNavigationResponsePolicy) -> Void + ) { if #available(iOS 11.0, *) { } else { // this block saves new cookies if any are available // first thing it does, is to try to obtain the header fields @@ -229,7 +260,10 @@ class TPPCookiesWebViewController: UIViewController, WKNavigationDelegate { // if we chose to let this webview controller present on its own, it should dismiss itself as well if self?.model?.autoPresentIfNeeded == true { - (self?.navigationController?.presentingViewController ?? self?.presentingViewController)?.dismiss(animated: true, completion: nil) + (self?.navigationController?.presentingViewController ?? self?.presentingViewController)?.dismiss( + animated: true, + completion: nil + ) } } } else { @@ -237,9 +271,11 @@ class TPPCookiesWebViewController: UIViewController, WKNavigationDelegate { bookHandler(previousRequest, rawCookies) TPPCookiesWebViewController.automaticBrowserStorage[uuid] = nil if model?.autoPresentIfNeeded == true { - (navigationController?.presentingViewController ?? presentingViewController)?.dismiss(animated: true, completion: nil) + (navigationController?.presentingViewController ?? presentingViewController)?.dismiss( + animated: true, + completion: nil + ) } - } return @@ -249,8 +285,9 @@ class TPPCookiesWebViewController: UIViewController, WKNavigationDelegate { // if model can handle a problem document if let problemHandler = model?.problemFound { // and problem document just happend - if let responseType = navigationResponse.response.mimeType, responseType == "application/problem+json" || responseType == "application/api-problem+json" { - + if let responseType = navigationResponse.response.mimeType, + responseType == "application/problem+json" || responseType == "application/api-problem+json" + { // discard further loading decisionHandler(.cancel) let presenter = navigationController?.presentingViewController ?? presentingViewController @@ -273,36 +310,48 @@ class TPPCookiesWebViewController: UIViewController, WKNavigationDelegate { } private var loginScreenHandlerOnceOnly = true - func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { - + func webView(_: WKWebView, didFinish _: WKNavigation!) { // when loading just finished // and this controller is asked to autopresent itself if needed if model?.autoPresentIfNeeded == true { // delay is needed in case IDP will want to do a redirect after initial load (from within the page) OperationQueue.current?.underlyingQueue?.asyncAfter(deadline: .now() + 0.5) { [weak self] in // once the time comes, we check if the controller still exists - guard let self = self else { return } + guard let self = self else { + return + } // we want to present only if webview really finished loading - guard !self.webView.isLoading else { return } + guard !webView.isLoading else { + return + } // and no book was found - guard !self.wasBookFound else { return } + guard !wasBookFound else { + return + } // and we didn't already handled this case - guard self.loginScreenHandlerOnceOnly else { return } - self.loginScreenHandlerOnceOnly = false + guard loginScreenHandlerOnceOnly else { + return + } + loginScreenHandlerOnceOnly = false // we can present let navigationWrapper = UINavigationController(rootViewController: self) - (UIApplication.shared.delegate as? TPPAppDelegate)?.topViewController()?.present(navigationWrapper, animated: true) + (UIApplication.shared.delegate as? TPPAppDelegate)?.topViewController()?.present( + navigationWrapper, + animated: true + ) // and actually remove reference to self, as this controller already is added to the UI stack - TPPCookiesWebViewController.automaticBrowserStorage[self.uuid] = nil + TPPCookiesWebViewController.automaticBrowserStorage[uuid] = nil } } } } +// MARK: UIAdaptivePresentationControllerDelegate + extension TPPCookiesWebViewController: UIAdaptivePresentationControllerDelegate { - func presentationControllerDidDismiss(_ presentationController: UIPresentationController) { + func presentationControllerDidDismiss(_: UIPresentationController) { model?.loginCancelHandler?() } } diff --git a/Palace/SignInLogic/TPPReauthenticator.swift b/Palace/SignInLogic/TPPReauthenticator.swift index 99bbb4cc2..8222fb689 100644 --- a/Palace/SignInLogic/TPPReauthenticator.swift +++ b/Palace/SignInLogic/TPPReauthenticator.swift @@ -8,12 +8,18 @@ import Foundation +// MARK: - Reauthenticator + protocol Reauthenticator: NSObject { - func authenticateIfNeeded(_ user: TPPUserAccount, - usingExistingCredentials: Bool, - authenticationCompletion: (()-> Void)?) + func authenticateIfNeeded( + _ user: TPPUserAccount, + usingExistingCredentials: Bool, + authenticationCompletion: (() -> Void)? + ) } +// MARK: - TPPReauthenticator + /// This class is a front-end for taking care of situations where an /// already authenticated user somehow sees its requests fail with a 401 /// HTTP status as it the request lacked proper authentication. @@ -26,7 +32,6 @@ protocol Reauthenticator: NSObject { /// opening up the VC when needed, and performing the log-in request under /// the hood when no user input is needed. @objc class TPPReauthenticator: NSObject, Reauthenticator { - private var signInModalVC: TPPAccountSignInViewController? /// Re-authenticates the user. This may involve presenting the sign-in @@ -39,16 +44,18 @@ protocol Reauthenticator: NSObject { /// flow completes. /// - Returns: `true` if an authentication flow was started to refresh the /// credentials, `false` otherwise. - @objc func authenticateIfNeeded(_ user: TPPUserAccount, - usingExistingCredentials: Bool, - authenticationCompletion: (()-> Void)?) { + @objc func authenticateIfNeeded( + _: TPPUserAccount, + usingExistingCredentials: Bool, + authenticationCompletion: (() -> Void)? + ) { TPPMainThreadRun.asyncIfNeeded { let vc = TPPAccountSignInViewController() self.signInModalVC = vc vc.forceEditability = true vc.presentIfNeeded(usingExistingCredentials: usingExistingCredentials) { authenticationCompletion?() - self.signInModalVC = nil //break desired retain cycle + self.signInModalVC = nil // break desired retain cycle } } } diff --git a/Palace/SignInLogic/TPPSAMLHelper.swift b/Palace/SignInLogic/TPPSAMLHelper.swift index e9eb24798..758374f98 100644 --- a/Palace/SignInLogic/TPPSAMLHelper.swift +++ b/Palace/SignInLogic/TPPSAMLHelper.swift @@ -2,7 +2,6 @@ import UIKit import WebKit class TPPSAMLHelper { - var businessLogic: TPPSignInBusinessLogic! func logIn(loginCancelHandler: @escaping () -> Void) { @@ -11,7 +10,10 @@ class TPPSAMLHelper { } var urlComponents = URLComponents(url: idpURL, resolvingAgainstBaseURL: true) - let redirectURI = URLQueryItem(name: "redirect_uri", value: businessLogic.urlSettingsProvider.universalLinksURL.absoluteString) + let redirectURI = URLQueryItem( + name: "redirect_uri", + value: businessLogic.urlSettingsProvider.universalLinksURL.absoluteString + ) urlComponents?.queryItems?.append(redirectURI) guard let url = urlComponents?.url else { // Handle error if URL creation failed @@ -21,12 +23,21 @@ class TPPSAMLHelper { let loginCompletionHandler: (URL, [HTTPCookie]) -> Void = { url, cookies in self.businessLogic.cookies = cookies - let redirectNotification = Notification(name: .TPPAppDelegateDidReceiveCleverRedirectURL, object: url, userInfo: nil) + let redirectNotification = Notification( + name: .TPPAppDelegateDidReceiveCleverRedirectURL, + object: url, + userInfo: nil + ) self.businessLogic.handleRedirectURL(redirectNotification) { error, errorTitle, errorMessage in DispatchQueue.main.async { self.businessLogic.uiDelegate?.dismiss(animated: true) { if let error = error, let errorTitle = errorTitle, let errorMessage = errorMessage { - self.businessLogic.uiDelegate?.businessLogic(self.businessLogic, didEncounterValidationError: error, userFriendlyErrorTitle: errorTitle, andMessage: errorMessage) + self.businessLogic.uiDelegate?.businessLogic( + self.businessLogic, + didEncounterValidationError: error, + userFriendlyErrorTitle: errorTitle, + andMessage: errorMessage + ) } } } diff --git a/Palace/SignInLogic/TPPSignInBusinessLogic+CardCreation.swift b/Palace/SignInLogic/TPPSignInBusinessLogic+CardCreation.swift index 7354e515a..5939b96ce 100644 --- a/Palace/SignInLogic/TPPSignInBusinessLogic+CardCreation.swift +++ b/Palace/SignInLogic/TPPSignInBusinessLogic+CardCreation.swift @@ -6,8 +6,8 @@ // Copyright © 2022 The Palace Project. All rights reserved. // -import Foundation import CoreLocation +import Foundation import SafariServices extension TPPSignInBusinessLogic: CLLocationManagerDelegate { @@ -73,7 +73,9 @@ extension TPPSignInBusinessLogic: CLLocationManagerDelegate { // Adds latitude and longitude parameters to the URL. private func addLocationInformation(baseURL: String, locationManager: CLLocationManager) -> URL? { - guard let userLocation = locationManager.location else { return nil } + guard let userLocation = locationManager.location else { + return nil + } let latitude = userLocation.coordinate.latitude let longitude = userLocation.coordinate.longitude @@ -82,7 +84,7 @@ extension TPPSignInBusinessLogic: CLLocationManagerDelegate { } // This delegate method is called when the authorization status changes. - func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) { + func locationManagerDidChangeAuthorization(_: CLLocationManager) { let status = CLLocationManager.authorizationStatus() if status == .authorizedWhenInUse || status == .authorizedAlways { startRegularCardCreation(completion: onLocationAuthorizationCompletion) diff --git a/Palace/SignInLogic/TPPSignInBusinessLogic+DRM.swift b/Palace/SignInLogic/TPPSignInBusinessLogic+DRM.swift index 9d4ec52f0..958d38f3b 100644 --- a/Palace/SignInLogic/TPPSignInBusinessLogic+DRM.swift +++ b/Palace/SignInLogic/TPPSignInBusinessLogic+DRM.swift @@ -11,7 +11,6 @@ import Foundation #if FEATURE_DRM_CONNECTOR extension TPPSignInBusinessLogic { - /// Extract authorization credentials from binary data and perform DRM /// authorization request. /// @@ -23,41 +22,50 @@ extension TPPSignInBusinessLogic { do { profileDoc = try UserProfileDocument.fromData(data) } catch { - TPPErrorLogger.logUserProfileDocumentAuthError(error as NSError, - summary:"SignIn: unable to parse user profile doc", - barcode: nil, - metadata:loggingContext) - finalizeSignIn(forDRMAuthorization: false, - errorMessage: "Error parsing user profile document") + TPPErrorLogger.logUserProfileDocumentAuthError( + error as NSError, + summary: "SignIn: unable to parse user profile doc", + barcode: nil, + metadata: loggingContext + ) + finalizeSignIn( + forDRMAuthorization: false, + errorMessage: "Error parsing user profile document" + ) return } if let authID = profileDoc.authorizationIdentifier { userAccount.setAuthorizationIdentifier(authID) } else { - TPPErrorLogger.logError(withCode: .noAuthorizationIdentifier, - summary: "SignIn: no authorization ID in user profile doc", - metadata: loggingContext) + TPPErrorLogger.logError( + withCode: .noAuthorizationIdentifier, + summary: "SignIn: no authorization ID in user profile doc", + metadata: loggingContext + ) } guard let drm = profileDoc.drm?.first, drm.vendor != nil, - let clientToken = drm.clientToken else { - - let drm = profileDoc.drm?.first - Log.info(#file, "\nLicensor: \(drm?.licensor ?? ["N/A": "N/A"])") - - TPPErrorLogger.logError(withCode: .noLicensorToken, - summary: "SignIn: no licensor token in user profile doc", - metadata: loggingContext) - - finalizeSignIn(forDRMAuthorization: false, - errorMessage: "No credentials were received to authorize access to books with DRM.") - return + let clientToken = drm.clientToken + else { + let drm = profileDoc.drm?.first + Log.info(#file, "\nLicensor: \(drm?.licensor ?? ["N/A": "N/A"])") + + TPPErrorLogger.logError( + withCode: .noLicensorToken, + summary: "SignIn: no licensor token in user profile doc", + metadata: loggingContext + ) + + finalizeSignIn( + forDRMAuthorization: false, + errorMessage: "No credentials were received to authorize access to books with DRM." + ) + return } - Log.info(#file, "\nLicensor: \(drm.licensor)") userAccount.setLicensor(drm.licensor) @@ -66,9 +74,11 @@ extension TPPSignInBusinessLogic { licensorItems.removeLast() let tokenUsername = (licensorItems as NSArray).componentsJoined(by: "|") - drmAuthorize(username: tokenUsername, - password: tokenPassword, - loggingContext: loggingContext) + drmAuthorize( + username: tokenUsername, + password: tokenPassword, + loggingContext: loggingContext + ) } /// Perform the DRM authorization request with the given credentials @@ -79,91 +89,108 @@ extension TPPSignInBusinessLogic { /// optional is because ADEPT already handles `nil` values, so we don't /// have to do the same here. /// - loggingContext: Information to report when logging errors. - private func drmAuthorize(username: String, - password: String?, - loggingContext: [String: Any]) { - + private func drmAuthorize( + username: String, + password: String?, + loggingContext: [String: Any] + ) { let vendor = userAccount.licensor?["vendor"] as? String Log.info(#file, """ - ***DRM Auth/Activation Attempt*** - Token username: \(username) - Token password: \(password ?? "N/A") - VendorID: \(vendor ?? "N/A") - """) + ***DRM Auth/Activation Attempt*** + Token username: \(username) + Token password: \(password ?? "N/A") + VendorID: \(vendor ?? "N/A") + """) drmAuthorizer? - .authorize(withVendorID: vendor, - username: username, - password: password) { success, error, deviceID, userID in - - // make sure to cancel the previously scheduled selector - // from the same thread it was scheduled on - TPPMainThreadRun.asyncIfNeeded { [weak self] in - if let self = self { - NSObject.cancelPreviousPerformRequests(withTarget: self) - } - } - - Log.info(#file, """ - Activation success: \(success) - Error: \(error?.localizedDescription ?? "N/A") - DeviceID: \(deviceID ?? "N/A") - UserID: \(userID ?? "N/A") - ***DRM Auth/Activation completion*** - """) - - var success = success - - if success, let userID = userID, let deviceID = deviceID { - TPPMainThreadRun.asyncIfNeeded { - self.userAccount.setUserID(userID) - self.userAccount.setDeviceID(deviceID) - } - } else { - success = false - TPPErrorLogger.logLocalAuthFailed(error: error as NSError?, - library: self.libraryAccount, - metadata: loggingContext) - } - - self.finalizeSignIn(forDRMAuthorization: success, - error: error as NSError?) - } + .authorize( + withVendorID: vendor, + username: username, + password: password + ) { success, error, deviceID, userID in + // make sure to cancel the previously scheduled selector + // from the same thread it was scheduled on + TPPMainThreadRun.asyncIfNeeded { [weak self] in + if let self = self { + NSObject.cancelPreviousPerformRequests(withTarget: self) + } + } + + Log.info(#file, """ + Activation success: \(success) + Error: \(error?.localizedDescription ?? "N/A") + DeviceID: \(deviceID ?? "N/A") + UserID: \(userID ?? "N/A") + ***DRM Auth/Activation completion*** + """) + + var success = success + + if success, let userID = userID, let deviceID = deviceID { + TPPMainThreadRun.asyncIfNeeded { + self.userAccount.setUserID(userID) + self.userAccount.setDeviceID(deviceID) + } + } else { + success = false + TPPErrorLogger.logLocalAuthFailed( + error: error as NSError?, + library: self.libraryAccount, + metadata: loggingContext + ) + } + + self.finalizeSignIn( + forDRMAuthorization: success, + error: error as NSError? + ) + } TPPMainThreadRun.asyncIfNeeded { [weak self] in self?.perform(#selector(self?.dismissAfterUnexpectedDRMDelay), with: self, afterDelay: 25) } } - @objc func dismissAfterUnexpectedDRMDelay(_ arg: Any) { + @objc func dismissAfterUnexpectedDRMDelay(_: Any) { TPPMainThreadRun.asyncIfNeeded { let title = Strings.Error.signInErrorTitle let message = Strings.Error.signInErrorDescription - let alert = UIAlertController(title: title, message: message, - preferredStyle: .alert) - alert.addAction(UIAlertAction(title: Strings.Generic.ok, - style: .default) { [weak self] action in - self?.uiDelegate?.dismiss(animated: true, - completion: nil) + let alert = UIAlertController( + title: title, + message: message, + preferredStyle: .alert + ) + alert.addAction(UIAlertAction( + title: Strings.Generic.ok, + style: .default + ) { [weak self] _ in + self?.uiDelegate?.dismiss( + animated: true, + completion: nil + ) }) - TPPAlertUtils.presentFromViewControllerOrNil(alertController: alert, - viewController: nil, - animated: true, - completion: nil) + TPPAlertUtils.presentFromViewControllerOrNil( + alertController: alert, + viewController: nil, + animated: true, + completion: nil + ) } } @objc func logInIfUserAuthorized() { if let drmAuthorizer = drmAuthorizer, - !drmAuthorizer.isUserAuthorized(userAccount.userID, - withDevice: userAccount.deviceID) { - + !drmAuthorizer.isUserAuthorized( + userAccount.userID, + withDevice: userAccount.deviceID + ) + { if userAccount.hasBarcodeAndPIN() && !isValidatingCredentials { if let usernameTextField = uiDelegate?.usernameTextField, - let PINTextField = uiDelegate?.PINTextField + let PINTextField = uiDelegate?.PINTextField { usernameTextField.text = userAccount.barcode PINTextField.text = userAccount.PIN diff --git a/Palace/SignInLogic/TPPSignInBusinessLogic+OAuth.swift b/Palace/SignInLogic/TPPSignInBusinessLogic+OAuth.swift index fc9a6c31a..2977a9685 100644 --- a/Palace/SignInLogic/TPPSignInBusinessLogic+OAuth.swift +++ b/Palace/SignInLogic/TPPSignInBusinessLogic+OAuth.swift @@ -9,111 +9,137 @@ import Foundation extension TPPSignInBusinessLogic { - //---------------------------------------------------------------------------- + // ---------------------------------------------------------------------------- func oauthLogIn() { // for this kind of authentication, we want to redirect user to Safari to // conduct the process guard let oauthURL = selectedAuthentication?.oauthIntermediaryUrl else { - TPPErrorLogger.logError(withCode: .noURL, - summary: "Nil OAuth intermediary URL", - metadata: [ - "authMethod": selectedAuthentication?.methodDescription ?? "N/A", - "context": uiDelegate?.context ?? "N/A"]) + TPPErrorLogger.logError( + withCode: .noURL, + summary: "Nil OAuth intermediary URL", + metadata: [ + "authMethod": selectedAuthentication?.methodDescription ?? "N/A", + "context": uiDelegate?.context ?? "N/A" + ] + ) return } guard var urlComponents = URLComponents(url: oauthURL, resolvingAgainstBaseURL: true) else { - TPPErrorLogger.logError(withCode: .malformedURL, - summary: "Malformed OAuth intermediary URL", - metadata: [ - "authMethod": selectedAuthentication?.methodDescription ?? "N/A", - "OAUth Intermediary URL": oauthURL.absoluteString, - "context": uiDelegate?.context ?? "N/A"]) + TPPErrorLogger.logError( + withCode: .malformedURL, + summary: "Malformed OAuth intermediary URL", + metadata: [ + "authMethod": selectedAuthentication?.methodDescription ?? "N/A", + "OAUth Intermediary URL": oauthURL.absoluteString, + "context": uiDelegate?.context ?? "N/A" + ] + ) return } let redirectParam = URLQueryItem( name: "redirect_uri", - value: urlSettingsProvider.universalLinksURL.absoluteString) + value: urlSettingsProvider.universalLinksURL.absoluteString + ) urlComponents.queryItems?.append(redirectParam) guard let finalURL = urlComponents.url else { - TPPErrorLogger.logError(withCode: .malformedURL, - summary: "Unable to create URL for OAuth login", - metadata: [ - "authMethod": selectedAuthentication?.methodDescription ?? "N/A", - "OAUth Intermediary URL": oauthURL.absoluteString, - "redirectParam": redirectParam, - "context": uiDelegate?.context ?? "N/A"]) + TPPErrorLogger.logError( + withCode: .malformedURL, + summary: "Unable to create URL for OAuth login", + metadata: [ + "authMethod": selectedAuthentication?.methodDescription ?? "N/A", + "OAUth Intermediary URL": oauthURL.absoluteString, + "redirectParam": redirectParam, + "context": uiDelegate?.context ?? "N/A" + ] + ) return } NotificationCenter.default - .addObserver(self, - selector: #selector(handleRedirectURL(_:)), - name: .TPPAppDelegateDidReceiveCleverRedirectURL, - object: nil) + .addObserver( + self, + selector: #selector(handleRedirectURL(_:)), + name: .TPPAppDelegateDidReceiveCleverRedirectURL, + object: nil + ) TPPMainThreadRun.asyncIfNeeded { UIApplication.shared.open(finalURL) } } - //---------------------------------------------------------------------------- + // ---------------------------------------------------------------------------- private func universalLinkRedirectURLContainsPayload(_ urlStr: String) -> Bool { - return urlStr.contains("error") + urlStr.contains("error") || (urlStr.contains("access_token") && urlStr.contains("patron_info")) } - //---------------------------------------------------------------------------- - + // ---------------------------------------------------------------------------- + // As per Apple Developer Documentation, selector for NSNotification must have // one and only one argument (an instance of NSNotification). // See https://developer.apple.com/documentation/foundation/nsnotificationcenter/1415360-addobserver // for more information. @objc func handleRedirectURL(_ notification: Notification) { - self.handleRedirectURL(notification, completion: nil) + handleRedirectURL(notification, completion: nil) } - + // this is used by both Clever and SAML authentication - @objc func handleRedirectURL(_ notification: Notification, completion: ((_ error: Error?, _ errorTitle: String?, _ errorMessage: String?)->())?) { + @objc func handleRedirectURL( + _ notification: Notification, + completion: ((_ error: Error?, _ errorTitle: String?, _ errorMessage: String?) -> Void)? + ) { NotificationCenter.default .removeObserver(self, name: .TPPAppDelegateDidReceiveCleverRedirectURL, object: nil) guard let url = notification.object as? URL else { - TPPErrorLogger.logError(withCode: .noURL, - summary: "Sign-in redirection error", - metadata: [ - "authMethod": selectedAuthentication?.methodDescription ?? "N/A", - "context": uiDelegate?.context ?? "N/A"]) + TPPErrorLogger.logError( + withCode: .noURL, + summary: "Sign-in redirection error", + metadata: [ + "authMethod": selectedAuthentication?.methodDescription ?? "N/A", + "context": uiDelegate?.context ?? "N/A" + ] + ) completion?(nil, nil, nil) return } let urlStr = url.absoluteString guard urlStr.hasPrefix(urlSettingsProvider.universalLinksURL.absoluteString), - universalLinkRedirectURLContainsPayload(urlStr) else { - - TPPErrorLogger.logError(withCode: .unrecognizedUniversalLink, - summary: "Sign-in redirection error: missing payload", - metadata: [ - "loginURL": urlStr, - "context": uiDelegate?.context ?? "N/A"]) - completion?(nil, - Strings.Error.loginErrorTitle, - Strings.Error.loginErrorDescription) - return + universalLinkRedirectURLContainsPayload(urlStr) + else { + TPPErrorLogger.logError( + withCode: .unrecognizedUniversalLink, + summary: "Sign-in redirection error: missing payload", + metadata: [ + "loginURL": urlStr, + "context": uiDelegate?.context ?? "N/A" + ] + ) + completion?( + nil, + Strings.Error.loginErrorTitle, + Strings.Error.loginErrorDescription + ) + return } var kvpairs = [String: String]() // Oauth method provides the auth token as a fragment while SAML as a // query parameter guard let payload = { url.fragment ?? url.query }() else { - TPPErrorLogger.logError(withCode: .unrecognizedUniversalLink, - summary: "Sign-in redirection error: payload not in fragment nor query params", - metadata: [ - "loginURL": urlStr, - "context": uiDelegate?.context ?? "N/A"]) + TPPErrorLogger.logError( + withCode: .unrecognizedUniversalLink, + summary: "Sign-in redirection error: payload not in fragment nor query params", + metadata: [ + "loginURL": urlStr, + "context": uiDelegate?.context ?? "N/A" + ] + ) completion?(nil, nil, nil) return } @@ -129,11 +155,13 @@ extension TPPSignInBusinessLogic { if let rawError = kvpairs["error"], let error = rawError.replacingOccurrences(of: "+", with: " ").removingPercentEncoding, - let parsedError = error.parseJSONString as? [String: Any] { - - completion?(nil, - Strings.Error.loginErrorTitle, - parsedError["title"] as? String) + let parsedError = error.parseJSONString as? [String: Any] + { + completion?( + nil, + Strings.Error.loginErrorTitle, + parsedError["title"] as? String + ) return } @@ -141,16 +169,19 @@ extension TPPSignInBusinessLogic { let authToken = kvpairs["access_token"], let patronInfo = kvpairs["patron_info"], let patron = patronInfo.replacingOccurrences(of: "+", with: " ").removingPercentEncoding, - let parsedPatron = patron.parseJSONString as? [String: Any] else { - - TPPErrorLogger.logError(withCode: .authDataParseFail, - summary: "Sign-in redirection error: Unable to parse auth info", - metadata: [ - "payloadDictionary": kvpairs, - "redirectURL": url, - "context": uiDelegate?.context ?? "N/A"]) - completion?(nil, nil, nil) - return + let parsedPatron = patron.parseJSONString as? [String: Any] + else { + TPPErrorLogger.logError( + withCode: .authDataParseFail, + summary: "Sign-in redirection error: Unable to parse auth info", + metadata: [ + "payloadDictionary": kvpairs, + "redirectURL": url, + "context": uiDelegate?.context ?? "N/A" + ] + ) + completion?(nil, nil, nil) + return } self.authToken = authToken diff --git a/Palace/SignInLogic/TPPSignInBusinessLogic+SignOut.swift b/Palace/SignInLogic/TPPSignInBusinessLogic+SignOut.swift index 3667846eb..1757a7728 100644 --- a/Palace/SignInLogic/TPPSignInBusinessLogic+SignOut.swift +++ b/Palace/SignInLogic/TPPSignInBusinessLogic+SignOut.swift @@ -9,62 +9,71 @@ import Foundation extension TPPSignInBusinessLogic { - /// Main entry point for logging a user out. /// /// - Important: Requires to be called from the main thread. -func performLogOut() { -#if FEATURE_DRM_CONNECTOR - + func performLogOut() { + #if FEATURE_DRM_CONNECTOR + uiDelegate?.businessLogicWillSignOut(self) - - guard var request = self.makeRequest(for: .signOut, context: "Sign Out") else { + + guard var request = makeRequest(for: .signOut, context: "Sign Out") else { return } request.timeoutInterval = 45 - + let barcode = userAccount.barcode networker.executeRequest(request, enableTokenRefresh: false) { [weak self] result in switch result { - case .success(let data, let response): - self?.processLogOut(data: data, - response: response, - for: request, - barcode: barcode) - case .failure(let errorWithProblemDoc, let response): - TPPUserAccount.sharedAccount().removeAll() - self?.processLogOutError(errorWithProblemDoc, - response: response, - for: request, - barcode: barcode) + case let .success(data, response): + self?.processLogOut( + data: data, + response: response, + for: request, + barcode: barcode + ) + case let .failure(errorWithProblemDoc, response): + TPPUserAccount.sharedAccount().removeAll() + self?.processLogOutError( + errorWithProblemDoc, + response: response, + for: request, + barcode: barcode + ) } } - -#else - if self.bookRegistry.isSyncing { + + #else + if bookRegistry.isSyncing { let alert = TPPAlertUtils.alert( title: "SettingsAccountViewControllerCannotLogOutTitle", - message: "SettingsAccountViewControllerCannotLogOutMessage") + message: "SettingsAccountViewControllerCannotLogOutMessage" + ) uiDelegate?.present(alert, animated: true, completion: nil) } else { completeLogOutProcess() } -#endif + #endif } #if FEATURE_DRM_CONNECTOR - private func processLogOut(data: Data, - response: URLResponse?, - for request: URLRequest, - barcode: String?) { + private func processLogOut( + data: Data, + response: URLResponse?, + for request: URLRequest, + barcode: String? + ) { let statusCode = (response as? HTTPURLResponse)?.statusCode ?? 0 let profileDoc: UserProfileDocument do { profileDoc = try UserProfileDocument.fromData(data) } catch { - Log.error(#file, "Unable to parse user profile at sign out (HTTP \(statusCode): Adobe device deauthorization won't be possible.") + Log.error( + #file, + "Unable to parse user profile at sign out (HTTP \(statusCode): Adobe device deauthorization won't be possible." + ) TPPErrorLogger.logUserProfileDocumentAuthError( error as NSError, summary: "SignOut: unable to parse user profile doc", @@ -73,35 +82,40 @@ func performLogOut() { "Request": request.loggableString, "Response": response ?? "N/A", "HTTP status code": statusCode - ]) - self.uiDelegate?.businessLogic(self, - didEncounterSignOutError: error, - withHTTPStatusCode: statusCode) + ] + ) + uiDelegate?.businessLogic( + self, + didEncounterSignOutError: error, + withHTTPStatusCode: statusCode + ) return } if let drm = profileDoc.drm?.first, - let clientToken = drm.clientToken, drm.vendor != nil { - + let clientToken = drm.clientToken, drm.vendor != nil + { // Set the fresh Adobe token info into the user account so that the // following `deauthorizeDevice` call can use it. - self.userAccount.setLicensor(drm.licensor) - Log.info(#file, "\nLicensory token updated to \(clientToken) for adobe user ID \(self.userAccount.userID ?? "N/A")") + userAccount.setLicensor(drm.licensor) + Log.info(#file, "\nLicensory token updated to \(clientToken) for adobe user ID \(userAccount.userID ?? "N/A")") } else { Log.error(#file, "\nLicensor token invalid: \(profileDoc.toJson())") } - self.deauthorizeDevice() + deauthorizeDevice() } - private func processLogOutError(_ errorWithProblemDoc: TPPUserFriendlyError, - response: URLResponse?, - for request: URLRequest, - barcode: String?) { + private func processLogOutError( + _ errorWithProblemDoc: TPPUserFriendlyError, + response: URLResponse?, + for request: URLRequest, + barcode: String? + ) { let statusCode = (response as? HTTPURLResponse)?.statusCode ?? 0 if statusCode == 401 { - self.deauthorizeDevice() + deauthorizeDevice() } TPPErrorLogger.logNetworkError( @@ -110,16 +124,20 @@ func performLogOut() { request: request, response: response, metadata: [ - "AuthMethod": self.selectedAuthentication?.methodDescription ?? "N/A", + "AuthMethod": selectedAuthentication?.methodDescription ?? "N/A", "Hashed barcode": barcode?.md5hex() ?? "N/A", - "HTTP status code": statusCode]) - - self.uiDelegate?.businessLogic(self, - didEncounterSignOutError: errorWithProblemDoc, - withHTTPStatusCode: statusCode) + "HTTP status code": statusCode + ] + ) + + uiDelegate?.businessLogic( + self, + didEncounterSignOutError: errorWithProblemDoc, + withHTTPStatusCode: statusCode + ) } #endif - + private func completeLogOutProcess() { bookDownloadsCenter.reset(libraryAccountID) bookRegistry.reset(libraryAccountID) @@ -145,7 +163,7 @@ func performLogOut() { let tokenUsername = licensorItems?.joined(separator: "|") let adobeUserID = userAccount.userID let adobeDeviceID = userAccount.deviceID - + Log.info(#file, """ ***DRM Deactivation Attempt*** Licensor: \(licensor) @@ -160,26 +178,30 @@ func performLogOut() { withUsername: tokenUsername, password: tokenPassword, userID: adobeUserID, - deviceID: adobeDeviceID) { [weak self] success, error in - if success { - Log.info(#file, "*** Successful DRM Deactivation ***") - } else { - // Even though we failed, let the user continue to log out. - // The most likely reason is a user changing their PIN. - TPPErrorLogger.logError(error, - summary: "User lost an activation on signout: ADEPT error", - metadata: [ - "AdobeUserID": adobeUserID ?? "N/A", - "DeviceID": adobeDeviceID ?? "N/A", - "Licensor": licensor, - "AdobeTokenUsername": tokenUsername ?? "N/A", - "AdobeTokenPassword": tokenPassword ?? "N/A"]) - } - - self?.completeLogOutProcess() + deviceID: adobeDeviceID + ) { [weak self] success, error in + if success { + Log.info(#file, "*** Successful DRM Deactivation ***") + } else { + // Even though we failed, let the user continue to log out. + // The most likely reason is a user changing their PIN. + TPPErrorLogger.logError( + error, + summary: "User lost an activation on signout: ADEPT error", + metadata: [ + "AdobeUserID": adobeUserID ?? "N/A", + "DeviceID": adobeDeviceID ?? "N/A", + "Licensor": licensor, + "AdobeTokenUsername": tokenUsername ?? "N/A", + "AdobeTokenPassword": tokenPassword ?? "N/A" + ] + ) + } + + self?.completeLogOutProcess() } } else { - self.completeLogOutProcess() + completeLogOutProcess() } } #endif diff --git a/Palace/SignInLogic/TPPSignInBusinessLogic+UI.swift b/Palace/SignInLogic/TPPSignInBusinessLogic+UI.swift index 5f135d888..1cf32769c 100644 --- a/Palace/SignInLogic/TPPSignInBusinessLogic+UI.swift +++ b/Palace/SignInLogic/TPPSignInBusinessLogic+UI.swift @@ -9,7 +9,6 @@ import UIKit extension TPPSignInBusinessLogic { - /// Finalizes the sign in process by updating the user account for the /// library we are signing in to and calling the completion handler in /// case that was set, as well as dismissing the presented view controller @@ -21,30 +20,35 @@ extension TPPSignInBusinessLogic { /// - error: The error encountered during sign-in, if any. /// - errorMessage: Error message to display, taking priority over `error`. /// This can be a localization key. - func finalizeSignIn(forDRMAuthorization drmSuccess: Bool, - error: Error? = nil, - errorMessage: String? = nil) { + func finalizeSignIn( + forDRMAuthorization drmSuccess: Bool, + error: Error? = nil, + errorMessage: String? = nil + ) { TPPMainThreadRun.asyncIfNeeded { defer { self.uiDelegate?.businessLogicDidCompleteSignIn(self) } - self.updateUserAccount(forDRMAuthorization: drmSuccess, - withBarcode: self.uiDelegate?.username, - pin: self.uiDelegate?.pin, - authToken: self.authToken, - expirationDate: self.authTokenExpiration, - patron: self.patron, - cookies: self.cookies + self.updateUserAccount( + forDRMAuthorization: drmSuccess, + withBarcode: self.uiDelegate?.username, + pin: self.uiDelegate?.pin, + authToken: self.authToken, + expirationDate: self.authTokenExpiration, + patron: self.patron, + cookies: self.cookies ) #if FEATURE_DRM_CONNECTOR guard drmSuccess else { NotificationCenter.default.post(name: .TPPSyncEnded, object: nil) - let alert = TPPAlertUtils.alert(title: Strings.Error.loginErrorTitle, - message: errorMessage, - error: error as NSError?) + let alert = TPPAlertUtils.alert( + title: Strings.Error.loginErrorTitle, + message: errorMessage, + error: error as NSError? + ) TPPPresentationUtils.safelyPresent(alert, animated: true) return } @@ -73,7 +77,6 @@ extension TPPSignInBusinessLogic { /// - Returns: An alert the caller needs to present in case there's syncing /// or book downloading/returning currently happening. @objc func logOutOrWarn() -> UIAlertController? { - let title = Strings.TPPSigninBusinessLogic.signout let msg: String if bookRegistry.isSyncing { @@ -85,19 +88,27 @@ extension TPPSignInBusinessLogic { return nil } - let alert = UIAlertController(title: title, - message: msg, - preferredStyle: .alert) + let alert = UIAlertController( + title: title, + message: msg, + preferredStyle: .alert + ) alert.addAction( - UIAlertAction(title: title, - style: .destructive, - handler: { _ in - self.performLogOut() - })) + UIAlertAction( + title: title, + style: .destructive, + handler: { _ in + self.performLogOut() + } + ) + ) alert.addAction( - UIAlertAction(title: Strings.Generic.wait, - style: .cancel, - handler: nil)) + UIAlertAction( + title: Strings.Generic.wait, + style: .cancel, + handler: nil + ) + ) return alert } diff --git a/Palace/SignInLogic/TPPSignInBusinessLogic.swift b/Palace/SignInLogic/TPPSignInBusinessLogic.swift index 2c27fb7ef..19befdc6e 100644 --- a/Palace/SignInLogic/TPPSignInBusinessLogic.swift +++ b/Palace/SignInLogic/TPPSignInBusinessLogic.swift @@ -8,68 +8,97 @@ import CoreLocation +// MARK: - TPPAuthRequestType + @objc enum TPPAuthRequestType: Int { case signIn = 1 case signOut = 2 } +// MARK: - TPPBookDownloadsDeleting + @objc protocol TPPBookDownloadsDeleting { func reset(_ libraryID: String!) } +// MARK: - TPPBookRegistrySyncing + @objc protocol TPPBookRegistrySyncing: NSObjectProtocol { - var isSyncing: Bool {get} + var isSyncing: Bool { get } func reset(_ libraryAccountUUID: String) func sync() } +// MARK: - TPPDRMAuthorizing + @objc protocol TPPDRMAuthorizing: NSObjectProtocol { - var workflowsInProgress: Bool {get} + var workflowsInProgress: Bool { get } func isUserAuthorized(_ userID: String!, withDevice device: String!) -> Bool - func authorize(withVendorID vendorID: String!, username: String!, password: String!, completion: ((Bool, Error?, String?, String?) -> Void)!) - func deauthorize(withUsername username: String!, password: String!, userID: String!, deviceID: String!, completion: ((Bool, Error?) -> Void)!) + func authorize( + withVendorID vendorID: String!, + username: String!, + password: String!, + completion: ((Bool, Error?, String?, String?) -> Void)! + ) + func deauthorize( + withUsername username: String!, + password: String!, + userID: String!, + deviceID: String!, + completion: ((Bool, Error?) -> Void)! + ) } #if FEATURE_DRM_CONNECTOR extension NYPLADEPT: TPPDRMAuthorizing {} #endif +// MARK: - TPPSignInBusinessLogic + class TPPSignInBusinessLogic: NSObject, TPPSignedInStateProvider, TPPCurrentLibraryAccountProvider { - var onLocationAuthorizationCompletion: (UINavigationController?, Error?) -> Void = {_,_ in } + var onLocationAuthorizationCompletion: (UINavigationController?, Error?) -> Void = { _, _ in } /// Makes a business logic object with a network request executor that /// performs no persistent storage for caching. - @objc convenience init(libraryAccountID: String, - libraryAccountsProvider: TPPLibraryAccountsProvider, - urlSettingsProvider: NYPLUniversalLinksSettings & NYPLFeedURLProvider, - bookRegistry: TPPBookRegistrySyncing, - bookDownloadsCenter: TPPBookDownloadsDeleting, - userAccountProvider: TPPUserAccountProvider.Type, - uiDelegate: TPPSignInOutBusinessLogicUIDelegate?, - drmAuthorizer: TPPDRMAuthorizing?) { - self.init(libraryAccountID: libraryAccountID, - libraryAccountsProvider: libraryAccountsProvider, - urlSettingsProvider: urlSettingsProvider, - bookRegistry: bookRegistry, - bookDownloadsCenter: bookDownloadsCenter, - userAccountProvider: userAccountProvider, - networkExecutor: TPPNetworkExecutor(credentialsProvider: uiDelegate, - cachingStrategy: .ephemeral, - delegateQueue: OperationQueue.main), - uiDelegate: uiDelegate, - drmAuthorizer: drmAuthorizer) + @objc convenience init( + libraryAccountID: String, + libraryAccountsProvider: TPPLibraryAccountsProvider, + urlSettingsProvider: NYPLUniversalLinksSettings & NYPLFeedURLProvider, + bookRegistry: TPPBookRegistrySyncing, + bookDownloadsCenter: TPPBookDownloadsDeleting, + userAccountProvider: TPPUserAccountProvider.Type, + uiDelegate: TPPSignInOutBusinessLogicUIDelegate?, + drmAuthorizer: TPPDRMAuthorizing? + ) { + self.init( + libraryAccountID: libraryAccountID, + libraryAccountsProvider: libraryAccountsProvider, + urlSettingsProvider: urlSettingsProvider, + bookRegistry: bookRegistry, + bookDownloadsCenter: bookDownloadsCenter, + userAccountProvider: userAccountProvider, + networkExecutor: TPPNetworkExecutor( + credentialsProvider: uiDelegate, + cachingStrategy: .ephemeral, + delegateQueue: OperationQueue.main + ), + uiDelegate: uiDelegate, + drmAuthorizer: drmAuthorizer + ) } /// Designated initializer. - init(libraryAccountID: String, - libraryAccountsProvider: TPPLibraryAccountsProvider, - urlSettingsProvider: NYPLUniversalLinksSettings & NYPLFeedURLProvider, - bookRegistry: TPPBookRegistrySyncing, - bookDownloadsCenter: TPPBookDownloadsDeleting, - userAccountProvider: TPPUserAccountProvider.Type, - networkExecutor: TPPRequestExecuting, - uiDelegate: TPPSignInOutBusinessLogicUIDelegate?, - drmAuthorizer: TPPDRMAuthorizing?) { + init( + libraryAccountID: String, + libraryAccountsProvider: TPPLibraryAccountsProvider, + urlSettingsProvider: NYPLUniversalLinksSettings & NYPLFeedURLProvider, + bookRegistry: TPPBookRegistrySyncing, + bookDownloadsCenter: TPPBookDownloadsDeleting, + userAccountProvider: TPPUserAccountProvider.Type, + networkExecutor: TPPRequestExecuting, + uiDelegate: TPPSignInOutBusinessLogicUIDelegate?, + drmAuthorizer: TPPDRMAuthorizing? + ) { self.uiDelegate = uiDelegate self.libraryAccountID = libraryAccountID self.libraryAccountsProvider = libraryAccountsProvider @@ -77,11 +106,11 @@ class TPPSignInBusinessLogic: NSObject, TPPSignedInStateProvider, TPPCurrentLibr self.bookRegistry = bookRegistry self.bookDownloadsCenter = bookDownloadsCenter self.userAccountProvider = userAccountProvider - self.networker = networkExecutor + networker = networkExecutor self.drmAuthorizer = drmAuthorizer - self.samlHelper = TPPSAMLHelper() + samlHelper = TPPSAMLHelper() super.init() - self.samlHelper.businessLogic = self + samlHelper.businessLogic = self } /// Signing in and out may imply syncing the book registry. @@ -94,13 +123,13 @@ class TPPSignInBusinessLogic: NSObject, TPPSignedInStateProvider, TPPCurrentLibr private let userAccountProvider: TPPUserAccountProvider.Type /// THe object determining whether there's an ongoing DRM authorization. - weak private(set) var drmAuthorizer: TPPDRMAuthorizing? + private(set) weak var drmAuthorizer: TPPDRMAuthorizing? /// The primary way for the business logic to communicate with the UI. @objc weak var uiDelegate: TPPSignInOutBusinessLogicUIDelegate? private var uiContext: String { - return uiDelegate?.context ?? "Unknown" + uiDelegate?.context ?? "Unknown" } /// This flag should be set if the instance is used to register new users. @@ -108,17 +137,17 @@ class TPPSignInBusinessLogic: NSObject, TPPSignedInStateProvider, TPPCurrentLibr /// A closure that will be invoked at the end of the sign-in process when /// refreshing authentication. - var refreshAuthCompletion: (() -> Void)? = nil + var refreshAuthCompletion: (() -> Void)? - // MARK:- OAuth / SAML / Clever Info + // MARK: - OAuth / SAML / Clever Info /// The current OAuth token if available. - var authToken: String? = nil - - var authTokenExpiration: Date? = nil + var authToken: String? + + var authTokenExpiration: Date? /// The current patron info if available. - var patron: [String: Any]? = nil + var patron: [String: Any]? /// Settings used by OAuth sign-in flows. @objc let urlSettingsProvider: NYPLUniversalLinksSettings & NYPLFeedURLProvider @@ -143,7 +172,7 @@ class TPPSignInBusinessLogic: NSObject, TPPSignedInStateProvider, TPPCurrentLibr /// `drmAuthorizeUserData`). @objc var isValidatingCredentials = false - // MARK:- Library Accounts Info + // MARK: - Library Accounts Info /// The ID of the library this object is signing in to. /// - Note: This is also provided by `libraryAccountsProvider::currentAccount` @@ -154,13 +183,13 @@ class TPPSignInBusinessLogic: NSObject, TPPSignedInStateProvider, TPPCurrentLibr let libraryAccountsProvider: TPPLibraryAccountsProvider @objc var libraryAccount: Account? { - return libraryAccountsProvider.account(libraryAccountID) + libraryAccountsProvider.account(libraryAccountID) } - + var currentAccount: Account? { - return libraryAccount + libraryAccount } - + /// Returns a valid password reset URL or `nil` /// /// Verifies that: @@ -170,17 +199,18 @@ class TPPSignInBusinessLogic: NSObject, TPPSignedInStateProvider, TPPCurrentLibr private var validPasswordResetUrl: URL? { guard let passwordResetHref = libraryAccount?.authenticationDocument?.links?.first(rel: .passwordReset)?.href, let passwordResetUrl = URL(string: passwordResetHref), - UIApplication.shared.canOpenURL(passwordResetUrl) else { + UIApplication.shared.canOpenURL(passwordResetUrl) + else { return nil } return passwordResetUrl } - + /// Verifies that current library account can reset user password. @objc var canResetPassword: Bool { validPasswordResetUrl != nil } - + /// Opens password reset URL to reset user password. /// /// This function doesn't show any error; use `canResetPassword` to identify if password can actually be reset. @@ -190,17 +220,25 @@ class TPPSignInBusinessLogic: NSObject, TPPSignedInStateProvider, TPPCurrentLibr } UIApplication.shared.open(passwordResetUrl) } - + @objc var selectedIDP: OPDS2SamlIDP? let locationManager = CLLocationManager() private var _selectedAuthentication: AccountDetails.Authentication? @objc var selectedAuthentication: AccountDetails.Authentication? { get { - guard _selectedAuthentication == nil else { return _selectedAuthentication } - guard userAccount.authDefinition == nil else { return userAccount.authDefinition } - guard let auths = libraryAccount?.details?.auths else { return nil } - guard auths.count > 1 else { return auths.first } + guard _selectedAuthentication == nil else { + return _selectedAuthentication + } + guard userAccount.authDefinition == nil else { + return userAccount.authDefinition + } + guard let auths = libraryAccount?.details?.auths else { + return nil + } + guard auths.count > 1 else { + return auths.first + } return nil } @@ -209,7 +247,7 @@ class TPPSignInBusinessLogic: NSObject, TPPSignedInStateProvider, TPPCurrentLibr } } - // MARK:- Network Requests Logic + // MARK: - Network Requests Logic let networker: TPPRequestExecuting @@ -221,26 +259,29 @@ class TPPSignInBusinessLogic: NSObject, TPPSignedInStateProvider, TPPCurrentLibr /// - authType: What kind of authentication request should be created. /// - context: A string for further context for error reporting. /// - Returns: A request for signing in or signing out. - func makeRequest(for authType: TPPAuthRequestType, - context: String) -> URLRequest? { - + func makeRequest( + for authType: TPPAuthRequestType, + context: String + ) -> URLRequest? { let authTypeStr = (authType == .signOut ? "signing out" : "signing in") guard let urlStr = libraryAccount?.details?.userProfileUrl, - let url = URL(string: urlStr) else { - TPPErrorLogger.logError( - withCode: .noURL, - summary: "Error: unable to create URL for \(authTypeStr)", - metadata: ["library.userProfileUrl": libraryAccount?.details?.userProfileUrl ?? "N/A"]) - return nil + let url = URL(string: urlStr) + else { + TPPErrorLogger.logError( + withCode: .noURL, + summary: "Error: unable to create URL for \(authTypeStr)", + metadata: ["library.userProfileUrl": libraryAccount?.details?.userProfileUrl ?? "N/A"] + ) + return nil } var req = URLRequest(url: url, applyingCustomUserAgent: true) if let selectedAuth = selectedAuthentication, - (selectedAuth.isOauth || selectedAuth.isSaml || selectedAuth.isToken) { - + selectedAuth.isOauth || selectedAuth.isSaml || selectedAuth.isToken + { // The nil-coalescing on the authToken covers 2 cases: // - sign in, where uiDelegate has the token because we just obtained it // externally (via OAuth) but user account may not have been updated yet; @@ -257,13 +298,16 @@ class TPPSignInBusinessLogic: NSObject, TPPSignedInStateProvider, TPPCurrentLibr req.addValue(authorization, forHTTPHeaderField: "Authorization") } else { Log.info(#file, "Auth token expected, but none is available.") - TPPErrorLogger.logError(withCode: .validationWithoutAuthToken, - summary: "Error \(authTypeStr): No token available during OAuth/SAML authentication validation", - metadata: [ - "isSAML": selectedAuth.isSaml, - "isOAuth": selectedAuth.isOauth, - "context": context, - "uiDelegate nil?": uiDelegate == nil ? "y" : "n"]) + TPPErrorLogger.logError( + withCode: .validationWithoutAuthToken, + summary: "Error \(authTypeStr): No token available during OAuth/SAML authentication validation", + metadata: [ + "isSAML": selectedAuth.isSaml, + "isOAuth": selectedAuth.isOauth, + "context": context, + "uiDelegate nil?": uiDelegate == nil ? "y" : "n" + ] + ) } } @@ -278,14 +322,17 @@ class TPPSignInBusinessLogic: NSObject, TPPSignedInStateProvider, TPPCurrentLibr isValidatingCredentials = true guard let req = makeRequest(for: .signIn, context: uiContext) else { - let error = NSError(domain: TPPErrorLogger.clientDomain, - code: TPPErrorCode.noURL.rawValue, - userInfo: [ - NSLocalizedDescriptionKey: - Strings.Error.serverConnectionErrorDescription, - NSLocalizedRecoverySuggestionErrorKey: - Strings.Error.serverConnectionErrorSuggestion]) - self.handleNetworkError(error, loggingContext: ["Context": uiContext]) + let error = NSError( + domain: TPPErrorLogger.clientDomain, + code: TPPErrorCode.noURL.rawValue, + userInfo: [ + NSLocalizedDescriptionKey: + Strings.Error.serverConnectionErrorDescription, + NSLocalizedRecoverySuggestionErrorKey: + Strings.Error.serverConnectionErrorSuggestion + ] + ) + handleNetworkError(error, loggingContext: ["Context": uiContext]) return } @@ -294,65 +341,73 @@ class TPPSignInBusinessLogic: NSObject, TPPSignedInStateProvider, TPPCurrentLibr return } - self.isValidatingCredentials = false + isValidatingCredentials = false let loggingContext: [String: Any] = [ "Request": req.loggableString, - "Attempted Barcode": self.uiDelegate?.username?.md5hex() ?? "N/A", - "Context": self.uiContext] + "Attempted Barcode": uiDelegate?.username?.md5hex() ?? "N/A", + "Context": uiContext + ] switch result { - case .success(let responseData, _): + case let .success(responseData, _): #if FEATURE_DRM_CONNECTOR - if (AdobeCertificate.defaultCertificate?.hasExpired == true) { - self.finalizeSignIn(forDRMAuthorization: true) + if AdobeCertificate.defaultCertificate?.hasExpired == true { + finalizeSignIn(forDRMAuthorization: true) } else { - self.drmAuthorizeUserData(responseData, loggingContext: loggingContext) + drmAuthorizeUserData(responseData, loggingContext: loggingContext) } #else - self.finalizeSignIn(forDRMAuthorization: true) + finalizeSignIn(forDRMAuthorization: true) #endif - case .failure(let errorWithProblemDoc, let response): - self.handleNetworkError(errorWithProblemDoc as NSError, - response: response, - loggingContext: loggingContext) + case let .failure(errorWithProblemDoc, response): + handleNetworkError( + errorWithProblemDoc as NSError, + response: response, + loggingContext: loggingContext + ) } } } - + func getBearerToken(username: String, password: String, tokenURL: URL, completion: (() -> Void)? = nil) { - TPPNetworkExecutor.shared.executeTokenRefresh(username: username, password: password, tokenURL: tokenURL) { [weak self] result in - defer { - completion?() - } + TPPNetworkExecutor.shared + .executeTokenRefresh(username: username, password: password, tokenURL: tokenURL) { [weak self] result in + defer { + completion?() + } - switch result { - case .success(let tokenResponse): - self?.authToken = tokenResponse.accessToken - self?.authTokenExpiration = tokenResponse.expirationDate - self?.validateCredentials() - case .failure(let error): - self?.handleNetworkError(error as NSError, loggingContext: ["Context": self?.uiContext as Any]) + switch result { + case let .success(tokenResponse): + self?.authToken = tokenResponse.accessToken + self?.authTokenExpiration = tokenResponse.expirationDate + self?.validateCredentials() + case let .failure(error): + self?.handleNetworkError(error as NSError, loggingContext: ["Context": self?.uiContext as Any]) + } } - } } /// Uses the problem document's `title` and `message` fields to /// communicate a user friendly error info to the `uiDelegate`. /// Also logs the `error`. - private func handleNetworkError(_ error: NSError, - response: URLResponse? = nil, - loggingContext: [String: Any]) { + private func handleNetworkError( + _ error: NSError, + response: URLResponse? = nil, + loggingContext: [String: Any] + ) { let problemDoc = error.problemDocument // TPPNetworkExecutor already logged the error, but this is more // informative - TPPErrorLogger.logLoginError(error, - library: libraryAccount, - response: response, - problemDocument: problemDoc, - metadata: loggingContext) + TPPErrorLogger.logLoginError( + error, + library: libraryAccount, + response: response, + problemDocument: problemDoc, + metadata: loggingContext + ) let title, message: String? if let problemDoc = problemDoc { @@ -364,10 +419,12 @@ class TPPSignInBusinessLogic: NSObject, TPPSignedInStateProvider, TPPCurrentLibr } TPPMainThreadRun.asyncIfNeeded { - self.uiDelegate?.businessLogic(self, - didEncounterValidationError: error, - userFriendlyErrorTitle: title, - andMessage: message) + self.uiDelegate?.businessLogic( + self, + didEncounterValidationError: error, + userFriendlyErrorTitle: title, + andMessage: message + ) } } @@ -386,7 +443,7 @@ class TPPSignInBusinessLogic: NSObject, TPPSignedInStateProvider, TPPCurrentLibr switch selectedAuthentication { case .none: return - case .some(let wrapped): + case let .some(wrapped): switch wrapped.authType { case .oauthIntermediary: oauthLogIn() @@ -395,8 +452,8 @@ class TPPSignInBusinessLogic: NSObject, TPPSignedInStateProvider, TPPCurrentLibr self.uiDelegate?.businessLogicDidCancelSignIn(self) } case .token: - guard let username = self.uiDelegate?.username, - let password = self.uiDelegate?.pin, + guard let username = uiDelegate?.username, + let password = uiDelegate?.pin, let tokenURL = tokenURL ?? TPPUserAccount.sharedAccount().authDefinition?.tokenURL else { validateCredentials() @@ -444,69 +501,75 @@ class TPPSignInBusinessLogic: NSObject, TPPSignedInStateProvider, TPPCurrentLibr /// - completion: Block to be run after the authentication refresh attempt /// is performed. /// - Returns: `true` if a sign-in UI is needed to refresh authentication. - @objc func refreshAuthIfNeeded(usingExistingCredentials: Bool, - completion: (() -> Void)?) -> Bool { - guard - let authDef = userAccount.authDefinition, - (authDef.isBasic || authDef.isOauth || authDef.isSaml || (authDef.isToken && TPPUserAccount.sharedAccount().authTokenHasExpired)) - else { - completion?() - return false - } - - refreshAuthCompletion = completion - - // reset authentication if needed - if authDef.isSaml || authDef.isOauth { - if !usingExistingCredentials { - // if current authentication is SAML and we don't want to use current - // credentials, we need to force log in process. this is for the case - // when we were logged in, but IDP expired our session and if this - // happens, we want the user to pick the idp to begin reauthentication - ignoreSignedInState = true - if authDef.isSaml { - selectedAuthentication = nil - } + @objc func refreshAuthIfNeeded( + usingExistingCredentials: Bool, + completion: (() -> Void)? + ) -> Bool { + guard + let authDef = userAccount.authDefinition, + authDef.isBasic || authDef.isOauth || authDef + .isSaml || (authDef.isToken && TPPUserAccount.sharedAccount().authTokenHasExpired) + else { + completion?() + return false + } + + refreshAuthCompletion = completion + + // reset authentication if needed + if authDef.isSaml || authDef.isOauth { + if !usingExistingCredentials { + // if current authentication is SAML and we don't want to use current + // credentials, we need to force log in process. this is for the case + // when we were logged in, but IDP expired our session and if this + // happens, we want the user to pick the idp to begin reauthentication + ignoreSignedInState = true + if authDef.isSaml { + selectedAuthentication = nil } } - - if authDef.isToken, let barcode = userAccount.barcode, let pin = userAccount.pin, let tokenURL = TPPUserAccount.sharedAccount().authDefinition?.tokenURL { - getBearerToken(username: barcode, password: pin, tokenURL: tokenURL, completion: completion) - } else if authDef.isBasic { - if usingExistingCredentials && userAccount.hasBarcodeAndPIN() { - if uiDelegate == nil { -#if DEBUG - preconditionFailure("uiDelegate must be set for logIn to work correctly") -#else - TPPErrorLogger.logError( - withCode: .appLogicInconsistency, - summary: "uiDelegate missing while refreshing basic auth", - metadata: [ - "usingExistingCredentials": usingExistingCredentials, - "hashedBarcode": userAccount.barcode?.md5hex() ?? "N/A" - ]) -#endif - } - uiDelegate?.usernameTextField?.text = userAccount.barcode - uiDelegate?.PINTextField?.text = userAccount.PIN - - logIn() - return false - } else { - uiDelegate?.usernameTextField?.text = "" - uiDelegate?.PINTextField?.text = "" - uiDelegate?.usernameTextField?.becomeFirstResponder() + } + + if authDef.isToken, let barcode = userAccount.barcode, let pin = userAccount.pin, + let tokenURL = TPPUserAccount.sharedAccount().authDefinition?.tokenURL + { + getBearerToken(username: barcode, password: pin, tokenURL: tokenURL, completion: completion) + } else if authDef.isBasic { + if usingExistingCredentials && userAccount.hasBarcodeAndPIN() { + if uiDelegate == nil { + #if DEBUG + preconditionFailure("uiDelegate must be set for logIn to work correctly") + #else + TPPErrorLogger.logError( + withCode: .appLogicInconsistency, + summary: "uiDelegate missing while refreshing basic auth", + metadata: [ + "usingExistingCredentials": usingExistingCredentials, + "hashedBarcode": userAccount.barcode?.md5hex() ?? "N/A" + ] + ) + #endif } + uiDelegate?.usernameTextField?.text = userAccount.barcode + uiDelegate?.PINTextField?.text = userAccount.PIN + + logIn() + return false + } else { + uiDelegate?.usernameTextField?.text = "" + uiDelegate?.PINTextField?.text = "" + uiDelegate?.usernameTextField?.becomeFirstResponder() } - - return true + } + + return true } - // MARK:- User Account Management + // MARK: - User Account Management /// The user account for the library we are signing in to. @objc var userAccount: TPPUserAccount { - return userAccountProvider.sharedAccount(libraryUUID: libraryAccountID) + userAccountProvider.sharedAccount(libraryUUID: libraryAccountID) } /// Updates the user account for the library we are signing in to. @@ -515,28 +578,30 @@ class TPPSignInBusinessLogic: NSObject, TPPSignedInStateProvider, TPPCurrentLibr /// Ignored if the app is built without DRM support. /// - barcode: The new barcode, if available. /// - pin: The new PIN, if barcode is provided. - /// - authToken: the token if `selectedAuthentication` is OAuth or SAML. + /// - authToken: the token if `selectedAuthentication` is OAuth or SAML. /// - patron: The patron info for OAuth / SAML authentication. /// - cookies: Cookies for SAML authentication. - func updateUserAccount(forDRMAuthorization drmSuccess: Bool, - withBarcode barcode: String?, - pin: String?, - authToken: String?, - expirationDate: Date?, - patron: [String:Any]?, - cookies: [HTTPCookie]?) { + func updateUserAccount( + forDRMAuthorization drmSuccess: Bool, + withBarcode barcode: String?, + pin: String?, + authToken: String?, + expirationDate: Date?, + patron: [String: Any]?, + cookies: [HTTPCookie]? + ) { #if FEATURE_DRM_CONNECTOR guard drmSuccess else { userAccount.removeAll() return } #endif - + guard let selectedAuthentication = selectedAuthentication else { setBarcode(barcode, pin: pin) return } - + if selectedAuthentication.isOauth || selectedAuthentication.isSaml || selectedAuthentication.isToken { if let patron { userAccount.setPatron(patron) @@ -549,14 +614,13 @@ class TPPSignInBusinessLogic: NSObject, TPPSignedInStateProvider, TPPCurrentLibr } else { setBarcode(barcode, pin: pin) } - + if selectedAuthentication.isSaml, let cookies { userAccount.setCookies(cookies) } userAccount.setAuthDefinitionWithoutUpdate(authDefinition: selectedAuthentication) - if libraryAccountID == libraryAccountsProvider.currentAccountId { bookRegistry.sync() } @@ -566,7 +630,7 @@ class TPPSignInBusinessLogic: NSObject, TPPSignedInStateProvider, TPPCurrentLibr private func setBarcode(_ barcode: String?, pin: String?) { if let barcode = barcode, let pin = pin { - userAccount.setBarcode(barcode, PIN:pin) + userAccount.setBarcode(barcode, PIN: pin) } } @@ -575,7 +639,7 @@ class TPPSignInBusinessLogic: NSObject, TPPSignedInStateProvider, TPPCurrentLibr @objc func librarySupportsBarcodeDisplay() -> Bool { // For now, only supports libraries granted access in Accounts.json, // is signed in, and has an authorization ID returned from the loans feed. - return userAccount.hasBarcodeAndPIN() && + userAccount.hasBarcodeAndPIN() && userAccount.authorizationIdentifier != nil && (selectedAuthentication?.supportsBarcodeDisplay ?? false) } @@ -589,7 +653,7 @@ class TPPSignInBusinessLogic: NSObject, TPPSignedInStateProvider, TPPCurrentLibr /// - Returns: Whether it is possible to sign up for a new account or not. @objc func registrationIsPossible() -> Bool { - return !isSignedIn() && libraryAccount?.details?.signUpUrl != nil + !isSignedIn() && libraryAccount?.details?.signUpUrl != nil } @objc func isSamlPossible() -> Bool { @@ -597,6 +661,6 @@ class TPPSignInBusinessLogic: NSObject, TPPSignedInStateProvider, TPPCurrentLibr } @objc func shouldShowEULALink() -> Bool { - return libraryAccount?.details?.getLicenseURL(.eula) != nil + libraryAccount?.details?.getLicenseURL(.eula) != nil } } diff --git a/Palace/SignInLogic/TPPSignInBusinessLogicUIDelegate.swift b/Palace/SignInLogic/TPPSignInBusinessLogicUIDelegate.swift index 9f94a6546..c72d9ade4 100644 --- a/Palace/SignInLogic/TPPSignInBusinessLogicUIDelegate.swift +++ b/Palace/SignInLogic/TPPSignInBusinessLogicUIDelegate.swift @@ -8,13 +8,15 @@ import Foundation +// MARK: - TPPSignInBusinessLogicUIDelegate + /// The functionalities on the UI that the sign-in business logic requires. @objc protocol TPPSignInBusinessLogicUIDelegate: NYPLBasicAuthCredentialsProvider, NYPLUserAccountInputProvider { /// The context in which the UI delegate is operating in, such as in a modal /// sheet or a tab. /// - Note: This should not be derived from a computation involving views, /// because it may be called outside of the main thread. - var context: String {get} + var context: String { get } /// Notifies the delegate that the process of signing in is about to begin. /// - Note: This is always called on the main thread. @@ -42,20 +44,26 @@ import Foundation /// if possible. /// - message: A user friendly message derived from the problem document /// if possible. - func businessLogic(_ logic: TPPSignInBusinessLogic, - didEncounterValidationError error: Error?, - userFriendlyErrorTitle title: String?, - andMessage message: String?) + func businessLogic( + _ logic: TPPSignInBusinessLogic, + didEncounterValidationError error: Error?, + userFriendlyErrorTitle title: String?, + andMessage message: String? + ) @objc(dismissViewControllerAnimated:completion:) func dismiss(animated flag: Bool, completion: (() -> Void)?) @objc(presentViewController:animated:completion:) - func present(_ viewControllerToPresent: UIViewController, - animated flag: Bool, - completion: (() -> Void)?) + func present( + _ viewControllerToPresent: UIViewController, + animated flag: Bool, + completion: (() -> Void)? + ) } +// MARK: - TPPSignInOutBusinessLogicUIDelegate + @objc protocol TPPSignInOutBusinessLogicUIDelegate: TPPSignInBusinessLogicUIDelegate { /// Notifies the delegate that the process of signing out is about to begin. /// - Note: This is always called on the main thread. @@ -67,9 +75,11 @@ import Foundation /// - logic: A reference to the business logic that handled the sign-out process. /// - error: The instance of the error if available. /// - httpStatusCode: The HTTP status code for the sign-out request. - func businessLogic(_ logic: TPPSignInBusinessLogic, - didEncounterSignOutError error: Error?, - withHTTPStatusCode httpStatusCode: Int) + func businessLogic( + _ logic: TPPSignInBusinessLogic, + didEncounterSignOutError error: Error?, + withHTTPStatusCode httpStatusCode: Int + ) /// Notifies the delegate that deauthorization has completed. /// - Parameter logic: The business logic in charge of signing out. diff --git a/Palace/SignInLogic/TPPUserAccountFrontEndValidation.swift b/Palace/SignInLogic/TPPUserAccountFrontEndValidation.swift index 605e30e2c..8a638e615 100644 --- a/Palace/SignInLogic/TPPUserAccountFrontEndValidation.swift +++ b/Palace/SignInLogic/TPPUserAccountFrontEndValidation.swift @@ -8,6 +8,8 @@ import UIKit +// MARK: - NYPLUserAccountInputProvider + /** Protocol that represents the input sources / UI requirements for performing front-end validation. @@ -19,23 +21,28 @@ protocol NYPLUserAccountInputProvider { var forceEditability: Bool { get } } +// MARK: - TPPUserAccountFrontEndValidation + @objcMembers class TPPUserAccountFrontEndValidation: NSObject { let account: Account private weak var businessLogic: TPPSignInBusinessLogic? private weak var userInputProvider: NYPLUserAccountInputProvider? - init(account: Account, - businessLogic: TPPSignInBusinessLogic?, - inputProvider: NYPLUserAccountInputProvider) { - + init( + account: Account, + businessLogic: TPPSignInBusinessLogic?, + inputProvider: NYPLUserAccountInputProvider + ) { self.account = account self.businessLogic = businessLogic - self.userInputProvider = inputProvider + userInputProvider = inputProvider } } +// MARK: UITextFieldDelegate + extension TPPUserAccountFrontEndValidation: UITextFieldDelegate { - func textFieldShouldBeginEditing(_ textField: UITextField) -> Bool { + func textFieldShouldBeginEditing(_: UITextField) -> Bool { if let userInputProvider = userInputProvider, userInputProvider.forceEditability { return true } @@ -43,12 +50,18 @@ extension TPPUserAccountFrontEndValidation: UITextFieldDelegate { return !(businessLogic?.userAccount.hasBarcodeAndPIN() ?? false) } - func textField(_ textField: UITextField, shouldChangeCharactersIn range: NSRange, replacementString string: String) -> Bool { - guard string.canBeConverted(to: .ascii) else { return false } + func textField( + _ textField: UITextField, + shouldChangeCharactersIn range: NSRange, + replacementString string: String + ) -> Bool { + guard string.canBeConverted(to: .ascii) else { + return false + } if textField == userInputProvider?.usernameTextField, - businessLogic?.selectedAuthentication?.patronIDKeyboard != .email { - + businessLogic?.selectedAuthentication?.patronIDKeyboard != .email + { if let text = textField.text { if range.location < 0 || range.location + range.length > text.count { return false @@ -56,7 +69,9 @@ extension TPPUserAccountFrontEndValidation: UITextFieldDelegate { let updatedText = (text as NSString).replacingCharacters(in: range, with: string) // Usernames cannot be longer than 25 characters. - guard updatedText.count <= 25 else { return false } + guard updatedText.count <= 25 else { + return false + } } } @@ -70,8 +85,8 @@ extension TPPUserAccountFrontEndValidation: UITextFieldDelegate { let passcodeLength = businessLogic?.selectedAuthentication?.authPasscodeLength ?? 0 if let text = textField.text, - let textRange = Range(range, in: text) { - + let textRange = Range(range, in: text) + { let updatedText = text.replacingCharacters(in: textRange, with: string) abovePinCharLimit = updatedText.count > passcodeLength } else { @@ -79,7 +94,9 @@ extension TPPUserAccountFrontEndValidation: UITextFieldDelegate { } // PIN's support numeric or alphanumeric. - guard alphanumericPin || !containsNonNumeric else { return false } + guard alphanumericPin || !containsNonNumeric else { + return false + } // PIN's character limit. Zero is unlimited. if passcodeLength == 0 { diff --git a/Palace/SignInLogic/TokenRequest.swift b/Palace/SignInLogic/TokenRequest.swift index 2b13d7ea9..2099b70c8 100644 --- a/Palace/SignInLogic/TokenRequest.swift +++ b/Palace/SignInLogic/TokenRequest.swift @@ -8,11 +8,13 @@ import Foundation +// MARK: - TokenResponse + @objc class TokenResponse: NSObject, Codable { @objc let accessToken: String let tokenType: String @objc let expiresIn: Int - + @objc init(accessToken: String, tokenType: String, expiresIn: Int) { self.accessToken = accessToken self.tokenType = tokenType @@ -21,36 +23,38 @@ import Foundation } @objc extension TokenResponse { - @objc var expirationDate: Date { + var expirationDate: Date { Date(timeIntervalSinceNow: Double(expiresIn)) } } +// MARK: - TokenRequest + @objc class TokenRequest: NSObject { let url: URL let username: String let password: String - + @objc init(url: URL, username: String, password: String) { self.url = url self.username = username self.password = password } - + func execute() async -> Result { var request = URLRequest(url: url, applyingCustomUserAgent: true) request.httpMethod = HTTPMethodType.POST.rawValue - + let loginString = "\(username):\(password)" guard let loginData = loginString.data(using: .utf8) else { return .failure(URLError(.badURL)) } let base64LoginString = loginData.base64EncodedString() request.addValue("Basic \(base64LoginString)", forHTTPHeaderField: "Authorization") - + do { let (data, _) = try await URLSession.shared.data(for: request) - + let decoder = JSONDecoder() decoder.keyDecodingStrategy = .convertFromSnakeCase let tokenResponse = try decoder.decode(TokenResponse.self, from: data) @@ -66,9 +70,9 @@ extension TokenRequest { Task { let result = await execute() switch result { - case .success(let tokenResponse): + case let .success(tokenResponse): completion(tokenResponse, nil) - case .failure(let error): + case let .failure(error): completion(nil, error) } } diff --git a/Palace/SignInLogic/URLResponse+TPPAuthentication.swift b/Palace/SignInLogic/URLResponse+TPPAuthentication.swift index 566623012..23455fbe2 100644 --- a/Palace/SignInLogic/URLResponse+TPPAuthentication.swift +++ b/Palace/SignInLogic/URLResponse+TPPAuthentication.swift @@ -9,7 +9,6 @@ import Foundation extension URLResponse { - /// Attempts to determine if the response indicates that the user's /// credentials are expired or invalid. /// @@ -26,14 +25,13 @@ extension URLResponse { /// authentication needs to be refreshed. @objc(indicatesAuthenticationNeedsRefresh:) func indicatesAuthenticationNeedsRefresh(with problemDoc: TPPProblemDocument?) -> Bool { - return isProblemDocument() && problemDoc?.type == TPPProblemDocument.TypeInvalidCredentials + isProblemDocument() && problemDoc?.type == TPPProblemDocument.TypeInvalidCredentials } } extension HTTPURLResponse { @objc(indicatesAuthenticationNeedsRefresh:) override func indicatesAuthenticationNeedsRefresh(with problemDoc: TPPProblemDocument?) -> Bool { - if super.indicatesAuthenticationNeedsRefresh(with: problemDoc) { return true } diff --git a/Palace/Utilities/Concurrency/TPPBackgroundExecutor.swift b/Palace/Utilities/Concurrency/TPPBackgroundExecutor.swift index 6a509e114..5b1cb206f 100644 --- a/Palace/Utilities/Concurrency/TPPBackgroundExecutor.swift +++ b/Palace/Utilities/Concurrency/TPPBackgroundExecutor.swift @@ -6,8 +6,10 @@ // Copyright © 2020 NYPL Labs. All rights reserved. // -import UIKit import Dispatch +import UIKit + +// MARK: - NYPLBackgroundWorkOwner /** Protocol that should be implemented by a class that wants to schedule work @@ -54,6 +56,8 @@ import Dispatch func performBackgroundWork() } +// MARK: - TPPBackgroundExecutor + /** This class wraps the logic of initiating and ending a background task on behalf of a `owner` in a thread-safe manner. @@ -68,8 +72,8 @@ import Dispatch private let queue = DispatchQueue.global(qos: .background) private let endLock = NSLock() private var isEndingTask = false - - //---------------------------------------------------------------------------- + + // ---------------------------------------------------------------------------- /// - Parameters: /// - owner: The object wanting to run an expensive task in the background. @@ -79,8 +83,8 @@ import Dispatch self.owner = owner super.init() } - - //---------------------------------------------------------------------------- + + // ---------------------------------------------------------------------------- /// The owner needs to call this function to perform in the background the /// work specified in `NYPLBackgroundWorkOwner::performBackgroundWork`. @@ -93,32 +97,34 @@ import Dispatch func endTaskIfNeeded(context: String) { endQueue.async { [weak self] in - guard let self = self else { return } - - self.endLock.lock() + guard let self = self else { + return + } + + endLock.lock() defer { self.endLock.unlock() } - + // Prevent multiple end task calls - if self.isEndingTask { + if isEndingTask { return } - self.isEndingTask = true - + isEndingTask = true + let timeRemaining: TimeInterval = DispatchQueue.main.sync { UIApplication.shared.backgroundTimeRemaining } Log.info(#file, """ - \(context) \(self.taskName) background task \(bgTask.rawValue). \ - Time remaining: \(timeRemaining) - """) + \(context) \(taskName) background task \(bgTask.rawValue). \ + Time remaining: \(timeRemaining) + """) if bgTask != .invalid { UIApplication.shared.endBackgroundTask(bgTask) bgTask = .invalid } - - self.isEndingTask = false + + isEndingTask = false } } @@ -138,8 +144,10 @@ import Dispatch endTaskIfNeeded(context: "Finishing up") }) else { - Log.warn(#file, - "No work item for \(self.taskName) background task \(bgTask.rawValue)!") + Log.warn( + #file, + "No work item for \(self.taskName) background task \(bgTask.rawValue)!" + ) endTaskIfNeeded(context: "No work item") return } diff --git a/Palace/Utilities/Concurrency/TPPMainThreadChecker.swift b/Palace/Utilities/Concurrency/TPPMainThreadChecker.swift index 21a0fb83d..71f16acd9 100644 --- a/Palace/Utilities/Concurrency/TPPMainThreadChecker.swift +++ b/Palace/Utilities/Concurrency/TPPMainThreadChecker.swift @@ -6,11 +6,10 @@ // Copyright © 2020 NYPL Labs. All rights reserved. // -import Foundation import Dispatch +import Foundation @objc class TPPMainThreadRun: NSObject { - /// Makes sure to run the specified work item synchronously on the /// main __thread__. /// - Note: If the caller was already executing on the main thread, @@ -48,4 +47,3 @@ import Dispatch } } } - diff --git a/Palace/Utilities/Date-Time/Date+NYPLAdditions.swift b/Palace/Utilities/Date-Time/Date+NYPLAdditions.swift index 8bfd011ad..4f4199d92 100644 --- a/Palace/Utilities/Date-Time/Date+NYPLAdditions.swift +++ b/Palace/Utilities/Date-Time/Date+NYPLAdditions.swift @@ -8,6 +8,8 @@ import Foundation +// MARK: - NYPLDateType + enum NYPLDateType: String { case year case month @@ -16,13 +18,14 @@ enum NYPLDateType: String { case hour } +// MARK: - NYPLDateSuffixType + public enum NYPLDateSuffixType { case long case short } public extension Date { - /// A static date formatter to get date strings formatted per RFC 1123 /// without incurring in the high cost of creating a new DateFormatter /// each time, which would be ~300% more expensive. @@ -38,9 +41,9 @@ public extension Date { /// header field (such as the `Expires` header in a HTTP response). /// Example: Wed, 25 Mar 2020 01:23:45 GMT var rfc1123String: String { - return Date.rfc1123DateFormatter.string(from: self) + Date.rfc1123DateFormatter.string(from: self) } - + /// A static date formatter to get date strings formatted per RFC 339 /// without incurring in the high cost of creating a new DateFormatter /// each time, which would be ~300% more expensive. @@ -51,15 +54,15 @@ public extension Date { df.dateFormat = "yyyy'-'MM'-'dd'T'HH':'mm':'ss'Z'" return df }() - + var rfc339String: String { - return Date.rfc339StringFormatter.string(from: self) + Date.rfc339StringFormatter.string(from: self) } - + /// A date string with the choice of short or long suffix /// Example: 5 years / 5 y / 6 months / 1 day func timeUntilString(suffixType: NYPLDateSuffixType) -> String { - var seconds = self.timeIntervalSince(Date()) + var seconds = timeIntervalSince(Date()) seconds = max(seconds, 0) let minutes = floor(seconds / 60) let hours = floor(minutes / 60) @@ -67,26 +70,41 @@ public extension Date { let weeks = floor(days / 7) let months = floor(days / 30) let years = floor(days / 365) - - if(years >= 4) { + + if years >= 4 { // Switch to years after ~48 months. - return String.localizedStringWithFormat(dateSuffix(dateType: .year, suffixType: suffixType, isPlural: true), Int(years)) - } else if(months >= 4) { + return String.localizedStringWithFormat( + dateSuffix(dateType: .year, suffixType: suffixType, isPlural: true), + Int(years) + ) + } else if months >= 4 { // Switch to months after ~16 weeks. - return String.localizedStringWithFormat(dateSuffix(dateType: .month, suffixType: suffixType, isPlural: true), Int(months)) - } else if(weeks >= 4) { + return String.localizedStringWithFormat( + dateSuffix(dateType: .month, suffixType: suffixType, isPlural: true), + Int(months) + ) + } else if weeks >= 4 { // Switch to weeks after 28 days. - return String.localizedStringWithFormat(dateSuffix(dateType: .week, suffixType: suffixType, isPlural: true), Int(weeks)) - } else if(days >= 2) { + return String.localizedStringWithFormat( + dateSuffix(dateType: .week, suffixType: suffixType, isPlural: true), + Int(weeks) + ) + } else if days >= 2 { // Switch to days after 48 hours. - return String.localizedStringWithFormat(dateSuffix(dateType: .day, suffixType: suffixType, isPlural: true), Int(days)) + return String.localizedStringWithFormat( + dateSuffix(dateType: .day, suffixType: suffixType, isPlural: true), + Int(days) + ) } else { // Use hours. - return String.localizedStringWithFormat(dateSuffix(dateType: .hour, suffixType: suffixType, isPlural: hours != 1), Int(hours)) + return String.localizedStringWithFormat( + dateSuffix(dateType: .hour, suffixType: suffixType, isPlural: hours != 1), + Int(hours) + ) } } - - private func dateSuffix(dateType: NYPLDateType, suffixType: NYPLDateSuffixType, isPlural: Bool) -> String { + + private func dateSuffix(dateType: NYPLDateType, suffixType: NYPLDateSuffixType, isPlural _: Bool) -> String { if suffixType == .short { return NSLocalizedString("\(dateType.rawValue)_suffix_short", comment: "Date Suffix (Short)") } @@ -96,6 +114,6 @@ public extension Date { @objc extension NSDate { func longTimeUntilString() -> String { - return (self as Date).timeUntilString(suffixType: .long) + (self as Date).timeUntilString(suffixType: .long) } } diff --git a/Palace/Utilities/EmailAddress.swift b/Palace/Utilities/EmailAddress.swift index 6dc7b3358..9ea4f009c 100644 --- a/Palace/Utilities/EmailAddress.swift +++ b/Palace/Utilities/EmailAddress.swift @@ -17,11 +17,11 @@ import Foundation let detector = try? NSDataDetector(types: NSTextCheckingResult.CheckingType.link.rawValue) let range = NSRange(sanitizedString.startIndex.. Element? { get { - return indices.contains(index) ? self[index] : nil + indices.contains(index) ? self[index] : nil } set { if indices.contains(index), let value = newValue { diff --git a/Palace/Utilities/Extensions/Array+SafeAccess.swift b/Palace/Utilities/Extensions/Array+SafeAccess.swift index 6e2f6c19c..8ce5a8c31 100644 --- a/Palace/Utilities/Extensions/Array+SafeAccess.swift +++ b/Palace/Utilities/Extensions/Array+SafeAccess.swift @@ -2,21 +2,20 @@ import Foundation extension Array { subscript(safe index: Index) -> Element? { - return indices.contains(index) ? self[index] : nil + indices.contains(index) ? self[index] : nil } - + func safePrefix(_ maxLength: Int) -> ArraySlice { - return prefix(Swift.min(maxLength, count)) + prefix(Swift.min(maxLength, count)) } - + func first(default defaultValue: Element) -> Element { - return first ?? defaultValue + first ?? defaultValue } } extension Collection { subscript(safe index: Index) -> Element? { - return indices.contains(index) ? self[index] : nil + indices.contains(index) ? self[index] : nil } } - diff --git a/Palace/Utilities/Extensions/Date+Extensions.swift b/Palace/Utilities/Extensions/Date+Extensions.swift index 0957672b2..48985f726 100644 --- a/Palace/Utilities/Extensions/Date+Extensions.swift +++ b/Palace/Utilities/Extensions/Date+Extensions.swift @@ -1,5 +1,4 @@ extension Date { - /// Returns the date formatted as "October 19, 2021". var monthDayYearString: String { let dateFormatter = DateFormatter() diff --git a/Palace/Utilities/Extensions/Dictionary+Extensions.swift b/Palace/Utilities/Extensions/Dictionary+Extensions.swift index 9fa271130..6acdca4f3 100644 --- a/Palace/Utilities/Extensions/Dictionary+Extensions.swift +++ b/Palace/Utilities/Extensions/Dictionary+Extensions.swift @@ -9,8 +9,8 @@ import Foundation extension Dictionary { - func mapKeys(_ transform: (Key) throws -> T) rethrows -> Dictionary { - var dictionary = Dictionary() + func mapKeys(_ transform: (Key) throws -> T) rethrows -> [T: Value] { + var dictionary = [T: Value]() for (key, value) in self { dictionary[try transform(key)] = value } @@ -19,7 +19,6 @@ extension Dictionary { } extension Dictionary where Key == String, Value: Any { - /// Pretty prints the JSON dictionary func prettyPrintJSON() { do { diff --git a/Palace/Utilities/Extensions/Float+TPPAdditions.swift b/Palace/Utilities/Extensions/Float+TPPAdditions.swift index 63108e144..6d175ee82 100644 --- a/Palace/Utilities/Extensions/Float+TPPAdditions.swift +++ b/Palace/Utilities/Extensions/Float+TPPAdditions.swift @@ -8,10 +8,9 @@ import Foundation -infix operator =~= : ComparisonPrecedence +infix operator =~=: ComparisonPrecedence extension Float { - /// Performs equality check minus an epsilon /// - Returns: `true` if the numbers differ by less than the epsilon, /// `false` otherwise. @@ -24,7 +23,6 @@ extension Float { } func roundTo(decimalPlaces: Int) -> String { - return String(format: "%.\(decimalPlaces)f%%", self) as String + String(format: "%.\(decimalPlaces)f%%", self) as String } } - diff --git a/Palace/Utilities/Extensions/Image+Extension.swift b/Palace/Utilities/Extensions/Image+Extension.swift index fa4044148..67e86115b 100644 --- a/Palace/Utilities/Extensions/Image+Extension.swift +++ b/Palace/Utilities/Extensions/Image+Extension.swift @@ -2,7 +2,7 @@ import SwiftUI extension Image { func toUIImage() -> UIImage? { - let controller = UIHostingController(rootView: self.resizable()) + let controller = UIHostingController(rootView: resizable()) let view = controller.view view?.bounds = CGRect(x: 0, y: 0, width: 300, height: 300) // Adjust size as needed @@ -10,7 +10,7 @@ extension Image { let renderer = UIGraphicsImageRenderer(size: view?.bounds.size ?? .zero) - return renderer.image { context in + return renderer.image { _ in view?.drawHierarchy(in: view?.bounds ?? .zero, afterScreenUpdates: true) } } diff --git a/Palace/Utilities/Extensions/KeyboardModifier.swift b/Palace/Utilities/Extensions/KeyboardModifier.swift index b16cb5beb..ddb0bb8cd 100644 --- a/Palace/Utilities/Extensions/KeyboardModifier.swift +++ b/Palace/Utilities/Extensions/KeyboardModifier.swift @@ -5,6 +5,8 @@ public func dismissKeyboard() { UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil) } +// MARK: - DismissKeyboardOnTap + public struct DismissKeyboardOnTap: ViewModifier { public let onDismiss: (() -> Void)? diff --git a/Palace/Utilities/Extensions/UIColor+Extensions.swift b/Palace/Utilities/Extensions/UIColor+Extensions.swift index e4d481867..821e52348 100644 --- a/Palace/Utilities/Extensions/UIColor+Extensions.swift +++ b/Palace/Utilities/Extensions/UIColor+Extensions.swift @@ -1,11 +1,11 @@ import UIKit -extension UIColor { - @objc class public func defaultLabelColor() -> UIColor { +public extension UIColor { + @objc class func defaultLabelColor() -> UIColor { if #available(iOS 13, *) { - return UIColor.label; + UIColor.label } else { - return UIColor.black; + UIColor.black } } } diff --git a/Palace/Utilities/Extensions/UIDevice+Extension.swift b/Palace/Utilities/Extensions/UIDevice+Extension.swift index 3dc0968f3..8d32b3124 100644 --- a/Palace/Utilities/Extensions/UIDevice+Extension.swift +++ b/Palace/Utilities/Extensions/UIDevice+Extension.swift @@ -2,10 +2,10 @@ import UIKit extension UIDevice { var isIpad: Bool { - return userInterfaceIdiom == .pad + userInterfaceIdiom == .pad } var isIphone: Bool { - return userInterfaceIdiom == .phone + userInterfaceIdiom == .phone } } diff --git a/Palace/Utilities/Extensions/UIViewController+Extensions.swift b/Palace/Utilities/Extensions/UIViewController+Extensions.swift index eeaf8b722..878df7477 100644 --- a/Palace/Utilities/Extensions/UIViewController+Extensions.swift +++ b/Palace/Utilities/Extensions/UIViewController+Extensions.swift @@ -2,10 +2,10 @@ import UIKit extension UIViewController { @objc func dismissSelf() { - if let navigationController = self.navigationController, navigationController.presentingViewController != nil { + if let navigationController = navigationController, navigationController.presentingViewController != nil { navigationController.dismiss(animated: true) } else { - self.dismiss(animated: true) + dismiss(animated: true) } } } @@ -18,11 +18,13 @@ extension UIViewController { return presented.topMostViewController } if let nav = self as? UINavigationController, - let visible = nav.visibleViewController { + let visible = nav.visibleViewController + { return visible.topMostViewController } if let tab = self as? UITabBarController, - let selected = tab.selectedViewController { + let selected = tab.selectedViewController + { return selected.topMostViewController } return self diff --git a/Palace/Utilities/Extensions/View+Extensions.swift b/Palace/Utilities/Extensions/View+Extensions.swift index d754913a5..34a5e7a3c 100644 --- a/Palace/Utilities/Extensions/View+Extensions.swift +++ b/Palace/Utilities/Extensions/View+Extensions.swift @@ -13,7 +13,7 @@ extension View { func anyView() -> AnyView { AnyView(self) } - + func verticallyCentered() -> some View { VStack { Spacer() @@ -21,7 +21,7 @@ extension View { Spacer() } } - + func horizontallyCentered() -> some View { HStack { Spacer() @@ -29,7 +29,7 @@ extension View { Spacer() } } - + func bottomrRightJustified() -> some View { VStack { Spacer() @@ -41,7 +41,7 @@ extension View { } func square(length: CGFloat) -> some View { - self.frame(width: length, height: length) + frame(width: length, height: length) } func refreshable(_ refreshAction: @escaping Action) -> some View { diff --git a/Palace/Utilities/ImageCache/GeneralCache.swift b/Palace/Utilities/ImageCache/GeneralCache.swift index 3bfc4bda5..b8d6666f4 100644 --- a/Palace/Utilities/ImageCache/GeneralCache.swift +++ b/Palace/Utilities/ImageCache/GeneralCache.swift @@ -1,5 +1,7 @@ -import Foundation import CryptoKit +import Foundation + +// MARK: - CachingMode public enum CachingMode { case memoryOnly @@ -8,6 +10,8 @@ public enum CachingMode { case none } +// MARK: - CachePolicy + public enum CachePolicy { case cacheFirst case networkFirst @@ -16,13 +20,15 @@ public enum CachePolicy { case noCache } +// MARK: - GeneralCache + public final class GeneralCache { private let memoryCache = NSCache() private let fileManager = FileManager.default private let cacheDirectory: URL private let queue = DispatchQueue(label: "com.Palace.GeneralCache", attributes: .concurrent) private let mode: CachingMode - + private final class Entry: Codable { let value: Value let expiration: Date? @@ -30,29 +36,34 @@ public final class GeneralCache { self.value = value self.expiration = expiration } + var isExpired: Bool { - if let exp = expiration { return exp < Date() } + if let exp = expiration { + return exp < Date() + } return false } } - + private final class WrappedKey: NSObject { let key: Key init(_ key: Key) { self.key = key } override var hash: Int { key.hashValue } override func isEqual(_ object: Any?) -> Bool { - guard let other = object as? WrappedKey else { return false } + guard let other = object as? WrappedKey else { + return false + } return other.key == key } } - + public init(cacheName: String = "GeneralCache", mode: CachingMode = .memoryAndDisk) { self.mode = mode let cachesDir = fileManager.urls(for: .cachesDirectory, in: .userDomainMask).first! cacheDirectory = cachesDir.appendingPathComponent(cacheName, isDirectory: true) try? fileManager.createDirectory(at: cacheDirectory, withIntermediateDirectories: true) } - + public func set(_ value: Value, for key: Key, expiresIn interval: TimeInterval? = nil) { let expirationDate = interval.map { Date().addingTimeInterval($0) } let entry = Entry(value: value, expiration: expirationDate) @@ -66,15 +77,18 @@ public final class GeneralCache { } } } - + public func get(for key: Key) -> Value? { - return queue.sync { + queue.sync { let wrappedKey = WrappedKey(key) - if (mode == .memoryOnly || mode == .memoryAndDisk), - let entry = memoryCache.object(forKey: wrappedKey), !entry.isExpired { + if mode == .memoryOnly || mode == .memoryAndDisk, + let entry = memoryCache.object(forKey: wrappedKey), !entry.isExpired + { return entry.value } - if mode == .memoryOnly { return nil } + if mode == .memoryOnly { + return nil + } let url = fileURL(for: key) do { let attrs = try fileManager.attributesOfItem(atPath: url.path) @@ -108,14 +122,18 @@ public final class GeneralCache { } } } - + @discardableResult - public func get(_ key: Key, - policy: CachePolicy, - fetcher: @escaping () async throws -> Value) async throws -> Value { + public func get( + _ key: Key, + policy: CachePolicy, + fetcher: @escaping () async throws -> Value + ) async throws -> Value { switch policy { case .cacheFirst: - if let cached = get(for: key) { return cached } + if let cached = get(for: key) { + return cached + } fallthrough case .networkFirst: do { @@ -123,7 +141,9 @@ public final class GeneralCache { set(fresh, for: key) return fresh } catch { - if let cached = get(for: key) { return cached } + if let cached = get(for: key) { + return cached + } throw error } case .cacheThenNetwork: @@ -139,7 +159,7 @@ public final class GeneralCache { set(fresh, for: key) return fresh } - case .timedCache(let interval): + case let .timedCache(interval): if let cached = get(for: key) { return cached } @@ -150,7 +170,7 @@ public final class GeneralCache { return try await fetcher() } } - + public func remove(for key: Key) { let wrappedKey = WrappedKey(key) queue.sync(flags: .barrier) { @@ -162,50 +182,56 @@ public final class GeneralCache { } } } - + public func clear() { queue.sync(flags: .barrier) { if mode == .memoryOnly || mode == .memoryAndDisk { memoryCache.removeAllObjects() } if mode == .diskOnly || mode == .memoryAndDisk { - (try? fileManager.contentsOfDirectory(at: cacheDirectory, - includingPropertiesForKeys: nil))? + (try? fileManager.contentsOfDirectory( + at: cacheDirectory, + includingPropertiesForKeys: nil + ))? .forEach { try? fileManager.removeItem(at: $0) } } } } - + public func clearMemory() { queue.sync(flags: .barrier) { memoryCache.removeAllObjects() } } - + private func saveToDisk(_ entry: Entry, for key: Key) { let url = fileURL(for: key) do { - let raw: Data - if Value.self == Data.self, let d = entry.value as? Data { - raw = d + let raw: Data = if Value.self == Data.self, let d = entry.value as? Data { + d } else { - raw = try JSONEncoder().encode(entry) + try JSONEncoder().encode(entry) } try raw.write(to: url, options: .atomic) if let exp = entry.expiration { - try fileManager.setAttributes([.modificationDate: exp], - ofItemAtPath: url.path) + try fileManager.setAttributes( + [.modificationDate: exp], + ofItemAtPath: url.path + ) } } catch { print("Cache disk write failed: \(error)") } } - + public func fileURL(for key: Key) -> URL { let name: String if let str = key as? String { - name = str.replacingOccurrences(of: "[^a-zA-Z0-9_-]", with: "", - options: .regularExpression) + name = str.replacingOccurrences( + of: "[^a-zA-Z0-9_-]", + with: "", + options: .regularExpression + ) } else { let data = try? JSONEncoder().encode(key) let hash = data.map { SHA256.hash(data: $0).compactMap { @@ -215,7 +241,7 @@ public final class GeneralCache { } return cacheDirectory.appendingPathComponent(name) } - + public static func clearAllCaches() { let fileManager = FileManager.default let cachesDir = fileManager.urls(for: .cachesDirectory, in: .userDomainMask).first! @@ -225,39 +251,39 @@ public final class GeneralCache { // Preserve Adobe DRM directories and critical system data let filename = url.lastPathComponent.lowercased() let fullPath = url.path.lowercased() - let shouldPreserve = filename.contains("adobe") || - filename.contains("adept") || - filename.contains("drm") || - filename.contains("activation") || - filename.contains("device") || - filename.hasPrefix("com.adobe") || - filename.hasPrefix("acsm") || - filename.contains("rights") || - filename.contains("license") || - fullPath.contains("adobe") || - fullPath.contains("adept") || - fullPath.contains("/drm/") || - fullPath.contains("deviceprovider") || - fullPath.contains("authorization") - + let shouldPreserve = filename.contains("adobe") || + filename.contains("adept") || + filename.contains("drm") || + filename.contains("activation") || + filename.contains("device") || + filename.hasPrefix("com.adobe") || + filename.hasPrefix("acsm") || + filename.contains("rights") || + filename.contains("license") || + fullPath.contains("adobe") || + fullPath.contains("adept") || + fullPath.contains("/drm/") || + fullPath.contains("deviceprovider") || + fullPath.contains("authorization") + if shouldPreserve { NSLog("[GeneralCache] Preserving Adobe DRM directory: \(filename)") continue } - + try? fileManager.removeItem(at: url) } } catch { NSLog("[GeneralCache] Failed to clear caches: \(error)") } } - + public static func clearCacheOnUpdate() { let cacheVersionKey = "AppCacheVersionBuild" let info = Bundle.main.infoDictionary let version = info?["CFBundleShortVersionString"] as? String ?? "0" - let build = info?["CFBundleVersion"] as? String ?? "0" + let build = info?["CFBundleVersion"] as? String ?? "0" let versionBuild = "\(version) (\(build))" @@ -268,4 +294,5 @@ public final class GeneralCache { Self.clearAllCaches() defaults.set(versionBuild, forKey: cacheVersionKey) } - }} + } +} diff --git a/Palace/Utilities/ImageCache/ImageCacheType.swift b/Palace/Utilities/ImageCache/ImageCacheType.swift index d074911dd..024480d2f 100644 --- a/Palace/Utilities/ImageCache/ImageCacheType.swift +++ b/Palace/Utilities/ImageCache/ImageCacheType.swift @@ -1,130 +1,144 @@ import UIKit +// MARK: - ImageCacheType + public protocol ImageCacheType { - func set(_ image: UIImage, for key: String, expiresIn: TimeInterval?) - func get(for key: String) -> UIImage? - func remove(for key: String) - func clear() + func set(_ image: UIImage, for key: String, expiresIn: TimeInterval?) + func get(for key: String) -> UIImage? + func remove(for key: String) + func clear() } public extension ImageCacheType { - func set(_ image: UIImage, for key: String) { - let sevenDays: TimeInterval = 7 * 24 * 60 * 60 - set(image, for: key, expiresIn: sevenDays) - } + func set(_ image: UIImage, for key: String) { + let sevenDays: TimeInterval = 7 * 24 * 60 * 60 + set(image, for: key, expiresIn: sevenDays) + } } +// MARK: - ImageCache + public final class ImageCache: ImageCacheType { - public static let shared = ImageCache() - - private let dataCache = GeneralCache(cacheName: "ImageCache", mode: .memoryAndDisk) - private let memoryImages = NSCache() - private let defaultTTL: TimeInterval = 14 * 24 * 60 * 60 - private let maxDimension: CGFloat = 1024 - private let compressionQuality: CGFloat = 0.7 - - private init() { - let deviceMemoryMB = ProcessInfo.processInfo.physicalMemory / (1024 * 1024) - let cacheMemoryMB: Int - - if deviceMemoryMB < 2048 { - cacheMemoryMB = 25 - memoryImages.countLimit = 100 - } else if deviceMemoryMB < 4096 { - cacheMemoryMB = 40 - memoryImages.countLimit = 150 - } else { - cacheMemoryMB = 60 - memoryImages.countLimit = 200 - } - - memoryImages.totalCostLimit = cacheMemoryMB * 1024 * 1024 - - NotificationCenter.default.addObserver( - self, - selector: #selector(handleMemoryWarning), - name: UIApplication.didReceiveMemoryWarningNotification, - object: nil - ) - - NotificationCenter.default.addObserver( - self, - selector: #selector(handleMemoryPressure), - name: UIApplication.didReceiveMemoryWarningNotification, - object: nil - ) - } - - @objc private func handleMemoryPressure() { - let currentCount = memoryImages.countLimit - memoryImages.countLimit = max(50, currentCount / 2) - memoryImages.totalCostLimit = memoryImages.totalCostLimit / 2 - - DispatchQueue.main.asyncAfter(deadline: .now() + 30) { [weak self] in - self?.memoryImages.countLimit = currentCount - } + public static let shared = ImageCache() + + private let dataCache = GeneralCache(cacheName: "ImageCache", mode: .memoryAndDisk) + private let memoryImages = NSCache() + private let defaultTTL: TimeInterval = 14 * 24 * 60 * 60 + private let maxDimension: CGFloat = 1024 + private let compressionQuality: CGFloat = 0.7 + + private init() { + let deviceMemoryMB = ProcessInfo.processInfo.physicalMemory / (1024 * 1024) + let cacheMemoryMB: Int + + if deviceMemoryMB < 2048 { + cacheMemoryMB = 25 + memoryImages.countLimit = 100 + } else if deviceMemoryMB < 4096 { + cacheMemoryMB = 40 + memoryImages.countLimit = 150 + } else { + cacheMemoryMB = 60 + memoryImages.countLimit = 200 } - @objc private func handleMemoryWarning() { - memoryImages.removeAllObjects() - dataCache.clearMemory() + memoryImages.totalCostLimit = cacheMemoryMB * 1024 * 1024 + + NotificationCenter.default.addObserver( + self, + selector: #selector(handleMemoryWarning), + name: UIApplication.didReceiveMemoryWarningNotification, + object: nil + ) + + NotificationCenter.default.addObserver( + self, + selector: #selector(handleMemoryPressure), + name: UIApplication.didReceiveMemoryWarningNotification, + object: nil + ) + } + + @objc private func handleMemoryPressure() { + let currentCount = memoryImages.countLimit + memoryImages.countLimit = max(50, currentCount / 2) + memoryImages.totalCostLimit = memoryImages.totalCostLimit / 2 + + DispatchQueue.main.asyncAfter(deadline: .now() + 30) { [weak self] in + self?.memoryImages.countLimit = currentCount } + } + + @objc private func handleMemoryWarning() { + memoryImages.removeAllObjects() + dataCache.clearMemory() + } - public func set(_ image: UIImage, for key: String, expiresIn: TimeInterval? = nil) { - let ttl = expiresIn ?? defaultTTL - let processed = resize(image, maxDimension: maxDimension) - let cost = imageCost(processed) - memoryImages.setObject(processed, forKey: key as NSString, cost: cost) - DispatchQueue.global(qos: .utility).async { - guard let data = processed.jpegData(compressionQuality: self.compressionQuality) else { return } - self.dataCache.set(data, for: key, expiresIn: ttl) - } + public func set(_ image: UIImage, for key: String, expiresIn: TimeInterval? = nil) { + let ttl = expiresIn ?? defaultTTL + let processed = resize(image, maxDimension: maxDimension) + let cost = imageCost(processed) + memoryImages.setObject(processed, forKey: key as NSString, cost: cost) + DispatchQueue.global(qos: .utility).async { + guard let data = processed.jpegData(compressionQuality: self.compressionQuality) else { + return + } + self.dataCache.set(data, for: key, expiresIn: ttl) } + } - public func get(for key: String) -> UIImage? { - if let img = memoryImages.object(forKey: key as NSString) { - return img - } - - guard let data = dataCache.get(for: key) else { return nil } - - guard let img = UIImage(data: data) else { - remove(for: key) - return nil - } - let cost = imageCost(img) - memoryImages.setObject(img, forKey: key as NSString, cost: cost) - return img + public func get(for key: String) -> UIImage? { + if let img = memoryImages.object(forKey: key as NSString) { + return img } - public func remove(for key: String) { - memoryImages.removeObject(forKey: key as NSString) - dataCache.remove(for: key) + guard let data = dataCache.get(for: key) else { + return nil } - public func clear() { - memoryImages.removeAllObjects() - dataCache.clear() + guard let img = UIImage(data: data) else { + remove(for: key) + return nil } + let cost = imageCost(img) + memoryImages.setObject(img, forKey: key as NSString, cost: cost) + return img + } - private func resize(_ image: UIImage, maxDimension: CGFloat) -> UIImage { - let size = image.size - guard size.width > 0 && size.height > 0 else { return image } - let maxSide = max(size.width, size.height) - if maxSide <= maxDimension { return image } - let scale = maxDimension / maxSide - let newSize = CGSize(width: size.width * scale, height: size.height * scale) - let format = UIGraphicsImageRendererFormat() - format.scale = 1 - format.opaque = false - let renderer = UIGraphicsImageRenderer(size: newSize, format: format) - return renderer.image { _ in - image.draw(in: CGRect(origin: .zero, size: newSize)) - } + public func remove(for key: String) { + memoryImages.removeObject(forKey: key as NSString) + dataCache.remove(for: key) + } + + public func clear() { + memoryImages.removeAllObjects() + dataCache.clear() + } + + private func resize(_ image: UIImage, maxDimension: CGFloat) -> UIImage { + let size = image.size + guard size.width > 0 && size.height > 0 else { + return image + } + let maxSide = max(size.width, size.height) + if maxSide <= maxDimension { + return image + } + let scale = maxDimension / maxSide + let newSize = CGSize(width: size.width * scale, height: size.height * scale) + let format = UIGraphicsImageRendererFormat() + format.scale = 1 + format.opaque = false + let renderer = UIGraphicsImageRenderer(size: newSize, format: format) + return renderer.image { _ in + image.draw(in: CGRect(origin: .zero, size: newSize)) } + } - private func imageCost(_ image: UIImage) -> Int { - guard let cg = image.cgImage else { return 1 } - return cg.bytesPerRow * cg.height + private func imageCost(_ image: UIImage) -> Int { + guard let cg = image.cgImage else { + return 1 } + return cg.bytesPerRow * cg.height + } } diff --git a/Palace/Utilities/JWK/JWK.swift b/Palace/Utilities/JWK/JWK.swift index 23800fe91..e4f8e1fcd 100644 --- a/Palace/Utilities/JWK/JWK.swift +++ b/Palace/Utilities/JWK/JWK.swift @@ -11,40 +11,39 @@ limitations under the License. */ - import Foundation public struct JWK: Codable { - var kty: String // key type - var id: Int? // key id - var use: String? // key usage - var alg: String? // Algorithm - - var x5u: String? // (X.509 URL) Header Parameter - var x5t: String? // (X.509 Certificate Thumbprint) Header Parameter - var x5c: String? // (X.509 Certificate Chain) Header Parameter - - // RSA keys - // Represented as the base64url encoding of the value’s unsigned big endian representation as an octet sequence. - var n: String? // modulus - var e: String? // exponent - - var d: String? // private exponent - var p: String? // first prime factor - var q: String? // second prime factor - var dp: String? // first factor CRT exponent - var dq: String? // second factor CRT exponent - var qi: String? // first CRT coefficient - var oth: othType? // other primes info - - // EC DSS keys - var crv: String? - var x: String? - var y: String? - - enum othType: String, Codable { - case r - case d - case t - } + var kty: String // key type + var id: Int? // key id + var use: String? // key usage + var alg: String? // Algorithm + + var x5u: String? // (X.509 URL) Header Parameter + var x5t: String? // (X.509 Certificate Thumbprint) Header Parameter + var x5c: String? // (X.509 Certificate Chain) Header Parameter + + // RSA keys + // Represented as the base64url encoding of the value’s unsigned big endian representation as an octet sequence. + var n: String? // modulus + var e: String? // exponent + + var d: String? // private exponent + var p: String? // first prime factor + var q: String? // second prime factor + var dp: String? // first factor CRT exponent + var dq: String? // second factor CRT exponent + var qi: String? // first CRT coefficient + var oth: othType? // other primes info + + // EC DSS keys + var crv: String? + var x: String? + var y: String? + + enum othType: String, Codable { + case r + case d + case t + } } diff --git a/Palace/Utilities/Localization/Data+Base64.swift b/Palace/Utilities/Localization/Data+Base64.swift index 24e6ec7cc..9aaab7039 100644 --- a/Palace/Utilities/Localization/Data+Base64.swift +++ b/Palace/Utilities/Localization/Data+Base64.swift @@ -1,8 +1,8 @@ import Foundation -extension Data { - public func base64EncodedStringUrlSafe() -> String { - return self.base64EncodedString() +public extension Data { + func base64EncodedStringUrlSafe() -> String { + base64EncodedString() .replacingOccurrences(of: "+", with: "-") .replacingOccurrences(of: "/", with: "_") .replacingOccurrences(of: "\n", with: "") diff --git a/Palace/Utilities/Localization/NSString+JSONParse.swift b/Palace/Utilities/Localization/NSString+JSONParse.swift index 82f258c32..df41c8b84 100644 --- a/Palace/Utilities/Localization/NSString+JSONParse.swift +++ b/Palace/Utilities/Localization/NSString+JSONParse.swift @@ -9,14 +9,15 @@ import Foundation extension NSString { - @objc var parseJSONString: AnyObject? { - - let data = self.data(using: String.Encoding.utf8.rawValue, allowLossyConversion: false) + let data = data(using: String.Encoding.utf8.rawValue, allowLossyConversion: false) if let jsonData = data { // Will return an object or nil if JSON decoding fails - return try! JSONSerialization.jsonObject(with: jsonData, options: JSONSerialization.ReadingOptions.mutableContainers) as AnyObject? + return try! JSONSerialization.jsonObject( + with: jsonData, + options: JSONSerialization.ReadingOptions.mutableContainers + ) as AnyObject? } else { // Lossless conversion of the string was not possible return nil diff --git a/Palace/Utilities/Localization/String+HTMLEntities.swift b/Palace/Utilities/Localization/String+HTMLEntities.swift index 0e854f00c..b9b16f66c 100644 --- a/Palace/Utilities/Localization/String+HTMLEntities.swift +++ b/Palace/Utilities/Localization/String+HTMLEntities.swift @@ -10,322 +10,320 @@ import Foundation // Mapping from XML/HTML character entity reference to character // From http://en.wikipedia.org/wiki/List_of_XML_and_HTML_character_entity_references -private let characterEntities : [Substring: Character] = [ - +private let characterEntities: [Substring: Character] = [ // XML predefined entities: - """ : "\"", - "&" : "&", - "'" : "'", - "<" : "<", - ">" : ">", - + """: "\"", + "&": "&", + "'": "'", + "<": "<", + ">": ">", + // HTML character entity references: - " " : "\u{00A0}", - "¡" : "\u{00A1}", - "¢" : "\u{00A2}", - "£" : "\u{00A3}", - "¤" : "\u{00A4}", - "¥" : "\u{00A5}", - "¦" : "\u{00A6}", - "§" : "\u{00A7}", - "¨" : "\u{00A8}", - "©" : "\u{00A9}", - "ª" : "\u{00AA}", - "«" : "\u{00AB}", - "¬" : "\u{00AC}", - "­" : "\u{00AD}", - "®" : "\u{00AE}", - "¯" : "\u{00AF}", - "°" : "\u{00B0}", - "±" : "\u{00B1}", - "²" : "\u{00B2}", - "³" : "\u{00B3}", - "´" : "\u{00B4}", - "µ" : "\u{00B5}", - "¶" : "\u{00B6}", - "·" : "\u{00B7}", - "¸" : "\u{00B8}", - "¹" : "\u{00B9}", - "º" : "\u{00BA}", - "»" : "\u{00BB}", - "¼" : "\u{00BC}", - "½" : "\u{00BD}", - "¾" : "\u{00BE}", - "¿" : "\u{00BF}", - "À" : "\u{00C0}", - "Á" : "\u{00C1}", - "Â" : "\u{00C2}", - "Ã" : "\u{00C3}", - "Ä" : "\u{00C4}", - "Å" : "\u{00C5}", - "Æ" : "\u{00C6}", - "Ç" : "\u{00C7}", - "È" : "\u{00C8}", - "É" : "\u{00C9}", - "Ê" : "\u{00CA}", - "Ë" : "\u{00CB}", - "Ì" : "\u{00CC}", - "Í" : "\u{00CD}", - "Î" : "\u{00CE}", - "Ï" : "\u{00CF}", - "Ð" : "\u{00D0}", - "Ñ" : "\u{00D1}", - "Ò" : "\u{00D2}", - "Ó" : "\u{00D3}", - "Ô" : "\u{00D4}", - "Õ" : "\u{00D5}", - "Ö" : "\u{00D6}", - "×" : "\u{00D7}", - "Ø" : "\u{00D8}", - "Ù" : "\u{00D9}", - "Ú" : "\u{00DA}", - "Û" : "\u{00DB}", - "Ü" : "\u{00DC}", - "Ý" : "\u{00DD}", - "Þ" : "\u{00DE}", - "ß" : "\u{00DF}", - "à" : "\u{00E0}", - "á" : "\u{00E1}", - "â" : "\u{00E2}", - "ã" : "\u{00E3}", - "ä" : "\u{00E4}", - "å" : "\u{00E5}", - "æ" : "\u{00E6}", - "ç" : "\u{00E7}", - "è" : "\u{00E8}", - "é" : "\u{00E9}", - "ê" : "\u{00EA}", - "ë" : "\u{00EB}", - "ì" : "\u{00EC}", - "í" : "\u{00ED}", - "î" : "\u{00EE}", - "ï" : "\u{00EF}", - "ð" : "\u{00F0}", - "ñ" : "\u{00F1}", - "ò" : "\u{00F2}", - "ó" : "\u{00F3}", - "ô" : "\u{00F4}", - "õ" : "\u{00F5}", - "ö" : "\u{00F6}", - "÷" : "\u{00F7}", - "ø" : "\u{00F8}", - "ù" : "\u{00F9}", - "ú" : "\u{00FA}", - "û" : "\u{00FB}", - "ü" : "\u{00FC}", - "ý" : "\u{00FD}", - "þ" : "\u{00FE}", - "ÿ" : "\u{00FF}", - "Œ" : "\u{0152}", - "œ" : "\u{0153}", - "Š" : "\u{0160}", - "š" : "\u{0161}", - "Ÿ" : "\u{0178}", - "ƒ" : "\u{0192}", - "ˆ" : "\u{02C6}", - "˜" : "\u{02DC}", - "Α" : "\u{0391}", - "Β" : "\u{0392}", - "Γ" : "\u{0393}", - "Δ" : "\u{0394}", - "Ε" : "\u{0395}", - "Ζ" : "\u{0396}", - "Η" : "\u{0397}", - "Θ" : "\u{0398}", - "Ι" : "\u{0399}", - "Κ" : "\u{039A}", - "Λ" : "\u{039B}", - "Μ" : "\u{039C}", - "Ν" : "\u{039D}", - "Ξ" : "\u{039E}", - "Ο" : "\u{039F}", - "Π" : "\u{03A0}", - "Ρ" : "\u{03A1}", - "Σ" : "\u{03A3}", - "Τ" : "\u{03A4}", - "Υ" : "\u{03A5}", - "Φ" : "\u{03A6}", - "Χ" : "\u{03A7}", - "Ψ" : "\u{03A8}", - "Ω" : "\u{03A9}", - "α" : "\u{03B1}", - "β" : "\u{03B2}", - "γ" : "\u{03B3}", - "δ" : "\u{03B4}", - "ε" : "\u{03B5}", - "ζ" : "\u{03B6}", - "η" : "\u{03B7}", - "θ" : "\u{03B8}", - "ι" : "\u{03B9}", - "κ" : "\u{03BA}", - "λ" : "\u{03BB}", - "μ" : "\u{03BC}", - "ν" : "\u{03BD}", - "ξ" : "\u{03BE}", - "ο" : "\u{03BF}", - "π" : "\u{03C0}", - "ρ" : "\u{03C1}", - "ς" : "\u{03C2}", - "σ" : "\u{03C3}", - "τ" : "\u{03C4}", - "υ" : "\u{03C5}", - "φ" : "\u{03C6}", - "χ" : "\u{03C7}", - "ψ" : "\u{03C8}", - "ω" : "\u{03C9}", - "ϑ" : "\u{03D1}", - "ϒ" : "\u{03D2}", - "ϖ" : "\u{03D6}", - " " : "\u{2002}", - " " : "\u{2003}", - " " : "\u{2009}", - "‌" : "\u{200C}", - "‍" : "\u{200D}", - "‎" : "\u{200E}", - "‏" : "\u{200F}", - "–" : "\u{2013}", - "—" : "\u{2014}", - "‘" : "\u{2018}", - "’" : "\u{2019}", - "‚" : "\u{201A}", - "“" : "\u{201C}", - "”" : "\u{201D}", - "„" : "\u{201E}", - "†" : "\u{2020}", - "‡" : "\u{2021}", - "•" : "\u{2022}", - "…" : "\u{2026}", - "‰" : "\u{2030}", - "′" : "\u{2032}", - "″" : "\u{2033}", - "‹" : "\u{2039}", - "›" : "\u{203A}", - "‾" : "\u{203E}", - "⁄" : "\u{2044}", - "€" : "\u{20AC}", - "ℑ" : "\u{2111}", - "℘" : "\u{2118}", - "ℜ" : "\u{211C}", - "™" : "\u{2122}", - "ℵ" : "\u{2135}", - "←" : "\u{2190}", - "↑" : "\u{2191}", - "→" : "\u{2192}", - "↓" : "\u{2193}", - "↔" : "\u{2194}", - "↵" : "\u{21B5}", - "⇐" : "\u{21D0}", - "⇑" : "\u{21D1}", - "⇒" : "\u{21D2}", - "⇓" : "\u{21D3}", - "⇔" : "\u{21D4}", - "∀" : "\u{2200}", - "∂" : "\u{2202}", - "∃" : "\u{2203}", - "∅" : "\u{2205}", - "∇" : "\u{2207}", - "∈" : "\u{2208}", - "∉" : "\u{2209}", - "∋" : "\u{220B}", - "∏" : "\u{220F}", - "∑" : "\u{2211}", - "−" : "\u{2212}", - "∗" : "\u{2217}", - "√" : "\u{221A}", - "∝" : "\u{221D}", - "∞" : "\u{221E}", - "∠" : "\u{2220}", - "∧" : "\u{2227}", - "∨" : "\u{2228}", - "∩" : "\u{2229}", - "∪" : "\u{222A}", - "∫" : "\u{222B}", - "∴" : "\u{2234}", - "∼" : "\u{223C}", - "≅" : "\u{2245}", - "≈" : "\u{2248}", - "≠" : "\u{2260}", - "≡" : "\u{2261}", - "≤" : "\u{2264}", - "≥" : "\u{2265}", - "⊂" : "\u{2282}", - "⊃" : "\u{2283}", - "⊄" : "\u{2284}", - "⊆" : "\u{2286}", - "⊇" : "\u{2287}", - "⊕" : "\u{2295}", - "⊗" : "\u{2297}", - "⊥" : "\u{22A5}", - "⋅" : "\u{22C5}", - "⌈" : "\u{2308}", - "⌉" : "\u{2309}", - "⌊" : "\u{230A}", - "⌋" : "\u{230B}", - "⟨" : "\u{2329}", - "⟩" : "\u{232A}", - "◊" : "\u{25CA}", - "♠" : "\u{2660}", - "♣" : "\u{2663}", - "♥" : "\u{2665}", - "♦" : "\u{2666}", - + " ": "\u{00A0}", + "¡": "\u{00A1}", + "¢": "\u{00A2}", + "£": "\u{00A3}", + "¤": "\u{00A4}", + "¥": "\u{00A5}", + "¦": "\u{00A6}", + "§": "\u{00A7}", + "¨": "\u{00A8}", + "©": "\u{00A9}", + "ª": "\u{00AA}", + "«": "\u{00AB}", + "¬": "\u{00AC}", + "­": "\u{00AD}", + "®": "\u{00AE}", + "¯": "\u{00AF}", + "°": "\u{00B0}", + "±": "\u{00B1}", + "²": "\u{00B2}", + "³": "\u{00B3}", + "´": "\u{00B4}", + "µ": "\u{00B5}", + "¶": "\u{00B6}", + "·": "\u{00B7}", + "¸": "\u{00B8}", + "¹": "\u{00B9}", + "º": "\u{00BA}", + "»": "\u{00BB}", + "¼": "\u{00BC}", + "½": "\u{00BD}", + "¾": "\u{00BE}", + "¿": "\u{00BF}", + "À": "\u{00C0}", + "Á": "\u{00C1}", + "Â": "\u{00C2}", + "Ã": "\u{00C3}", + "Ä": "\u{00C4}", + "Å": "\u{00C5}", + "Æ": "\u{00C6}", + "Ç": "\u{00C7}", + "È": "\u{00C8}", + "É": "\u{00C9}", + "Ê": "\u{00CA}", + "Ë": "\u{00CB}", + "Ì": "\u{00CC}", + "Í": "\u{00CD}", + "Î": "\u{00CE}", + "Ï": "\u{00CF}", + "Ð": "\u{00D0}", + "Ñ": "\u{00D1}", + "Ò": "\u{00D2}", + "Ó": "\u{00D3}", + "Ô": "\u{00D4}", + "Õ": "\u{00D5}", + "Ö": "\u{00D6}", + "×": "\u{00D7}", + "Ø": "\u{00D8}", + "Ù": "\u{00D9}", + "Ú": "\u{00DA}", + "Û": "\u{00DB}", + "Ü": "\u{00DC}", + "Ý": "\u{00DD}", + "Þ": "\u{00DE}", + "ß": "\u{00DF}", + "à": "\u{00E0}", + "á": "\u{00E1}", + "â": "\u{00E2}", + "ã": "\u{00E3}", + "ä": "\u{00E4}", + "å": "\u{00E5}", + "æ": "\u{00E6}", + "ç": "\u{00E7}", + "è": "\u{00E8}", + "é": "\u{00E9}", + "ê": "\u{00EA}", + "ë": "\u{00EB}", + "ì": "\u{00EC}", + "í": "\u{00ED}", + "î": "\u{00EE}", + "ï": "\u{00EF}", + "ð": "\u{00F0}", + "ñ": "\u{00F1}", + "ò": "\u{00F2}", + "ó": "\u{00F3}", + "ô": "\u{00F4}", + "õ": "\u{00F5}", + "ö": "\u{00F6}", + "÷": "\u{00F7}", + "ø": "\u{00F8}", + "ù": "\u{00F9}", + "ú": "\u{00FA}", + "û": "\u{00FB}", + "ü": "\u{00FC}", + "ý": "\u{00FD}", + "þ": "\u{00FE}", + "ÿ": "\u{00FF}", + "Œ": "\u{0152}", + "œ": "\u{0153}", + "Š": "\u{0160}", + "š": "\u{0161}", + "Ÿ": "\u{0178}", + "ƒ": "\u{0192}", + "ˆ": "\u{02C6}", + "˜": "\u{02DC}", + "Α": "\u{0391}", + "Β": "\u{0392}", + "Γ": "\u{0393}", + "Δ": "\u{0394}", + "Ε": "\u{0395}", + "Ζ": "\u{0396}", + "Η": "\u{0397}", + "Θ": "\u{0398}", + "Ι": "\u{0399}", + "Κ": "\u{039A}", + "Λ": "\u{039B}", + "Μ": "\u{039C}", + "Ν": "\u{039D}", + "Ξ": "\u{039E}", + "Ο": "\u{039F}", + "Π": "\u{03A0}", + "Ρ": "\u{03A1}", + "Σ": "\u{03A3}", + "Τ": "\u{03A4}", + "Υ": "\u{03A5}", + "Φ": "\u{03A6}", + "Χ": "\u{03A7}", + "Ψ": "\u{03A8}", + "Ω": "\u{03A9}", + "α": "\u{03B1}", + "β": "\u{03B2}", + "γ": "\u{03B3}", + "δ": "\u{03B4}", + "ε": "\u{03B5}", + "ζ": "\u{03B6}", + "η": "\u{03B7}", + "θ": "\u{03B8}", + "ι": "\u{03B9}", + "κ": "\u{03BA}", + "λ": "\u{03BB}", + "μ": "\u{03BC}", + "ν": "\u{03BD}", + "ξ": "\u{03BE}", + "ο": "\u{03BF}", + "π": "\u{03C0}", + "ρ": "\u{03C1}", + "ς": "\u{03C2}", + "σ": "\u{03C3}", + "τ": "\u{03C4}", + "υ": "\u{03C5}", + "φ": "\u{03C6}", + "χ": "\u{03C7}", + "ψ": "\u{03C8}", + "ω": "\u{03C9}", + "ϑ": "\u{03D1}", + "ϒ": "\u{03D2}", + "ϖ": "\u{03D6}", + " ": "\u{2002}", + " ": "\u{2003}", + " ": "\u{2009}", + "‌": "\u{200C}", + "‍": "\u{200D}", + "‎": "\u{200E}", + "‏": "\u{200F}", + "–": "\u{2013}", + "—": "\u{2014}", + "‘": "\u{2018}", + "’": "\u{2019}", + "‚": "\u{201A}", + "“": "\u{201C}", + "”": "\u{201D}", + "„": "\u{201E}", + "†": "\u{2020}", + "‡": "\u{2021}", + "•": "\u{2022}", + "…": "\u{2026}", + "‰": "\u{2030}", + "′": "\u{2032}", + "″": "\u{2033}", + "‹": "\u{2039}", + "›": "\u{203A}", + "‾": "\u{203E}", + "⁄": "\u{2044}", + "€": "\u{20AC}", + "ℑ": "\u{2111}", + "℘": "\u{2118}", + "ℜ": "\u{211C}", + "™": "\u{2122}", + "ℵ": "\u{2135}", + "←": "\u{2190}", + "↑": "\u{2191}", + "→": "\u{2192}", + "↓": "\u{2193}", + "↔": "\u{2194}", + "↵": "\u{21B5}", + "⇐": "\u{21D0}", + "⇑": "\u{21D1}", + "⇒": "\u{21D2}", + "⇓": "\u{21D3}", + "⇔": "\u{21D4}", + "∀": "\u{2200}", + "∂": "\u{2202}", + "∃": "\u{2203}", + "∅": "\u{2205}", + "∇": "\u{2207}", + "∈": "\u{2208}", + "∉": "\u{2209}", + "∋": "\u{220B}", + "∏": "\u{220F}", + "∑": "\u{2211}", + "−": "\u{2212}", + "∗": "\u{2217}", + "√": "\u{221A}", + "∝": "\u{221D}", + "∞": "\u{221E}", + "∠": "\u{2220}", + "∧": "\u{2227}", + "∨": "\u{2228}", + "∩": "\u{2229}", + "∪": "\u{222A}", + "∫": "\u{222B}", + "∴": "\u{2234}", + "∼": "\u{223C}", + "≅": "\u{2245}", + "≈": "\u{2248}", + "≠": "\u{2260}", + "≡": "\u{2261}", + "≤": "\u{2264}", + "≥": "\u{2265}", + "⊂": "\u{2282}", + "⊃": "\u{2283}", + "⊄": "\u{2284}", + "⊆": "\u{2286}", + "⊇": "\u{2287}", + "⊕": "\u{2295}", + "⊗": "\u{2297}", + "⊥": "\u{22A5}", + "⋅": "\u{22C5}", + "⌈": "\u{2308}", + "⌉": "\u{2309}", + "⌊": "\u{230A}", + "⌋": "\u{230B}", + "⟨": "\u{2329}", + "⟩": "\u{232A}", + "◊": "\u{25CA}", + "♠": "\u{2660}", + "♣": "\u{2663}", + "♥": "\u{2665}", + "♦": "\u{2666}" ] // Discussion: https://stackoverflow.com/questions/25607247/how-do-i-decode-html-entities-in-swift extension String { - /// Returns a new string made by replacing in the `String` /// all HTML character entity references with the corresponding /// character. - var stringByDecodingHTMLEntities : String { - + var stringByDecodingHTMLEntities: String { // ===== Utility functions ===== - + // Convert the number in the string to the corresponding // Unicode character, e.g. // decodeNumeric("64", 10) --> "@" // decodeNumeric("20ac", 16) --> "€" - func decodeNumeric(_ string : Substring, base : Int) -> Character? { + func decodeNumeric(_ string: Substring, base: Int) -> Character? { guard let code = UInt32(string, radix: base), - let uniScalar = UnicodeScalar(code) else { return nil } + let uniScalar = UnicodeScalar(code) + else { + return nil + } return Character(uniScalar) } - + // Decode the HTML character entity to the corresponding // Unicode character, return `nil` for invalid input. // decode("@") --> "@" // decode("€") --> "€" // decode("<") --> "<" // decode("&foo;") --> nil - func decode(_ entity : Substring) -> Character? { - + func decode(_ entity: Substring) -> Character? { if entity.hasPrefix("&#x") || entity.hasPrefix("&#X") { - return decodeNumeric(entity.dropFirst(3).dropLast(), base: 16) + decodeNumeric(entity.dropFirst(3).dropLast(), base: 16) } else if entity.hasPrefix("&#") { - return decodeNumeric(entity.dropFirst(2).dropLast(), base: 10) + decodeNumeric(entity.dropFirst(2).dropLast(), base: 10) } else { - return characterEntities[entity] + characterEntities[entity] } } - + // ===== Method starts here ===== - + var result = "" var position = startIndex - + // Find the next '&' and copy the characters preceding it to `result`: while let ampRange = self[position...].range(of: "&") { - result.append(contentsOf: self[position ..< ampRange.lowerBound]) + result.append(contentsOf: self[position.. NSString { - return (self as String).stringByDecodingHTMLEntities as NSString + (self as String).stringByDecodingHTMLEntities as NSString } } diff --git a/Palace/Utilities/Localization/String+MD5.swift b/Palace/Utilities/Localization/String+MD5.swift index 7b00da1cd..fa5e08139 100644 --- a/Palace/Utilities/Localization/String+MD5.swift +++ b/Palace/Utilities/Localization/String+MD5.swift @@ -1,30 +1,30 @@ // Adapted from: https://stackoverflow.com/a/31932898/9964065 // TODO: Migrate to new Crypto API coming soon -import Foundation import CommonCrypto +import Foundation -extension String { - public func md5() -> Data { - let messageData = self.data(using:.utf8)! +public extension String { + func md5() -> Data { + let messageData = data(using: .utf8)! var digestData = Data(count: Int(CC_MD5_DIGEST_LENGTH)) - + _ = digestData.withUnsafeMutableBytes { digestBytes in messageData.withUnsafeBytes { messageBytes in CC_MD5(messageBytes, CC_LONG(messageData.count), digestBytes) } } - + return digestData } - public func md5hex() -> String { - return md5().map { String(format: "%02hhx", $0) }.joined() + func md5hex() -> String { + md5().map { String(format: "%02hhx", $0) }.joined() } } -@objc extension NSString { - public func md5String() -> NSString { - return (self as String).md5hex() as NSString +@objc public extension NSString { + func md5String() -> NSString { + (self as String).md5hex() as NSString } } diff --git a/Palace/Utilities/Localization/Strings+objC.swift b/Palace/Utilities/Localization/Strings+objC.swift index f3ed36aaf..f43a70975 100644 --- a/Palace/Utilities/Localization/Strings+objC.swift +++ b/Palace/Utilities/Localization/Strings+objC.swift @@ -11,12 +11,14 @@ import Foundation /// Makes `Strings` string properties available in Objective-C @objcMembers class LocalizedStrings: NSObject { - // MARK: - TPPLastListenedPositionSynchronizer - static let syncListeningPositionAlertTitle = Strings.TPPLastListenedPositionSynchronizer.syncListeningPositionAlertTitle + + static let syncListeningPositionAlertTitle = Strings.TPPLastListenedPositionSynchronizer + .syncListeningPositionAlertTitle static let syncListeningPositionAlertBody = Strings.TPPLastListenedPositionSynchronizer.syncListeningPositionAlertBody - + // MARK: - TPPLastReadPositionSynchronizer + static let stay = Strings.TPPLastReadPositionSynchronizer.stay static let move = Strings.TPPLastReadPositionSynchronizer.move } diff --git a/Palace/Utilities/Localization/Strings.swift b/Palace/Utilities/Localization/Strings.swift index 7a16d1974..2760eb472 100644 --- a/Palace/Utilities/Localization/Strings.swift +++ b/Palace/Utilities/Localization/Strings.swift @@ -8,112 +8,182 @@ import Foundation -struct Strings { - - struct Accessibility { +enum Strings { + enum Accessibility { static let navigationTitle = "navigationTitle" static let librarySwitchButton = "librarySwitchButton" static let viewBookmarksAndTocButton = "viewBookmarksAndTocButton" } - struct AgeCheck { + enum AgeCheck { static let title = NSLocalizedString("Age Verification", comment: "Title for Age Verification") - static let titleLabel = NSLocalizedString("Please enter your birth year", comment: "Caption for asking user to enter their birth year") - static let done = NSLocalizedString("Done", comment: "Button title for hiding picker view") + static let titleLabel = NSLocalizedString( + "Please enter your birth year", + comment: "Caption for asking user to enter their birth year" + ) + static let done = NSLocalizedString("Done", comment: "Button title for hiding picker view") static let placeholderString = NSLocalizedString("Select Year", comment: "Placeholder for birth year textfield") static let rightBarButtonItem = NSLocalizedString("Next", comment: "Button title for completing age verification") } - - struct Announcments { + + enum Announcments { static let alertTitle = NSLocalizedString("Announcement", comment: "") static let ok = NSLocalizedString("Announcement", comment: "") } - struct Error { + enum Error { static let loginFailedErrorTitle = NSLocalizedString("Login Failed", comment: "") static let loadFailedError = NSLocalizedString("The page could not load due to a conection error.", comment: "") static let invalidCredentialsErrorTitle = NSLocalizedString("Invalid Credentials", comment: "") - static let invalidCredentialsErrorMessage = NSLocalizedString("Please check your username and password and try again.", comment: "") - static let unknownRequestError = NSLocalizedString("An unknown error occurred. Please check your connection or try again later.", comment: "A generic error message for when a network request fails") + static let invalidCredentialsErrorMessage = NSLocalizedString( + "Please check your username and password and try again.", + comment: "" + ) + static let unknownRequestError = NSLocalizedString( + "An unknown error occurred. Please check your connection or try again later.", + comment: "A generic error message for when a network request fails" + ) static let connectionFailed = NSLocalizedString( "Connection Failed", - comment: "Title for alert that explains that the page could not download the information") + comment: "Title for alert that explains that the page could not download the information" + ) static let syncSettingChangeErrorTitle = NSLocalizedString("Error Changing Sync Setting", comment: "") - static let syncSettingsChangeErrorBody = NSLocalizedString("There was a problem contacting the server.\nPlease make sure you are connected to the internet, or try again later.", comment: "") - static let invalidBookError = NSLocalizedString("The book you were trying to open is invalid.", comment: "Error message used when trying to import a publication that is not valid") - static let openFailedError = NSLocalizedString("An error was encountered while trying to open this book.", comment: "Error message used when a low-level error occured while opening a publication") - static let formatNotSupportedError = NSLocalizedString("The book you were trying to read is in an unsupported format.", comment: "Error message when trying to read a publication with a unsupported format") - static let epubNotValidError = NSLocalizedString("The book you were trying to read is corrupted. Please try downloading it again.", comment: "Error message when trying to read an EPUB that is invalid") - static let pageLoadFailedError = NSLocalizedString("The page could not load due to a connection error.", comment: "") - static let serverConnectionErrorDescription = NSLocalizedString("Unable to contact the server because the URL for signing in is missing.", - comment: "Error message for when the library profile url is missing from the authentication document the server provided.") - static let serverConnectionErrorSuggestion = NSLocalizedString("Try force-quitting the app and repeat the sign-in process.", - comment: "Recovery instructions for when the URL to sign in is missing") - static let cardCreationError = NSLocalizedString("We're sorry. Currently we do not support signups for new patrons via the app.", comment: "Message describing the fact that new patron sign up is not supported by the current selected library") - static let signInErrorTitle = NSLocalizedString("Sign In Error", - comment: "Title for sign in error alert") - static let signInErrorDescription = NSLocalizedString("The DRM Library is taking longer than expected. Please wait and try again later.\n\nIf the problem persists, try to sign out and back in again from the Library Settings menu.", - comment: "Message for sign-in error alert caused by failed DRM authorization") + static let syncSettingsChangeErrorBody = NSLocalizedString( + "There was a problem contacting the server.\nPlease make sure you are connected to the internet, or try again later.", + comment: "" + ) + static let invalidBookError = NSLocalizedString( + "The book you were trying to open is invalid.", + comment: "Error message used when trying to import a publication that is not valid" + ) + static let openFailedError = NSLocalizedString( + "An error was encountered while trying to open this book.", + comment: "Error message used when a low-level error occured while opening a publication" + ) + static let formatNotSupportedError = NSLocalizedString( + "The book you were trying to read is in an unsupported format.", + comment: "Error message when trying to read a publication with a unsupported format" + ) + static let epubNotValidError = NSLocalizedString( + "The book you were trying to read is corrupted. Please try downloading it again.", + comment: "Error message when trying to read an EPUB that is invalid" + ) + static let pageLoadFailedError = NSLocalizedString( + "The page could not load due to a connection error.", + comment: "" + ) + static let serverConnectionErrorDescription = NSLocalizedString( + "Unable to contact the server because the URL for signing in is missing.", + comment: "Error message for when the library profile url is missing from the authentication document the server provided." + ) + static let serverConnectionErrorSuggestion = NSLocalizedString( + "Try force-quitting the app and repeat the sign-in process.", + comment: "Recovery instructions for when the URL to sign in is missing" + ) + static let cardCreationError = NSLocalizedString( + "We're sorry. Currently we do not support signups for new patrons via the app.", + comment: "Message describing the fact that new patron sign up is not supported by the current selected library" + ) + static let signInErrorTitle = NSLocalizedString( + "Sign In Error", + comment: "Title for sign in error alert" + ) + static let signInErrorDescription = NSLocalizedString( + "The DRM Library is taking longer than expected. Please wait and try again later.\n\nIf the problem persists, try to sign out and back in again from the Library Settings menu.", + comment: "Message for sign-in error alert caused by failed DRM authorization" + ) static let loginErrorTitle = NSLocalizedString("Login Failed", comment: "Title for login error alert") - static let loginErrorDescription = NSLocalizedString("An error occurred during the authentication process", - comment: "Generic error message while handling sign-in redirection during authentication") - static let userDeniedLocationAccess = NSLocalizedString("User denied location access. Go to system settings to enable location access for the Palace App.", comment: "Error message shown to user when location services are denied.") - static let uknownLocationError = NSLocalizedString("Unkown error occurred. Please try again.", comment: "Error message shown to user when an unknown location error occurs.") - static let locationFetchFailed = NSLocalizedString("Failed to get current location. Please try again.", comment: "Error message shown to user when CoreLocation does not return the current location.") + static let loginErrorDescription = NSLocalizedString( + "An error occurred during the authentication process", + comment: "Generic error message while handling sign-in redirection during authentication" + ) + static let userDeniedLocationAccess = NSLocalizedString( + "User denied location access. Go to system settings to enable location access for the Palace App.", + comment: "Error message shown to user when location services are denied." + ) + static let uknownLocationError = NSLocalizedString( + "Unkown error occurred. Please try again.", + comment: "Error message shown to user when an unknown location error occurs." + ) + static let locationFetchFailed = NSLocalizedString( + "Failed to get current location. Please try again.", + comment: "Error message shown to user when CoreLocation does not return the current location." + ) static let tryAgain = NSLocalizedString("Please try again later.", comment: "Error message to please try again.") } - - struct Generic { + + enum Generic { static let back = NSLocalizedString("Back", comment: "Text for Back button") static let more = NSLocalizedString("More...", comment: "") static let error = NSLocalizedString("Error", comment: "") static let ok = NSLocalizedString("OK", comment: "") - static let cancel = NSLocalizedString("Cancel", comment: "Button that says to cancel and go back to the last screen.") + static let cancel = NSLocalizedString( + "Cancel", + comment: "Button that says to cancel and go back to the last screen." + ) static let reload = NSLocalizedString("Reload", comment: "Button that says to try again") - static let delete = NSLocalizedString("Delete", comment:"") + static let delete = NSLocalizedString("Delete", comment: "") static let wait = NSLocalizedString("Wait", comment: "button title") static let reject = NSLocalizedString("Reject", comment: "Title for a Reject button") static let accept = NSLocalizedString("Accept", comment: "Title for a Accept button") static let signin = NSLocalizedString("Sign in", comment: "") static let close = NSLocalizedString("Close", comment: "Title for close button") static let search = NSLocalizedString("Search", comment: "Placeholder for Search Field") - static let done = NSLocalizedString("Done", comment: "Title for Done button") + static let done = NSLocalizedString("Done", comment: "Title for Done button") static let clear = NSLocalizedString("Clear", comment: "Button to clear selection") } - - struct OETutorialChoiceViewController { + + enum OETutorialChoiceViewController { static let loginMessage = NSLocalizedString("You need to login to access the collection.", comment: "") static let requestNewCodes = NSLocalizedString("Request New Codes", comment: "") } - - struct OETutorialEligibilityViewController { - static let description = NSLocalizedString("Open eBooks provides free books to the children who need them the most.\n\nThe collection includes thousands of popular and award-winning titles as well as hundreds of public domain works.", comment: "Description of Open eBooks app displayed during 1st launch tutorial") + + enum OETutorialEligibilityViewController { + static let description = NSLocalizedString( + "Open eBooks provides free books to the children who need them the most.\n\nThe collection includes thousands of popular and award-winning titles as well as hundreds of public domain works.", + comment: "Description of Open eBooks app displayed during 1st launch tutorial" + ) } - - struct OETutorialWelcomeViewController { - static let description = NSLocalizedString("Welcome to Open eBooks", - comment: "Welcome text") + + enum OETutorialWelcomeViewController { + static let description = NSLocalizedString( + "Welcome to Open eBooks", + comment: "Welcome text" + ) } - - struct ProblemReportEmail { + + enum ProblemReportEmail { static let supportEmail = "logs@thepalaceproject.org" - static let noAccountSetupTitle = NSLocalizedString("No email account is set for this device.", comment: "Alert title") + static let noAccountSetupTitle = NSLocalizedString( + "No email account is set for this device.", + comment: "Alert title" + ) static let reportSentTitle = NSLocalizedString("Thank You", comment: "Alert title") - static let reportSentBody = NSLocalizedString("Your report will be reviewed as soon as possible.", comment: "Alert message") + static let reportSentBody = NSLocalizedString( + "Your report will be reviewed as soon as possible.", + comment: "Alert message" + ) } - - struct ReturnPromptHelper { + + enum ReturnPromptHelper { static let audiobookPromptTitle = NSLocalizedString("Your Audiobook Has Finished", comment: "") static let audiobookPromptMessage = NSLocalizedString("Would you like to return it?", comment: "") - static let keepActionAlertTitle = NSLocalizedString("Keep", - comment: "Button title for keeping an audiobook") - static let returnActionTitle = NSLocalizedString("Return", - comment: "Button title for keeping an audiobook") + static let keepActionAlertTitle = NSLocalizedString( + "Keep", + comment: "Button title for keeping an audiobook" + ) + static let returnActionTitle = NSLocalizedString( + "Return", + comment: "Button title for keeping an audiobook" + ) } - - struct Settings { + + enum Settings { static let settings = NSLocalizedString("Settings", comment: "") - static let libraries = NSLocalizedString("Libraries", comment: "A title for a list of libraries the user may select or add to.") + static let libraries = NSLocalizedString( + "Libraries", + comment: "A title for a list of libraries the user may select or add to." + ) static let catalog = NSLocalizedString("Catalog", comment: "For the catalog tab") static let addLibrary = NSLocalizedString("Add Library", comment: "Title of button to add a new library") static let aboutApp = NSLocalizedString("About App", comment: "") @@ -122,189 +192,291 @@ struct Strings { static let eula = NSLocalizedString("User Agreement", comment: "") static let developerSettings = NSLocalizedString("Testing", comment: "Developer Settings") } - - struct TPPAccountListDataSource { - static let addLibrary = NSLocalizedString("Add Library", comment: "Title that also informs the user that they should choose a library from the list.") - } - - struct TPPBaseReaderViewController { - static let removeBookmark = NSLocalizedString("Remove Bookmark", - comment: "Accessibility label for button to remove a bookmark") - static let addBookmark = NSLocalizedString("Add Bookmark", - comment: "Accessibility label for button to add a bookmark") - static let previousChapter = NSLocalizedString("Previous Chapter", comment: "Accessibility label to go backward in the publication") - static let nextChapter = NSLocalizedString("Next Chapter", comment: "Accessibility label to go forward in the publication") + + enum TPPAccountListDataSource { + static let addLibrary = NSLocalizedString( + "Add Library", + comment: "Title that also informs the user that they should choose a library from the list." + ) + } + + enum TPPBaseReaderViewController { + static let removeBookmark = NSLocalizedString( + "Remove Bookmark", + comment: "Accessibility label for button to remove a bookmark" + ) + static let addBookmark = NSLocalizedString( + "Add Bookmark", + comment: "Accessibility label for button to add a bookmark" + ) + static let previousChapter = NSLocalizedString( + "Previous Chapter", + comment: "Accessibility label to go backward in the publication" + ) + static let nextChapter = NSLocalizedString( + "Next Chapter", + comment: "Accessibility label to go forward in the publication" + ) static let read = NSLocalizedString("Read", comment: "Accessibility label to read current chapter") - static let pageOf = NSLocalizedString("Page %d of ", value: "Page %d of ", comment: "States the page count out of total pages, i.e. `Page 1 of 20`") + static let pageOf = NSLocalizedString( + "Page %d of ", + value: "Page %d of ", + comment: "States the page count out of total pages, i.e. `Page 1 of 20`" + ) } - - struct TPPBarCode { - static let cameraAccessDisabledTitle = NSLocalizedString("Camera Access Disabled", - comment: "An alert title stating the user has disallowed the app to access the user's location") + + enum TPPBarCode { + static let cameraAccessDisabledTitle = NSLocalizedString( + "Camera Access Disabled", + comment: "An alert title stating the user has disallowed the app to access the user's location" + ) static let cameraAccessDisabledBody = NSLocalizedString( - ("You must enable camera access for this application " + - "in order to sign up for a library card."), - comment: "An alert message informing the user that camera access is required") - static let openSettings = NSLocalizedString("Open Settings", - comment: "A title for a button that will open the Settings app") - } - - struct TPPBook { + "You must enable camera access for this application " + + "in order to sign up for a library card.", + comment: "An alert message informing the user that camera access is required" + ) + static let openSettings = NSLocalizedString( + "Open Settings", + comment: "A title for a button that will open the Settings app" + ) + } + + enum TPPBook { static let epubContentType = NSLocalizedString("ePub", comment: "ePub") static let pdfContentType = NSLocalizedString("PDF", comment: "PDF") static let audiobookContentType = NSLocalizedString("Audiobook", comment: "Audiobook") static let unsupportedContentType = NSLocalizedString("Unsupported format", comment: "Unsupported format") } - - struct TPPPDFNavigation { + + enum TPPPDFNavigation { static let resume = NSLocalizedString("Resume", comment: "A button to continue reading title.") } - - struct TPPDeveloperSettingsTableViewController { + + enum TPPDeveloperSettingsTableViewController { static let developerSettingsTitle = NSLocalizedString("Testing", comment: "Developer Settings") } - - struct TPPEPUBViewController { + + enum TPPEPUBViewController { static let readerSettings = NSLocalizedString("Reader settings", comment: "Reader settings") static let emptySearchView = NSLocalizedString("There are no results", comment: "No search results available.") - static let endOfResults = NSLocalizedString("Reached the end of the results.", comment: "Reached the end of the results." -) + static let endOfResults = NSLocalizedString( + "Reached the end of the results.", + comment: "Reached the end of the results." + ) } - - struct TPPLastReadPositionSynchronizer { - static let syncReadingPositionAlertTitle = NSLocalizedString("Sync Reading Position", comment: "An alert title notifying the user the reading position has been synced") - static let syncReadingPositionAlertBody = NSLocalizedString("Do you want to move to the page on which you left off?", comment: "An alert message asking the user to perform navigation to the synced reading position or not") + + enum TPPLastReadPositionSynchronizer { + static let syncReadingPositionAlertTitle = NSLocalizedString( + "Sync Reading Position", + comment: "An alert title notifying the user the reading position has been synced" + ) + static let syncReadingPositionAlertBody = NSLocalizedString( + "Do you want to move to the page on which you left off?", + comment: "An alert message asking the user to perform navigation to the synced reading position or not" + ) static let stay = NSLocalizedString("Stay", comment: "Do not perform navigation") static let move = NSLocalizedString("Move", comment: "Perform navigation") } - struct TPPLastListenedPositionSynchronizer { - static let syncListeningPositionAlertTitle = NSLocalizedString("Sync Listening Position", comment: "An alert title notifying the user the listening position has been synced") - static let syncListeningPositionAlertBody = NSLocalizedString("Do you want to move to the time on which you left off?", comment: "An alert message asking the user to perform navigation to the synced listening position or not") + enum TPPLastListenedPositionSynchronizer { + static let syncListeningPositionAlertTitle = NSLocalizedString( + "Sync Listening Position", + comment: "An alert title notifying the user the listening position has been synced" + ) + static let syncListeningPositionAlertBody = NSLocalizedString( + "Do you want to move to the time on which you left off?", + comment: "An alert message asking the user to perform navigation to the synced listening position or not" + ) } - struct TPPProblemDocument { - static let authenticationExpiredTitle = NSLocalizedString("Authentication Expired", - comment: "Title for an error related to expired credentials") - static let authenticationExpiredBody = NSLocalizedString("Your authentication details have expired. Please sign in again.", - comment: "Message to prompt user to re-authenticate") - static let authenticationRequiredTitle = NSLocalizedString("Authentication Required", - comment: "Title for an error related to credentials being required") - static let authenticationRequireBody = NSLocalizedString("Your authentication details have expired. Please sign in again.", - comment: "Message to prompt user to re-authenticate") + enum TPPProblemDocument { + static let authenticationExpiredTitle = NSLocalizedString( + "Authentication Expired", + comment: "Title for an error related to expired credentials" + ) + static let authenticationExpiredBody = NSLocalizedString( + "Your authentication details have expired. Please sign in again.", + comment: "Message to prompt user to re-authenticate" + ) + static let authenticationRequiredTitle = NSLocalizedString( + "Authentication Required", + comment: "Title for an error related to credentials being required" + ) + static let authenticationRequireBody = NSLocalizedString( + "Your authentication details have expired. Please sign in again.", + comment: "Message to prompt user to re-authenticate" + ) } - - struct TPPReaderAppearance { - static let blackOnWhiteText = NSLocalizedString("Appearance Selector: Open dyslexic font", comment: "OpenDyslexicFont") - static let blackOnSepiaText = NSLocalizedString("Appearance Selector: Black on sepia text", comment: "BlackOnSepiaText") - static let whiteOnBlackText = NSLocalizedString("Appearance Selector: White on black text", comment: "WhiteOnBlackText") + + enum TPPReaderAppearance { + static let blackOnWhiteText = NSLocalizedString( + "Appearance Selector: Open dyslexic font", + comment: "OpenDyslexicFont" + ) + static let blackOnSepiaText = NSLocalizedString( + "Appearance Selector: Black on sepia text", + comment: "BlackOnSepiaText" + ) + static let whiteOnBlackText = NSLocalizedString( + "Appearance Selector: White on black text", + comment: "WhiteOnBlackText" + ) } - - struct TPPReaderBookmarksBusinessLogic { - static let noBookmarks = NSLocalizedString("There are no bookmarks for this book.", comment: "Text showing in bookmarks view when there are no bookmarks") + + enum TPPReaderBookmarksBusinessLogic { + static let noBookmarks = NSLocalizedString( + "There are no bookmarks for this book.", + comment: "Text showing in bookmarks view when there are no bookmarks" + ) } - - struct TPPReaderFont { + + enum TPPReaderFont { static let original = NSLocalizedString("Font selector: Default book font", comment: "OriginalFont") static let sans = NSLocalizedString("Font selector: Sans font", comment: "SansFont") static let serif = NSLocalizedString("Font selector: Serif font", comment: "SerifFont") static let dyslexic = NSLocalizedString("Font selector: Open dyslexic font", comment: "OpenDyslexicFont") } - - struct TPPReaderPositionsVC { - static let contents = NSLocalizedString("Contents", comment: "") - static let bookmarks = NSLocalizedString("Bookmarks", comment: "") + + enum TPPReaderPositionsVC { + static let contents = NSLocalizedString("Contents", comment: "") + static let bookmarks = NSLocalizedString("Bookmarks", comment: "") } - - struct TPPReaderTOCBusinessLogic { - static let tocDisplayTitle = NSLocalizedString("Table of Contents", comment: "Title for Table of Contents in eReader") + + enum TPPReaderTOCBusinessLogic { + static let tocDisplayTitle = NSLocalizedString( + "Table of Contents", + comment: "Title for Table of Contents in eReader" + ) } - - struct TPPSettingsAdvancedViewController { + + enum TPPSettingsAdvancedViewController { static let advanced = NSLocalizedString("Advanced", comment: "") - static let pleaseWait = NSLocalizedString("Please wait...", comment:"Generic Wait message") - static let deleteServerData = NSLocalizedString("Delete Server Data", comment:"") + static let pleaseWait = NSLocalizedString("Please wait...", comment: "Generic Wait message") + static let deleteServerData = NSLocalizedString("Delete Server Data", comment: "") } - - struct TPPSettingsSplitViewController { + + enum TPPSettingsSplitViewController { static let account = NSLocalizedString("Account", comment: "Title for account section") static let acknowledgements = NSLocalizedString("Acknowledgements", comment: "Title for acknowledgements section") static let eula = NSLocalizedString("User Agreement", comment: "Title for User Agreement section") static let privacyPolicy = NSLocalizedString("Privacy Policy", comment: "Title for Privacy Policy section") } - - struct TPPSigninBusinessLogic { - static let ecard = NSLocalizedString("eCard", - comment: "Title for web-based card creator page") - static let ecardErrorMessage = NSLocalizedString("We're sorry. Our sign up system is currently down. Please try again later.", - comment: "Message for error loading the web-based card creator") - static let signout = NSLocalizedString("Sign out", - comment: "Title for sign out action") - static let annotationSyncMessage = NSLocalizedString("Your bookmarks and reading positions are in the process of being saved to the server. Would you like to stop that and continue logging out?", - comment: "Warning message offering the user the choice of interrupting book registry syncing to log out immediately, or waiting until that finishes.") - static let pendingDownloadMessage = NSLocalizedString("It looks like you may have a book download or return in progress. Would you like to stop that and continue logging out?", - comment: "Warning message offering the user the choice of interrupting the download or return of a book to log out immediately, or waiting until that finishes.") - } - - struct TPPWelcomeScreenViewController { - static let findYourLibrary = NSLocalizedString("Find Your Library", comment: "Button that lets user know they can select a library they have a card for") - } - - struct UserNotifications { + + enum TPPSigninBusinessLogic { + static let ecard = NSLocalizedString( + "eCard", + comment: "Title for web-based card creator page" + ) + static let ecardErrorMessage = NSLocalizedString( + "We're sorry. Our sign up system is currently down. Please try again later.", + comment: "Message for error loading the web-based card creator" + ) + static let signout = NSLocalizedString( + "Sign out", + comment: "Title for sign out action" + ) + static let annotationSyncMessage = NSLocalizedString( + "Your bookmarks and reading positions are in the process of being saved to the server. Would you like to stop that and continue logging out?", + comment: "Warning message offering the user the choice of interrupting book registry syncing to log out immediately, or waiting until that finishes." + ) + static let pendingDownloadMessage = NSLocalizedString( + "It looks like you may have a book download or return in progress. Would you like to stop that and continue logging out?", + comment: "Warning message offering the user the choice of interrupting the download or return of a book to log out immediately, or waiting until that finishes." + ) + } + + enum TPPWelcomeScreenViewController { + static let findYourLibrary = NSLocalizedString( + "Find Your Library", + comment: "Button that lets user know they can select a library they have a card for" + ) + } + + enum UserNotifications { static let downloadReady = NSLocalizedString("Ready for Download", comment: "") static let checkoutTitle = NSLocalizedString("Check Out", comment: "") } - - struct MyBooksView { + + enum MyBooksView { static let navTitle = NSLocalizedString("My Books", comment: "") static let sortBy = NSLocalizedString("Sort By:", comment: "") static let searchBooks = NSLocalizedString("Search My Books", comment: "") static let emptyViewMessage = NSLocalizedString("Visit the Catalog to\nadd books to My Books.", comment: "") - static let findYourLibrary = NSLocalizedString("Find Your Library", comment: "Button that lets user know they can select a library they have a card for") + static let findYourLibrary = NSLocalizedString( + "Find Your Library", + comment: "Button that lets user know they can select a library they have a card for" + ) static let addLibrary = NSLocalizedString("Add Library", comment: "Title of button to add a new library") static let accountSyncingAlertTitle = NSLocalizedString("Please wait", comment: "") - static let accountSyncingAlertMessage = NSLocalizedString("Please wait a moment before switching library accounts", comment: "") + static let accountSyncingAlertMessage = NSLocalizedString( + "Please wait a moment before switching library accounts", + comment: "" + ) } - - struct FacetView { + + enum FacetView { static let author = NSLocalizedString("Author", comment: "") static let title = NSLocalizedString("Title", comment: "") } - - struct Catalog { + + enum Catalog { static let filter = NSLocalizedString("Filter", comment: "") static let sortBy = NSLocalizedString("Sort By", comment: "Header label for sort options") static let showResults = NSLocalizedString("SHOW RESULTS", comment: "Button to apply filters and show results") } - - struct BookCell { + + enum BookCell { static let delete = NSLocalizedString("Delete", comment: "") static let `return` = NSLocalizedString("Return", comment: "") static let remove = NSLocalizedString("Remove", comment: "") - static let deleteMessage = NSLocalizedString("Are you sure you want to delete \"%@\"?", comment: "Message shown in an alert to the user prior to deleting a title") - static let returnMessage = NSLocalizedString("Are you sure you want to return \"%@\"?", comment: "Message shown in an alert to the user prior to returning a title") + static let deleteMessage = NSLocalizedString( + "Are you sure you want to delete \"%@\"?", + comment: "Message shown in an alert to the user prior to deleting a title" + ) + static let returnMessage = NSLocalizedString( + "Are you sure you want to return \"%@\"?", + comment: "Message shown in an alert to the user prior to returning a title" + ) static let removeReservation = NSLocalizedString("Remove Reservation", comment: "") - static let removeReservationMessage = NSLocalizedString("Are you sure you want ot remove \"%@\" from your reservations? You will no longer be in line for this book.", comment: "Message shown in an alert to the user prior to returning a reserved title.") + static let removeReservationMessage = NSLocalizedString( + "Are you sure you want ot remove \"%@\" from your reservations? You will no longer be in line for this book.", + comment: "Message shown in an alert to the user prior to returning a reserved title." + ) static let downloading = NSLocalizedString("Downloading", comment: "") static let downloadFailedMessage = NSLocalizedString("The download could not be completed.", comment: "") } - - struct TPPAccountRegistration { - static let doesUserHaveLibraryCard = NSLocalizedString("Don't have a library card?", comment: "Title for registration. Asking the user if they already have a library card.") - static let geolocationInstructions = NSLocalizedString("The Palace App requires a one-time location check in order to verify your library service area. Once you choose \"Create Card\", please select \"Allow Once\" in the popup so we can verify this information.", comment: "Body for registration. Explaining the reason for requesting the user's location and instructions for how to provide permission.") + + enum TPPAccountRegistration { + static let doesUserHaveLibraryCard = NSLocalizedString( + "Don't have a library card?", + comment: "Title for registration. Asking the user if they already have a library card." + ) + static let geolocationInstructions = NSLocalizedString( + "The Palace App requires a one-time location check in order to verify your library service area. Once you choose \"Create Card\", please select \"Allow Once\" in the popup so we can verify this information.", + comment: "Body for registration. Explaining the reason for requesting the user's location and instructions for how to provide permission." + ) static let createCard = NSLocalizedString("Create Card", comment: "") - static let deniedLocationAccessMessage = NSLocalizedString("The Palace App requires a one-time location check in order to verify your library service area. You have disabled location services for this app. To enable, please select the 'Open Settings' button below then continue with card creation.", comment: "Registration message shown to user when location access has been denied.") - static let deniedLocationAccessMessageBoldText = NSLocalizedString("You have disabled location services for this app.",comment: "Registration message shown to user when location access has been denied.") + static let deniedLocationAccessMessage = NSLocalizedString( + "The Palace App requires a one-time location check in order to verify your library service area. You have disabled location services for this app. To enable, please select the 'Open Settings' button below then continue with card creation.", + comment: "Registration message shown to user when location access has been denied." + ) + static let deniedLocationAccessMessageBoldText = NSLocalizedString( + "You have disabled location services for this app.", + comment: "Registration message shown to user when location access has been denied." + ) static let openSettings = NSLocalizedString("Open Settings", comment: "") } - - struct MyDownloadCenter { + + enum MyDownloadCenter { static let borrowFailed = NSLocalizedString("Borrow Failed", comment: "") static let borrowFailedMessage = NSLocalizedString("Borrowing %@ could not be completed.", comment: "") - static let loanAlreadyExistsAlertMessage = NSLocalizedString("You have already checked out this loan. You may need to refresh your My Books list to download the title.", comment: "") + static let loanAlreadyExistsAlertMessage = NSLocalizedString( + "You have already checked out this loan. You may need to refresh your My Books list to download the title.", + comment: "" + ) } - struct BookDetailView { + enum BookDetailView { static let audiobookAvailable = NSLocalizedString("Also available as an audiobook.", comment: "") static let description = NSLocalizedString("Description", comment: "") static let information = NSLocalizedString("Information", comment: "") @@ -333,7 +505,7 @@ struct Strings { static let manageHold = BookButton.manageHold } - struct BookButton { + enum BookButton { static let borrow = NSLocalizedString("Borrow", comment: "") static let preview = NSLocalizedString("Preview", comment: "") static let returnLoan = NSLocalizedString("Return Loan", comment: "") @@ -352,13 +524,13 @@ struct Strings { static let otherBooks = NSLocalizedString("Other books by this author", comment: "") static let close = NSLocalizedString("Close", comment: "") } - - struct HoldsView { + + enum HoldsView { static let reservations = NSLocalizedString("Reservations", comment: "Nav title") static let emptyMessage = NSLocalizedString(""" - When you reserve a book from the catalog, it will show up here. \ - Look here from time to time to see if your book is available to download. - """, comment: "") + When you reserve a book from the catalog, it will show up here. \ + Look here from time to time to see if your book is available to download. + """, comment: "") static let findYourLibrary = NSLocalizedString("Find Your Library", comment: "") } } diff --git a/Palace/Utilities/Localization/Transifex/TXNativeExtensions.swift b/Palace/Utilities/Localization/Transifex/TXNativeExtensions.swift index 956e00f57..e4a4198b7 100644 --- a/Palace/Utilities/Localization/Transifex/TXNativeExtensions.swift +++ b/Palace/Utilities/Localization/Transifex/TXNativeExtensions.swift @@ -20,32 +20,42 @@ import Transifex /// Override Swift String.localizedStringWithFormat: method public extension String { - static func localizedStringWithFormat( - _ format: String, _ arguments: CVarArg... - ) -> String { - guard let localized = TXNative.localizedString(format: format, - arguments: arguments) else { - return String(format: format, locale: Locale.current, - arguments: arguments) - } - - return localized + static func localizedStringWithFormat( + _ format: String, _ arguments: CVarArg... + ) -> String { + guard let localized = TXNative.localizedString( + format: format, + arguments: arguments + ) else { + return String( + format: format, + locale: Locale.current, + arguments: arguments + ) } + + return localized + } } /// Override Swift NSString.localizedStringWithFormat: method public extension NSString { - class func localizedStringWithFormat( - _ format: NSString, _ args: CVarArg... - ) -> Self { - guard let localized = TXNative.localizedString(format: format as String, - arguments: args) as? Self else { - return withVaList(args) { - self.init(format: format as String, locale: Locale.current, - arguments: $0) - } - } - - return localized + class func localizedStringWithFormat( + _ format: NSString, _ args: CVarArg... + ) -> Self { + guard let localized = TXNative.localizedString( + format: format as String, + arguments: args + ) as? Self else { + return withVaList(args) { + self.init( + format: format as String, + locale: Locale.current, + arguments: $0 + ) + } } + + return localized + } } diff --git a/Palace/Utilities/Localization/Transifex/TransifexManager.swift b/Palace/Utilities/Localization/Transifex/TransifexManager.swift index f4c18c035..59e1f38e4 100644 --- a/Palace/Utilities/Localization/Transifex/TransifexManager.swift +++ b/Palace/Utilities/Localization/Transifex/TransifexManager.swift @@ -10,8 +10,10 @@ import Transifex @objc class TransifexManager: NSObject { @objc static func setup() { - let locales = TXLocaleState(sourceLocale: "en", - appLocales: ["en", "es", "it", "de", "fr"]) + let locales = TXLocaleState( + sourceLocale: "en", + appLocales: ["en", "es", "it", "de", "fr"] + ) TXNative.initialize( locales: locales, diff --git a/Palace/Utilities/Networking/URLRequest+Extensions.swift b/Palace/Utilities/Networking/URLRequest+Extensions.swift index 693e436a4..7897a99f7 100644 --- a/Palace/Utilities/Networking/URLRequest+Extensions.swift +++ b/Palace/Utilities/Networking/URLRequest+Extensions.swift @@ -11,16 +11,16 @@ import Foundation extension URLRequest { init(url: URL, applyingCustomUserAgent: Bool) { self.init(url: url) - + if applyingCustomUserAgent { let appName = Bundle.main.infoDictionary?["CFBundleName"] as? String ?? "App" let appVersion = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "1.0" let customUserAgent = "\(appName)/\(appVersion) (iOS; \(UIDevice.current.systemVersion))" - - if let existingUserAgent = self.value(forHTTPHeaderField: "User-Agent") { - self.setValue("\(existingUserAgent) \(customUserAgent)", forHTTPHeaderField: "User-Agent") + + if let existingUserAgent = value(forHTTPHeaderField: "User-Agent") { + setValue("\(existingUserAgent) \(customUserAgent)", forHTTPHeaderField: "User-Agent") } else { - self.setValue(customUserAgent, forHTTPHeaderField: "User-Agent") + setValue(customUserAgent, forHTTPHeaderField: "User-Agent") } } } @@ -31,13 +31,13 @@ extension URLRequest { let appName = Bundle.main.infoDictionary?["CFBundleName"] as? String ?? "App" let appVersion = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "1.0" let customUserAgent = "\(appName)/\(appVersion) (iOS; \(UIDevice.current.systemVersion))" - + if let existingUserAgent = value(forHTTPHeaderField: "User-Agent") { setValue("\(existingUserAgent) \(customUserAgent)", forHTTPHeaderField: "User-Agent") } else { setValue(customUserAgent, forHTTPHeaderField: "User-Agent") } - + return self } } diff --git a/Palace/Utilities/Networking/URLResponse+NYPL.swift b/Palace/Utilities/Networking/URLResponse+NYPL.swift index c45dc4938..c8298d858 100644 --- a/Palace/Utilities/Networking/URLResponse+NYPL.swift +++ b/Palace/Utilities/Networking/URLResponse+NYPL.swift @@ -9,20 +9,20 @@ import Foundation extension URLResponse { - /// Determines if the response is a Problem Document response per /// https://tools.ietf.org/html/rfc7807. /// - Returns: `true` is the response contains a problem Document, /// `false` otherwise. @objc func isProblemDocument() -> Bool { - - return ["application/problem+json", - "application/api-problem+json"].contains(mimeType) + [ + "application/problem+json", + "application/api-problem+json" + ].contains(mimeType) } } extension HTTPURLResponse { @objc func isSuccess() -> Bool { - return (200...299).contains(statusCode) + (200...299).contains(statusCode) } } diff --git a/Palace/Utilities/SwiftUI/AdaptiveShadowModifier.swift b/Palace/Utilities/SwiftUI/AdaptiveShadowModifier.swift index 582c5fff5..8c5340e61 100644 --- a/Palace/Utilities/SwiftUI/AdaptiveShadowModifier.swift +++ b/Palace/Utilities/SwiftUI/AdaptiveShadowModifier.swift @@ -1,5 +1,7 @@ import SwiftUI +// MARK: - AdaptiveShadowModifier + struct AdaptiveShadowModifier: ViewModifier { @Environment(\.colorScheme) var colorScheme var radius: CGFloat @@ -13,10 +15,12 @@ struct AdaptiveShadowModifier: ViewModifier { extension View { func adaptiveShadow(radius: CGFloat = 10) -> some View { - self.modifier(AdaptiveShadowModifier(radius: radius)) + modifier(AdaptiveShadowModifier(radius: radius)) } } +// MARK: - AdaptiveShadowLightModifier + struct AdaptiveShadowLightModifier: ViewModifier { @Environment(\.colorScheme) var colorScheme var radius: CGFloat @@ -29,6 +33,6 @@ struct AdaptiveShadowLightModifier: ViewModifier { extension View { func adaptiveShadowLight(radius: CGFloat = 1.0) -> some View { - self.modifier(AdaptiveShadowLightModifier(radius: radius)) + modifier(AdaptiveShadowLightModifier(radius: radius)) } } diff --git a/Palace/Utilities/SwiftUI/AlertModel.swift b/Palace/Utilities/SwiftUI/AlertModel.swift index 19d331e6c..2f0a3ad0f 100644 --- a/Palace/Utilities/SwiftUI/AlertModel.swift +++ b/Palace/Utilities/SwiftUI/AlertModel.swift @@ -12,8 +12,8 @@ struct AlertModel: Identifiable { let id = UUID() var title: String var message: String - var buttonTitle: String? = nil + var buttonTitle: String? var primaryAction: () -> Void = {} - var secondaryButtonTitle: String? = nil + var secondaryButtonTitle: String? var secondaryAction: () -> Void = {} } diff --git a/Palace/Utilities/SwiftUI/AsyncImage.swift b/Palace/Utilities/SwiftUI/AsyncImage.swift index caeb1bb87..1feacc28c 100644 --- a/Palace/Utilities/SwiftUI/AsyncImage.swift +++ b/Palace/Utilities/SwiftUI/AsyncImage.swift @@ -6,17 +6,19 @@ // Copyright © 2022 The Palace Project. All rights reserved. // https://www.swiftbysundell.com/tips/constant-combine-publishers/ -import SwiftUI import Combine import Foundation +import SwiftUI + +// MARK: - ImageLoader class ImageLoader { private let urlSession: URLSession - + init(urlSession: URLSession = .shared) { self.urlSession = urlSession } - + func publisher(for url: URL) -> AnyPublisher { urlSession.dataTaskPublisher(for: url) .map(\.data) @@ -34,21 +36,23 @@ class ImageLoader { } } +// MARK: - AsyncImage + @MainActor class AsyncImage: ObservableObject { @Published var image: UIImage private var cancellable: AnyCancellable? private let imageLoader = ImageLoader() - + init(image: UIImage) { self.image = image } - + func loadImage(url: URL) { - self.cancellable = imageLoader.publisher(for: url) + cancellable = imageLoader.publisher(for: url) .sink(receiveCompletion: { result in switch result { - case .failure(let error): + case let .failure(error): TPPErrorLogger.logError(error, summary: "Failed to load image") default: return diff --git a/Palace/Utilities/SwiftUI/HTMLTextView.swift b/Palace/Utilities/SwiftUI/HTMLTextView.swift index 653e7179c..c63a011c9 100644 --- a/Palace/Utilities/SwiftUI/HTMLTextView.swift +++ b/Palace/Utilities/SwiftUI/HTMLTextView.swift @@ -15,7 +15,9 @@ struct HTMLTextView: View { } private func htmlToAttributedString(_ html: String) -> AttributedString? { - guard let data = html.data(using: .utf8) else { return nil } + guard let data = html.data(using: .utf8) else { + return nil + } let options: [NSAttributedString.DocumentReadingOptionKey: Any] = [ .documentType: NSAttributedString.DocumentType.html, @@ -24,7 +26,11 @@ struct HTMLTextView: View { if let nsAttributedString = try? NSAttributedString(data: data, options: options, documentAttributes: nil) { let mutableAttributedString = NSMutableAttributedString(attributedString: nsAttributedString) - mutableAttributedString.addAttribute(.font, value: UIFont.palaceFont(ofSize: 15), range: NSRange(location: 0, length: mutableAttributedString.length)) + mutableAttributedString.addAttribute( + .font, + value: UIFont.palaceFont(ofSize: 15), + range: NSRange(location: 0, length: mutableAttributedString.length) + ) return AttributedString(mutableAttributedString) } diff --git a/Palace/Utilities/SwiftUI/ImageProvider.swift b/Palace/Utilities/SwiftUI/ImageProvider.swift index b1dd65444..f551abd55 100644 --- a/Palace/Utilities/SwiftUI/ImageProvider.swift +++ b/Palace/Utilities/SwiftUI/ImageProvider.swift @@ -8,14 +8,14 @@ import SwiftUI -struct ImageProviders { - struct AudiobookSampleToolbar { +enum ImageProviders { + enum AudiobookSampleToolbar { static let pause = Image(systemName: "pause.circle") static let play = Image(systemName: "play.circle") static let stepBack = Image(systemName: "gobackward.30") } - - struct MyBooksView { + + enum MyBooksView { static let bookPlaceholder = UIImage(systemName: "book.closed.fill") static let audiobookBadge = Image("AudiobookBadge") static let unreadBadge = Image("Unread") diff --git a/Palace/Utilities/SwiftUI/LoadingOverlayModifier.swift b/Palace/Utilities/SwiftUI/LoadingOverlayModifier.swift index 4a984fd2a..dcbdd0f8e 100644 --- a/Palace/Utilities/SwiftUI/LoadingOverlayModifier.swift +++ b/Palace/Utilities/SwiftUI/LoadingOverlayModifier.swift @@ -1,5 +1,7 @@ import SwiftUI +// MARK: - LoadingOverlayModifier + struct LoadingOverlayModifier: ViewModifier { var isLoading: Bool @@ -31,10 +33,12 @@ struct LoadingOverlayModifier: ViewModifier { extension View { func loadingOverlay(isLoading: Bool) -> some View { - self.modifier(LoadingOverlayModifier(isLoading: isLoading)) + modifier(LoadingOverlayModifier(isLoading: isLoading)) } } +// MARK: - ShimmerEffect + struct ShimmerEffect: ViewModifier { @State private var isAnimating = false @@ -62,10 +66,12 @@ struct ShimmerEffect: ViewModifier { extension View { func shimmerEffect() -> some View { - self.modifier(ShimmerEffect()) + modifier(ShimmerEffect()) } } +// MARK: - ShimmerView + struct ShimmerView: View { var width: CGFloat var height: CGFloat diff --git a/Palace/Utilities/SwiftUI/RefreshableView.swift b/Palace/Utilities/SwiftUI/RefreshableView.swift index 4d5696e52..adfa23001 100644 --- a/Palace/Utilities/SwiftUI/RefreshableView.swift +++ b/Palace/Utilities/SwiftUI/RefreshableView.swift @@ -11,13 +11,15 @@ import SwiftUI typealias Action = () -> Void +// MARK: - RefreshableScrollView + struct RefreshableScrollView: ViewModifier { var onRefresh: Action private var topPadding = 50.0 @State private var needRefresh: Bool = false private let coordinatorSpaceName = "RefreshingView" - + init(_ refreshAction: @escaping Action) { onRefresh = refreshAction } @@ -29,15 +31,15 @@ struct RefreshableScrollView: ViewModifier { } .coordinateSpace(name: coordinatorSpaceName) } - + private var refreshView: some View { GeometryReader { geometry in - if (geometry.frame(in: .named(coordinatorSpaceName)).midY > topPadding) { + if geometry.frame(in: .named(coordinatorSpaceName)).midY > topPadding { Spacer() .onAppear { needRefresh = true } - } else if (geometry.frame(in: .named(coordinatorSpaceName)).midY < 10) { + } else if geometry.frame(in: .named(coordinatorSpaceName)).midY < 10 { Spacer() .onAppear { if needRefresh { diff --git a/Palace/Utilities/SwiftUI/UIViewControllerWrapper.swift b/Palace/Utilities/SwiftUI/UIViewControllerWrapper.swift index 4655ae04b..6777e1c3d 100644 --- a/Palace/Utilities/SwiftUI/UIViewControllerWrapper.swift +++ b/Palace/Utilities/SwiftUI/UIViewControllerWrapper.swift @@ -9,22 +9,24 @@ import Foundation import SwiftUI -struct UIViewControllerWrapper: UIViewControllerRepresentable { +struct UIViewControllerWrapper: UIViewControllerRepresentable { typealias Updater = (Wrapper, Context) -> Void - + var makeView: () -> Wrapper var update: (Wrapper, Context) -> Void - - init(_ makeView: @escaping @autoclosure () -> Wrapper, - updater update: @escaping (Wrapper) -> Void) { + + init( + _ makeView: @escaping @autoclosure () -> Wrapper, + updater update: @escaping (Wrapper) -> Void + ) { self.makeView = makeView self.update = { view, _ in update(view) } } - - func makeUIViewController(context: Context) -> Wrapper { + + func makeUIViewController(context _: Context) -> Wrapper { makeView() } - + func updateUIViewController(_ uiViewController: Wrapper, context: Context) { update(uiViewController, context) } diff --git a/Palace/Utilities/SwiftUI/View+DismissKeyboard.swift b/Palace/Utilities/SwiftUI/View+DismissKeyboard.swift index 80f4a3640..e3dab86a8 100644 --- a/Palace/Utilities/SwiftUI/View+DismissKeyboard.swift +++ b/Palace/Utilities/SwiftUI/View+DismissKeyboard.swift @@ -5,6 +5,8 @@ public func dismissKeyboard() { UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil) } +// MARK: - DismissKeyboardOnTap + public struct DismissKeyboardOnTap: ViewModifier { public let onDismiss: (() -> Void)? @@ -23,5 +25,3 @@ public extension View { modifier(DismissKeyboardOnTap(onDismiss: onDismiss)) } } - - diff --git a/Palace/Utilities/System/DeviceOrientation.swift b/Palace/Utilities/System/DeviceOrientation.swift index e3c263232..98f25caa9 100644 --- a/Palace/Utilities/System/DeviceOrientation.swift +++ b/Palace/Utilities/System/DeviceOrientation.swift @@ -6,7 +6,6 @@ // Copyright © 2025 The Palace Project. All rights reserved. // - @MainActor class DeviceOrientation: ObservableObject { @Published var isLandscape: Bool = { @@ -33,7 +32,7 @@ class DeviceOrientation: ObservableObject { let screenWidth = UIScreen.main.bounds.width let screenHeight = UIScreen.main.bounds.height let newIsLandscape = screenWidth > screenHeight - + if self.isLandscape != newIsLandscape { self.isLandscape = newIsLandscape } diff --git a/Palace/Utilities/System/LocationManager.swift b/Palace/Utilities/System/LocationManager.swift index f4d0190b0..575398868 100644 --- a/Palace/Utilities/System/LocationManager.swift +++ b/Palace/Utilities/System/LocationManager.swift @@ -12,26 +12,28 @@ extension Notification.Name { static let locationAuthorizationDidChange = Notification.Name("LocationAuthorizationDidChange") } +// MARK: - LocationManager + class LocationManager: NSObject, CLLocationManagerDelegate { static let shared = LocationManager() private let locationManager = CLLocationManager() - private override init() { + override private init() { super.init() locationManager.delegate = self } - + var locationAccessAuthorized: Bool { let status = locationManager.authorizationStatus return status == .authorizedAlways || status == .authorizedWhenInUse } - + var locationAccessDenied: Bool { let status = locationManager.authorizationStatus return status == .denied } - func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) { + func locationManagerDidChangeAuthorization(_: CLLocationManager) { NotificationCenter.default.post(name: .locationAuthorizationDidChange, object: nil) } } diff --git a/Palace/Utilities/UI/LoadingViewController.swift b/Palace/Utilities/UI/LoadingViewController.swift index 82d82caf8..7678f202e 100644 --- a/Palace/Utilities/UI/LoadingViewController.swift +++ b/Palace/Utilities/UI/LoadingViewController.swift @@ -9,17 +9,17 @@ import Foundation class LoadingViewController: UIViewController { - var spinner = UIActivityIndicatorView(style: .large) + var spinner = UIActivityIndicatorView(style: .large) - override func loadView() { - view = UIView() - view.backgroundColor = UIColor(white: 0, alpha: 0.7) + override func loadView() { + view = UIView() + view.backgroundColor = UIColor(white: 0, alpha: 0.7) - spinner.translatesAutoresizingMaskIntoConstraints = false - spinner.startAnimating() - view.addSubview(spinner) + spinner.translatesAutoresizingMaskIntoConstraints = false + spinner.startAnimating() + view.addSubview(spinner) - spinner.centerXAnchor.constraint(equalTo: view.centerXAnchor).isActive = true - spinner.centerYAnchor.constraint(equalTo: view.centerYAnchor).isActive = true - } + spinner.centerXAnchor.constraint(equalTo: view.centerXAnchor).isActive = true + spinner.centerYAnchor.constraint(equalTo: view.centerYAnchor).isActive = true + } } diff --git a/Palace/Utilities/UI/TPPLoadingViewController.swift b/Palace/Utilities/UI/TPPLoadingViewController.swift index 570a8f706..1b8b49058 100644 --- a/Palace/Utilities/UI/TPPLoadingViewController.swift +++ b/Palace/Utilities/UI/TPPLoadingViewController.swift @@ -1,11 +1,12 @@ import UIKit +// MARK: - TPPLoadingViewController + protocol TPPLoadingViewController: UIViewController { var loadingView: UIView? { get set } } extension TPPLoadingViewController { - private func loadingOverlayView() -> UIView { let overlayView = UIView() overlayView.backgroundColor = UIColor.black.withAlphaComponent(0.7) @@ -16,24 +17,27 @@ extension TPPLoadingViewController { activityView.startAnimating() return overlayView } - + func startLoading() { - guard loadingView == nil else { return } - + guard loadingView == nil else { + return + } + let loadingOverlay = loadingOverlayView() - DispatchQueue.main.async { - if let scene = UIApplication.shared.connectedScenes.compactMap({ $0 as? UIWindowScene }).first, - let win = scene.windows.first(where: { $0.isKeyWindow }) { - win.addSubview(loadingOverlay) - } else if let win = UIApplication.shared.windows.first(where: { $0.isKeyWindow }) { - win.addSubview(loadingOverlay) - } - loadingOverlay.autoPinEdgesToSuperviewEdges() + DispatchQueue.main.async { + if let scene = UIApplication.shared.connectedScenes.compactMap({ $0 as? UIWindowScene }).first, + let win = scene.windows.first(where: { $0.isKeyWindow }) + { + win.addSubview(loadingOverlay) + } else if let win = UIApplication.shared.windows.first(where: { $0.isKeyWindow }) { + win.addSubview(loadingOverlay) } - + loadingOverlay.autoPinEdgesToSuperviewEdges() + } + loadingView = loadingOverlay } - + func stopLoading() { DispatchQueue.main.async { self.loadingView?.removeFromSuperview() @@ -41,4 +45,3 @@ extension TPPLoadingViewController { } } } - diff --git a/Palace/Utilities/UI/UINavigationBar+appearance.swift b/Palace/Utilities/UI/UINavigationBar+appearance.swift index 4b27f9d8f..71f3f8cc1 100644 --- a/Palace/Utilities/UI/UINavigationBar+appearance.swift +++ b/Palace/Utilities/UI/UINavigationBar+appearance.swift @@ -22,7 +22,9 @@ extension UINavigationBar { /// Calling this function will trigger the view to redraw itself, forcing an appearance update @objc func forceUpdateAppearance(style: UIUserInterfaceStyle) { DispatchQueue.main.async { - guard let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene else { return } + guard let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene else { + return + } for window in windowScene.windows { window.overrideUserInterfaceStyle = style } diff --git a/Palace/Views/ExtendedNavBarView.swift b/Palace/Views/ExtendedNavBarView.swift index e084c07d7..d931841ec 100644 --- a/Palace/Views/ExtendedNavBarView.swift +++ b/Palace/Views/ExtendedNavBarView.swift @@ -1,7 +1,7 @@ /* Copyright (C) 2016 Apple Inc. All Rights Reserved. See LICENSE.txt for this sample’s licensing information - + Abstract: A UIView subclass that draws a gray hairline along its bottom border, similar to a navigation bar. This view is used as the navigation @@ -11,23 +11,21 @@ import UIKit class ExtendedNavBarView: UIView { - - /** - * Called when the view is about to be displayed. May be called more than - * once. - */ - override func willMove(toWindow newWindow: UIWindow?) { - super.willMove(toWindow: newWindow) - - // Use the layer shadow to draw a one pixel hairline under this view. - layer.shadowOffset = CGSize(width: 0, height: CGFloat(1) / UIScreen.main.scale) - layer.shadowRadius = 0 - - // UINavigationBar's hairline is adaptive, its properties change with - // the contents it overlies. You may need to experiment with these - // values to best match your content. - layer.shadowColor = #colorLiteral(red: 0, green: 0, blue: 0, alpha: 1).cgColor - layer.shadowOpacity = 0.25 - } - + /** + * Called when the view is about to be displayed. May be called more than + * once. + */ + override func willMove(toWindow newWindow: UIWindow?) { + super.willMove(toWindow: newWindow) + + // Use the layer shadow to draw a one pixel hairline under this view. + layer.shadowOffset = CGSize(width: 0, height: CGFloat(1) / UIScreen.main.scale) + layer.shadowRadius = 0 + + // UINavigationBar's hairline is adaptive, its properties change with + // the contents it overlies. You may need to experiment with these + // values to best match your content. + layer.shadowColor = #colorLiteral(red: 0, green: 0, blue: 0, alpha: 1).cgColor + layer.shadowOpacity = 0.25 + } } diff --git a/Palace/Views/RegistrationCell.swift b/Palace/Views/RegistrationCell.swift index 4f93f8150..6592e9498 100644 --- a/Palace/Views/RegistrationCell.swift +++ b/Palace/Views/RegistrationCell.swift @@ -12,7 +12,7 @@ class RegistrationCell: UITableViewCell { typealias DisplayStrings = Strings.TPPAccountRegistration private let padding: CGFloat = 20.0 - + private let regTitle: UILabel = { let label = UILabel() label.font = UIFont.preferredFont(forTextStyle: .body) @@ -21,7 +21,7 @@ class RegistrationCell: UITableViewCell { label.translatesAutoresizingMaskIntoConstraints = false return label }() - + private let regBody: UILabel = { let label = UILabel() label.font = UIFont.preferredFont(forTextStyle: .callout) @@ -30,7 +30,7 @@ class RegistrationCell: UITableViewCell { label.translatesAutoresizingMaskIntoConstraints = false return label }() - + private let regButton: UIButton = { let button = UIButton(type: .system) button.setTitle(DisplayStrings.createCard, for: .normal) @@ -43,46 +43,51 @@ class RegistrationCell: UITableViewCell { button.translatesAutoresizingMaskIntoConstraints = false return button }() - + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { super.init(style: style, reuseIdentifier: reuseIdentifier) - + let containerView = UIView() containerView.translatesAutoresizingMaskIntoConstraints = false - + containerView.addSubview(regTitle) containerView.addSubview(regBody) containerView.addSubview(regButton) - + contentView.addSubview(containerView) - + NSLayoutConstraint.activate([ regTitle.leadingAnchor.constraint(equalTo: containerView.leadingAnchor, constant: padding), regTitle.topAnchor.constraint(equalTo: containerView.topAnchor, constant: padding), regTitle.trailingAnchor.constraint(equalTo: containerView.trailingAnchor, constant: -padding), - + regBody.leadingAnchor.constraint(equalTo: containerView.leadingAnchor, constant: padding), regBody.topAnchor.constraint(equalTo: regTitle.bottomAnchor, constant: padding), regBody.trailingAnchor.constraint(equalTo: containerView.trailingAnchor, constant: -padding), - + regButton.leadingAnchor.constraint(equalTo: containerView.leadingAnchor, constant: padding), regButton.topAnchor.constraint(equalTo: regBody.bottomAnchor, constant: padding), regButton.bottomAnchor.constraint(equalTo: containerView.bottomAnchor, constant: -padding), - + containerView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor), containerView.topAnchor.constraint(equalTo: contentView.topAnchor), containerView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor), - containerView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor), + containerView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor) ]) - + selectionStyle = .none } - + required init?(coder: NSCoder) { super.init(coder: coder) } - - @objc func configure(title: String? = nil, body: String? = nil, buttonTitle: String? = nil, buttonAction: @escaping Action) { + + @objc func configure( + title: String? = nil, + body: String? = nil, + buttonTitle: String? = nil, + buttonAction: @escaping Action + ) { if LocationManager.shared.locationAccessDenied { configureForDeniedLocationAccess() return @@ -91,19 +96,20 @@ class RegistrationCell: UITableViewCell { regTitle.text = title ?? regTitle.text regBody.text = body ?? regBody.text regButton.setTitle(buttonTitle ?? regButton.titleLabel?.text ?? "", for: .normal) - regButton.addAction(UIAction(handler: { _ in buttonAction() }), for: .touchUpInside) + regButton.addAction(UIAction(handler: { _ in buttonAction() }), for: .touchUpInside) } @objc func configureForDeniedLocationAccess() { let attributedString = NSMutableAttributedString(string: DisplayStrings.deniedLocationAccessMessage) - let boldRange = (DisplayStrings.deniedLocationAccessMessage as NSString).range(of: DisplayStrings.deniedLocationAccessMessageBoldText) + let boldRange = (DisplayStrings.deniedLocationAccessMessage as NSString) + .range(of: DisplayStrings.deniedLocationAccessMessageBoldText) let boldFont = UIFont.boldPalaceFont(ofSize: UIFont.systemFontSize) attributedString.addAttributes([NSAttributedString.Key.font: boldFont], range: boldRange) - + regTitle.text = regTitle.text regBody.attributedText = attributedString regButton.setTitle(DisplayStrings.openSettings, for: .normal) - + regButton.addAction(UIAction(handler: { _ in if let url = URL(string: UIApplication.openSettingsURLString) { if UIApplication.shared.canOpenURL(url) { diff --git a/Palace/Views/TPPRoundedButton.swift b/Palace/Views/TPPRoundedButton.swift index 050e7457c..9697b2b79 100644 --- a/Palace/Views/TPPRoundedButton.swift +++ b/Palace/Views/TPPRoundedButton.swift @@ -10,11 +10,15 @@ import UIKit private let TPPRoundedButtonPadding: CGFloat = 6.0 +// MARK: - TPPRoundedButtonType + @objc enum TPPRoundedButtonType: Int { case normal case clock } +// MARK: - TPPRoundedButton + @objc class TPPRoundedButton: UIButton { // Properties private var type: TPPRoundedButtonType { @@ -22,129 +26,136 @@ private let TPPRoundedButtonPadding: CGFloat = 6.0 updateViews() } } + private var endDate: Date? { didSet { updateViews() } } + private var isFromDetailView: Bool - + // UI Components - private let label: UILabel = UILabel() - private let iconView: UIImageView = UIImageView() - + private let label: UILabel = .init() + private let iconView: UIImageView = .init() + // Initializer init(type: TPPRoundedButtonType, endDate: Date?, isFromDetailView: Bool) { self.type = type self.endDate = endDate self.isFromDetailView = isFromDetailView - + super.init(frame: CGRect.zero) - + setupUI() } - - required init?(coder: NSCoder) { + + @available(*, unavailable) + required init?(coder _: NSCoder) { fatalError("init(coder:) has not been implemented") } // MARK: - Setter + @objc func setType(_ type: TPPRoundedButtonType) { self.type = type } - + @objc func setEndDate(_ date: NSDate?) { guard let convertedDate = date as Date? else { return } endDate = convertedDate } - + @objc func setFromDetailView(_ isFromDetailView: Bool) { self.isFromDetailView = isFromDetailView } - + // MARK: - UI + private func setupUI() { titleLabel?.font = UIFont.palaceFont(ofSize: 14) layer.borderColor = tintColor.cgColor layer.borderWidth = 1 layer.cornerRadius = 3 - - label.textColor = self.tintColor + + label.textColor = tintColor label.font = UIFont.palaceFont(ofSize: 9) - + addSubview(label) addSubview(iconView) } - + private func updateViews() { let padX = TPPRoundedButtonPadding + 2 let padY = TPPRoundedButtonPadding - - if (self.type == .normal || self.isFromDetailView) { + + if type == .normal || isFromDetailView { if isFromDetailView { - self.contentEdgeInsets = UIEdgeInsets(top: 8, left: 20, bottom: 8, right: 20) + contentEdgeInsets = UIEdgeInsets(top: 8, left: 20, bottom: 8, right: 20) } else { - self.contentEdgeInsets = UIEdgeInsets(top: padY, left: padX, bottom: padY, right: padX) + contentEdgeInsets = UIEdgeInsets(top: padY, left: padX, bottom: padY, right: padX) } - self.iconView.isHidden = true - self.label.isHidden = true + iconView.isHidden = true + label.isHidden = true } else { - self.iconView.image = UIImage.init(named: "Clock")?.withRenderingMode(.alwaysTemplate) - self.iconView.isHidden = false - self.label.isHidden = false - self.label.text = self.endDate?.timeUntilString(suffixType: .short) ?? "" - self.label.sizeToFit() - - self.iconView.frame = CGRect(x: padX, y: padY/2, width: 14, height: 14) - var frame = self.label.frame - frame.origin = CGPoint(x: self.iconView.center.x - frame.size.width/2, y: self.iconView.frame.maxY) - self.label.frame = frame - self.contentEdgeInsets = UIEdgeInsets(top: padY, left: self.iconView.frame.maxX + padX, bottom: padY, right: padX) + iconView.image = UIImage(named: "Clock")?.withRenderingMode(.alwaysTemplate) + iconView.isHidden = false + label.isHidden = false + label.text = endDate?.timeUntilString(suffixType: .short) ?? "" + label.sizeToFit() + + iconView.frame = CGRect(x: padX, y: padY / 2, width: 14, height: 14) + var frame = label.frame + frame.origin = CGPoint(x: iconView.center.x - frame.size.width / 2, y: iconView.frame.maxY) + label.frame = frame + contentEdgeInsets = UIEdgeInsets(top: padY, left: iconView.frame.maxX + padX, bottom: padY, right: padX) } } - + private func updateColors() { - let color: UIColor = self.isEnabled ? self.tintColor : UIColor.gray - self.layer.borderColor = color.cgColor - self.label.textColor = color - self.iconView.tintColor = color + let color: UIColor = isEnabled ? tintColor : UIColor.gray + layer.borderColor = color.cgColor + label.textColor = color + iconView.tintColor = color setTitleColor(color, for: .normal) } - + // Override UIView functions override var isEnabled: Bool { didSet { updateColors() } } - + override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { - if (!self.isEnabled - && self.point(inside: self.convert(point, to: self), with: event)) { + if !isEnabled + && self.point(inside: convert(point, to: self), with: event) + { return self } return super.hitTest(point, with: event) } - + override func sizeThatFits(_ size: CGSize) -> CGSize { var s = super.sizeThatFits(size) s.width += TPPRoundedButtonPadding * 2 return s } - + override func tintColorDidChange() { super.tintColorDidChange() updateColors() } - + override var accessibilityLabel: String? { get { - guard !self.iconView.isHidden, - let title = self.titleLabel?.text, - let timeUntilString = self.endDate?.timeUntilString(suffixType: .long) else { - return self.titleLabel?.text + guard !iconView.isHidden, + let title = titleLabel?.text, + let timeUntilString = endDate?.timeUntilString(suffixType: .long) + else { + return titleLabel?.text } return "\(title).\(timeUntilString) remaining." } @@ -153,7 +164,7 @@ private let TPPRoundedButtonPadding: CGFloat = 6.0 } extension TPPRoundedButton { - @objc (initWithType:isFromDetailView:) + @objc(initWithType:isFromDetailView:) convenience init(type: TPPRoundedButtonType, isFromDetailView: Bool) { self.init(type: type, endDate: nil, isFromDetailView: isFromDetailView) } diff --git a/PalaceTests/AudiobookBookmarkBusinessLogicTests.swift b/PalaceTests/AudiobookBookmarkBusinessLogicTests.swift index 70faf30dc..a391fa39d 100644 --- a/PalaceTests/AudiobookBookmarkBusinessLogicTests.swift +++ b/PalaceTests/AudiobookBookmarkBusinessLogicTests.swift @@ -11,27 +11,26 @@ import XCTest @testable import PalaceAudiobookToolkit class AudiobookBookmarkBusinessLogicTests: XCTestCase { - var sut: AudiobookBookmarkBusinessLogic! var mockAnnotations: TPPAnnotationMock! var mockRegistry: TPPBookRegistryMock! let bookIdentifier = "fakeEpub" var fakeBook: TPPBook! - + let testID = "TestID" - + func loadTracks(for manifestJSON: ManifestJSON) throws -> Tracks { let manifest = try Manifest.from(jsonFileName: manifestJSON.rawValue, bundle: Bundle(for: type(of: self))) return Tracks(manifest: manifest, audiobookID: testID, token: nil) } - + let manifestJSON: ManifestJSON = .snowcrash - + var tracks: Tracks! - + override func setUp() { super.setUp() - + let emptyUrl = URL(fileURLWithPath: "") let fakeAcquisition = TPPOPDSAcquisition( relation: .generic, @@ -40,7 +39,7 @@ class AudiobookBookmarkBusinessLogicTests: XCTestCase { indirectAcquisitions: [TPPOPDSIndirectAcquisition](), availability: TPPOPDSAcquisitionAvailabilityUnlimited() ) - + fakeBook = TPPBook( acquisitions: [fakeAcquisition], authors: [TPPBookAuthor](), @@ -68,52 +67,51 @@ class AudiobookBookmarkBusinessLogicTests: XCTestCase { bookDuration: nil, imageCache: MockImageCache() ) - } - + func testSaveListeningPosition() { mockRegistry = TPPBookRegistryMock() mockRegistry.addBook(fakeBook, state: .downloadSuccessful) mockAnnotations = TPPAnnotationMock() - + tracks = try! loadTracks(for: manifestJSON) - + let expectation = XCTestExpectation(description: "SaveListeningPosition") sut = AudiobookBookmarkBusinessLogic(book: fakeBook, registry: mockRegistry, annotationsManager: mockAnnotations) - + let localTestBookmark = TrackPosition(track: tracks.tracks[0], timestamp: 1000, tracks: tracks) let expectedString = localTestBookmark.toAudioBookmark().toTPPBookLocation()!.locationString - sut.saveListeningPosition(at: localTestBookmark) { result in + sut.saveListeningPosition(at: localTestBookmark) { _ in expectation.fulfill() - + let savedString = self.mockAnnotations.savedLocations[self.fakeBook.identifier]?.first?.value ?? "" - + let expectedData = expectedString.data(using: .utf8)! let savedData = savedString.data(using: .utf8)! var expectedDict = try! JSONSerialization.jsonObject(with: expectedData, options: []) as! [String: Any] var savedDict = try! JSONSerialization.jsonObject(with: savedData, options: []) as! [String: Any] - + expectedDict.removeValue(forKey: "timeStamp") savedDict.removeValue(forKey: "timeStamp") - + XCTAssertTrue((expectedDict as NSDictionary) == (savedDict as NSDictionary)) } wait(for: [expectation], timeout: 5.0) } - + func testSaveBookmark() { mockRegistry = TPPBookRegistryMock() mockRegistry.addBook(fakeBook, state: .downloadSuccessful) mockAnnotations = TPPAnnotationMock() - + tracks = try! loadTracks(for: manifestJSON) - + let expectation = XCTestExpectation(description: "SaveBookmark") sut = AudiobookBookmarkBusinessLogic(book: fakeBook, registry: mockRegistry, annotationsManager: mockAnnotations) - + let position = TrackPosition(track: tracks.tracks[0], timestamp: 1000, tracks: tracks) XCTAssertTrue(position.lastSavedTimeStamp.isEmpty) - + sut.saveBookmark(at: position) { bookmark in expectation.fulfill() XCTAssertNotNil(bookmark) @@ -122,71 +120,84 @@ class AudiobookBookmarkBusinessLogicTests: XCTestCase { } wait(for: [expectation], timeout: 5.0) } - + func testFetchBookmarksDuplicate_LocalAndRemote() { mockRegistry = TPPBookRegistryMock() mockRegistry.addBook(fakeBook, state: .downloadSuccessful) mockAnnotations = TPPAnnotationMock() - + tracks = try! loadTracks(for: manifestJSON) - + let expectation = XCTestExpectation(description: "FetchAllBookmarks") - + var localTestBookmark = TrackPosition(track: tracks.tracks[0], timestamp: 1000, tracks: tracks) localTestBookmark.annotationId = "TestannotationId1" - var localTestBookmarkTwo = TrackPosition(track: tracks.tracks[1], timestamp: 111000, tracks: tracks) + var localTestBookmarkTwo = TrackPosition(track: tracks.tracks[1], timestamp: 111_000, tracks: tracks) localTestBookmarkTwo.annotationId = "TestannotationId2" - + let registryTestBookmarks = [localTestBookmark, localTestBookmarkTwo] - + var testBookmark = TrackPosition(track: tracks.tracks[0], timestamp: 1000, tracks: tracks) testBookmark.annotationId = "TestannotationId1" var testBookmarkThree = TrackPosition(track: tracks.tracks[2], timestamp: 1000, tracks: tracks) testBookmarkThree.annotationId = "TestannotationId3" - + let remoteTestBookmarks = [testBookmark, testBookmarkThree] let expectedBookmarks = [localTestBookmark, localTestBookmarkTwo, testBookmarkThree] - + // Preload registry data - mockRegistry.preloadData(bookIdentifier: fakeBook.identifier, locations: registryTestBookmarks.compactMap { $0.toAudioBookmark().toTPPBookLocation() }) - + mockRegistry.preloadData( + bookIdentifier: fakeBook.identifier, + locations: registryTestBookmarks.compactMap { $0.toAudioBookmark().toTPPBookLocation() } + ) + // Setup mock annotations - let remoteBookmarks = remoteTestBookmarks.compactMap { TestBookmark(annotationId: $0.annotationId, value: $0.toAudioBookmark().toTPPBookLocation()!.locationString) } - + let remoteBookmarks = remoteTestBookmarks.compactMap { TestBookmark( + annotationId: $0.annotationId, + value: $0.toAudioBookmark().toTPPBookLocation()!.locationString + ) } + mockAnnotations.bookmarks = [fakeBook.identifier: remoteBookmarks] - + // Initialize the system under test (sut) sut = AudiobookBookmarkBusinessLogic(book: fakeBook, registry: mockRegistry, annotationsManager: mockAnnotations) - + // Ensure the fetchBookmarks function is called correctly - sut.fetchBookmarks(for: tracks, toc: [Chapter(title: "", position: localTestBookmark, duration: 10.0)]) { bookmarks in + sut.fetchBookmarks(for: tracks, toc: [Chapter( + title: "", + position: localTestBookmark, + duration: 10.0 + )]) { bookmarks in XCTAssertEqual(bookmarks.count, expectedBookmarks.count) expectedBookmarks.forEach { expectedBookmark in XCTAssertFalse(bookmarks.filter { $0 == expectedBookmark }.isEmpty) } expectation.fulfill() } - + wait(for: [expectation], timeout: 10.0) } - + func testFetchBookmarks_localOnly() { mockRegistry = TPPBookRegistryMock() mockRegistry.addBook(fakeBook, state: .downloadSuccessful) mockAnnotations = TPPAnnotationMock() - + tracks = try! loadTracks(for: manifestJSON) - + let expectation = XCTestExpectation(description: "FetchLocalBookmarks") - + let localTestBookmark = TrackPosition(track: tracks.tracks[0], timestamp: 1000, tracks: tracks) let localTestBookmarkThree = TrackPosition(track: tracks.tracks[2], timestamp: 1000, tracks: tracks) let registryTestBookmarks = [localTestBookmark, localTestBookmarkThree] let expectedBookmarks = [localTestBookmark, localTestBookmarkThree] - - mockRegistry.preloadData(bookIdentifier: fakeBook.identifier, locations: registryTestBookmarks.compactMap { $0.toAudioBookmark().toTPPBookLocation() }) + + mockRegistry.preloadData( + bookIdentifier: fakeBook.identifier, + locations: registryTestBookmarks.compactMap { $0.toAudioBookmark().toTPPBookLocation() } + ) mockAnnotations.bookmarks = [fakeBook.identifier: []] - + sut = AudiobookBookmarkBusinessLogic(book: fakeBook, registry: mockRegistry, annotationsManager: mockAnnotations) sut.fetchBookmarks(for: tracks, toc: []) { bookmarks in XCTAssertEqual(bookmarks.count, expectedBookmarks.count) @@ -195,31 +206,34 @@ class AudiobookBookmarkBusinessLogicTests: XCTestCase { } expectation.fulfill() } - + wait(for: [expectation], timeout: 5.0) } - + func testFetchBookmarks_RemoteOnly() { mockRegistry = TPPBookRegistryMock() mockRegistry.addBook(fakeBook, state: .downloadSuccessful) mockAnnotations = TPPAnnotationMock() - + tracks = try! loadTracks(for: manifestJSON) - + let expectation = XCTestExpectation(description: "FetchRemoteBookmarks") - + var testBookmark = TrackPosition(track: tracks.tracks[0], timestamp: 1000, tracks: tracks) testBookmark.annotationId = "testBookmarkTwo" - var testBookmarkTwo = TrackPosition(track: tracks.tracks[1], timestamp: 111000, tracks: tracks) + var testBookmarkTwo = TrackPosition(track: tracks.tracks[1], timestamp: 111_000, tracks: tracks) testBookmarkTwo.annotationId = "testBookmarkThree" let remoteTestBookmarks = [testBookmark, testBookmarkTwo] let expectedBookmarks = [testBookmark, testBookmarkTwo] - - let remoteBookmarks = remoteTestBookmarks.compactMap { TestBookmark(annotationId: $0.annotationId, value: $0.toAudioBookmark().toTPPBookLocation()!.locationString) } + + let remoteBookmarks = remoteTestBookmarks.compactMap { TestBookmark( + annotationId: $0.annotationId, + value: $0.toAudioBookmark().toTPPBookLocation()!.locationString + ) } mockAnnotations.bookmarks = [fakeBook.identifier: remoteBookmarks] - + sut = AudiobookBookmarkBusinessLogic(book: fakeBook, registry: mockRegistry, annotationsManager: mockAnnotations) - + sut.fetchBookmarks(for: tracks, toc: []) { bookmarks in XCTAssertEqual(bookmarks.count, expectedBookmarks.count) expectedBookmarks.forEach { expectedBookmark in @@ -227,202 +241,226 @@ class AudiobookBookmarkBusinessLogicTests: XCTestCase { } expectation.fulfill() } - + wait(for: [expectation], timeout: 5.0) } - - + func testBookmarkSync_RemoteToLocal() { mockRegistry = TPPBookRegistryMock() mockRegistry.addBook(fakeBook, state: .downloadSuccessful) mockAnnotations = TPPAnnotationMock() - + tracks = try! loadTracks(for: manifestJSON) - + let expectation = XCTestExpectation(description: "SyncRemoteBookmarks") - + let localTestBookmark = TrackPosition(track: tracks.tracks[0], timestamp: 1000, tracks: tracks) let registryTestBookmarks: [TrackPosition] = [localTestBookmark] - var testBookmarkTwo = TrackPosition(track: tracks.tracks[1], timestamp: 111000, tracks: tracks) + var testBookmarkTwo = TrackPosition(track: tracks.tracks[1], timestamp: 111_000, tracks: tracks) testBookmarkTwo.annotationId = "testBookmarkTwo" var testBookmarkThree = TrackPosition(track: tracks.tracks[2], timestamp: 1000, tracks: tracks) testBookmarkThree.annotationId = "testBookmarkThree" let remoteTestBookmarks: [TrackPosition] = [testBookmarkTwo, testBookmarkThree] let expectedLocalBookmarks = [localTestBookmark, testBookmarkTwo, testBookmarkThree] - - mockRegistry.preloadData(bookIdentifier: fakeBook.identifier, locations: registryTestBookmarks.compactMap { $0.toAudioBookmark().toTPPBookLocation() }) - let remoteBookmarks = remoteTestBookmarks.compactMap { TestBookmark(annotationId: $0.annotationId, value: $0.toAudioBookmark().toTPPBookLocation()!.locationString) } + + mockRegistry.preloadData( + bookIdentifier: fakeBook.identifier, + locations: registryTestBookmarks.compactMap { $0.toAudioBookmark().toTPPBookLocation() } + ) + let remoteBookmarks = remoteTestBookmarks.compactMap { TestBookmark( + annotationId: $0.annotationId, + value: $0.toAudioBookmark().toTPPBookLocation()!.locationString + ) } mockAnnotations.bookmarks = [fakeBook.identifier: remoteBookmarks] - + sut = AudiobookBookmarkBusinessLogic(book: fakeBook, registry: mockRegistry, annotationsManager: mockAnnotations) sut.syncBookmarks(localBookmarks: registryTestBookmarks.compactMap { $0.toAudioBookmark() }) { _ in DispatchQueue.main.async { - let localBookmarks = self.mockRegistry.genericBookmarksForIdentifier(self.fakeBook.identifier) - + XCTAssertEqual(localBookmarks.count, expectedLocalBookmarks.count) expectedLocalBookmarks.forEach { expectedBookmark in - XCTAssertFalse(localBookmarks.filter { $0.locationString == expectedBookmark.toAudioBookmark().toTPPBookLocation()?.locationString }.isEmpty) + XCTAssertFalse(localBookmarks + .filter { $0.locationString == expectedBookmark.toAudioBookmark().toTPPBookLocation()?.locationString } + .isEmpty + ) } } } - + expectation.fulfill() wait(for: [expectation], timeout: 5.0) } - + func testBookmarkSync_LocalToRemote() { mockRegistry = TPPBookRegistryMock() mockRegistry.addBook(fakeBook, state: .downloadSuccessful) mockAnnotations = TPPAnnotationMock() - + tracks = try! loadTracks(for: manifestJSON) - + let expectation = XCTestExpectation(description: "SyncLocalBookmarks") - + let localTestBookmark = TrackPosition(track: tracks.tracks[0], timestamp: 1000, tracks: tracks) - let localTestBookmarkTwo = TrackPosition(track: tracks.tracks[1], timestamp: 111000, tracks: tracks) + let localTestBookmarkTwo = TrackPosition(track: tracks.tracks[1], timestamp: 111_000, tracks: tracks) let localTestBookmarkThree = TrackPosition(track: tracks.tracks[2], timestamp: 1000, tracks: tracks) let registryTestBookmarks: [TrackPosition] = [localTestBookmark, localTestBookmarkTwo, localTestBookmarkThree] let remoteTestBookmarks: [TrackPosition] = [] - - mockRegistry.preloadData(bookIdentifier: fakeBook.identifier, locations: registryTestBookmarks.compactMap { $0.toAudioBookmark().toTPPBookLocation() }) - let remoteBookmarks = remoteTestBookmarks.compactMap { TestBookmark(annotationId: $0.annotationId, value: $0.toAudioBookmark().toTPPBookLocation()!.locationString) } + + mockRegistry.preloadData( + bookIdentifier: fakeBook.identifier, + locations: registryTestBookmarks.compactMap { $0.toAudioBookmark().toTPPBookLocation() } + ) + let remoteBookmarks = remoteTestBookmarks.compactMap { TestBookmark( + annotationId: $0.annotationId, + value: $0.toAudioBookmark().toTPPBookLocation()!.locationString + ) } mockAnnotations.bookmarks = [fakeBook.identifier: remoteBookmarks] - + sut = AudiobookBookmarkBusinessLogic(book: fakeBook, registry: mockRegistry, annotationsManager: mockAnnotations) sut.syncBookmarks(localBookmarks: registryTestBookmarks.compactMap { $0.toAudioBookmark() }) { _ in - - let remoteBookmarks = self.mockAnnotations.bookmarks[self.fakeBook.identifier]?.compactMap { $0.value } ?? [] + let remoteBookmarks = self.mockAnnotations.bookmarks[self.fakeBook.identifier]?.compactMap(\.value) ?? [] XCTAssertEqual(remoteBookmarks.count, registryTestBookmarks.count) expectation.fulfill() } - + wait(for: [expectation], timeout: 5.0) } - + func testDeleteBookmark_localOnly() { mockRegistry = TPPBookRegistryMock() mockRegistry.addBook(fakeBook, state: .downloadSuccessful) mockAnnotations = TPPAnnotationMock() - + tracks = try! loadTracks(for: manifestJSON) - + let expectation = XCTestExpectation(description: "DeleteLocalBookmarks") - + let localTestBookmark = TrackPosition(track: tracks.tracks[0], timestamp: 1000, tracks: tracks) - let localTestBookmarkTwo = TrackPosition(track: tracks.tracks[1], timestamp: 111000, tracks: tracks) + let localTestBookmarkTwo = TrackPosition(track: tracks.tracks[1], timestamp: 111_000, tracks: tracks) let localTestBookmarkThree = TrackPosition(track: tracks.tracks[2], timestamp: 1000, tracks: tracks) let deletedBookmark = localTestBookmarkTwo let registryTestBookmarks: [TrackPosition] = [localTestBookmark, deletedBookmark, localTestBookmarkThree] let remoteTestBookmarks: [TrackPosition] = [] let expectedLocalBookmarks = [localTestBookmark, localTestBookmarkThree] - - mockRegistry.preloadData(bookIdentifier: fakeBook.identifier, locations: registryTestBookmarks.compactMap { $0.toAudioBookmark().toTPPBookLocation() }) - let remoteBookmarks = remoteTestBookmarks.compactMap { TestBookmark(annotationId: $0.annotationId, value: $0.toAudioBookmark().toTPPBookLocation()!.locationString) } + + mockRegistry.preloadData( + bookIdentifier: fakeBook.identifier, + locations: registryTestBookmarks.compactMap { $0.toAudioBookmark().toTPPBookLocation() } + ) + let remoteBookmarks = remoteTestBookmarks.compactMap { TestBookmark( + annotationId: $0.annotationId, + value: $0.toAudioBookmark().toTPPBookLocation()!.locationString + ) } mockAnnotations.bookmarks = [fakeBook.identifier: remoteBookmarks] - + sut = AudiobookBookmarkBusinessLogic(book: fakeBook, registry: mockRegistry, annotationsManager: mockAnnotations) sut.deleteBookmark(at: deletedBookmark) { success in XCTAssertTrue(success) let localBookmarks = self.mockRegistry.genericBookmarksForIdentifier(self.fakeBook.identifier) - + XCTAssertEqual(localBookmarks.count, expectedLocalBookmarks.count) expectedLocalBookmarks.forEach { expectedBookmark in let expectedString = expectedBookmark.toAudioBookmark().toTPPBookLocation()?.locationString ?? "" - + let matchingLocalBookmarks = localBookmarks.filter { localBookmark in let savedString = localBookmark.locationString - + let expectedData = expectedString.data(using: .utf8)! let savedData = savedString.data(using: .utf8)! let expectedDict = try! JSONSerialization.jsonObject(with: expectedData, options: []) as! [String: Any] let savedDict = try! JSONSerialization.jsonObject(with: savedData, options: []) as! [String: Any] - + return (expectedDict as NSDictionary) == (savedDict as NSDictionary) } - + XCTAssertFalse(matchingLocalBookmarks.isEmpty) } - + expectation.fulfill() } - + wait(for: [expectation], timeout: 5.0) } - + func testDeleteBookmark_localAndRemote() { mockRegistry = TPPBookRegistryMock() mockRegistry.addBook(fakeBook, state: .downloadSuccessful) mockAnnotations = TPPAnnotationMock() - + tracks = try! loadTracks(for: manifestJSON) - + let expectation = XCTestExpectation(description: "DeleteLocalAndRemoteBookmarks") - + var testBookmark = TrackPosition(track: tracks.tracks[0], timestamp: 1000, tracks: tracks) testBookmark.annotationId = "TestannotationId1" - - var testBookmarkTwo = TrackPosition(track: tracks.tracks[1], timestamp: 111000, tracks: tracks) + + var testBookmarkTwo = TrackPosition(track: tracks.tracks[1], timestamp: 111_000, tracks: tracks) testBookmarkTwo.annotationId = "TestannotationId2" let localTestBookmarkThree = TrackPosition(track: tracks.tracks[2], timestamp: 1000, tracks: tracks) - + let registryTestBookmarks: [TrackPosition] = [testBookmark, testBookmarkTwo, localTestBookmarkThree] let remoteTestBookmarks: [TrackPosition] = [testBookmark, testBookmarkTwo] let expectedLocalBookmarks = [testBookmark, localTestBookmarkThree] let expectedRemoteBookmarks = [testBookmark] - - mockRegistry.preloadData(bookIdentifier: fakeBook.identifier, locations: registryTestBookmarks.compactMap { $0.toAudioBookmark().toTPPBookLocation() }) - let remoteBookmarks = remoteTestBookmarks.compactMap { TestBookmark(annotationId: $0.annotationId, value: $0.toAudioBookmark().toTPPBookLocation()!.locationString) } + + mockRegistry.preloadData( + bookIdentifier: fakeBook.identifier, + locations: registryTestBookmarks.compactMap { $0.toAudioBookmark().toTPPBookLocation() } + ) + let remoteBookmarks = remoteTestBookmarks.compactMap { TestBookmark( + annotationId: $0.annotationId, + value: $0.toAudioBookmark().toTPPBookLocation()!.locationString + ) } mockAnnotations.bookmarks = [fakeBook.identifier: remoteBookmarks] - + sut = AudiobookBookmarkBusinessLogic(book: fakeBook, registry: mockRegistry, annotationsManager: mockAnnotations) sut.deleteBookmark(at: testBookmarkTwo) { success in XCTAssertTrue(success) - + let localBookmarks = self.mockRegistry.genericBookmarksForIdentifier(self.fakeBook.identifier) XCTAssertEqual(localBookmarks.count, expectedLocalBookmarks.count) - + expectedLocalBookmarks.forEach { expectedBookmark in let expectedString = expectedBookmark.toAudioBookmark().toTPPBookLocation()?.locationString ?? "" - + let matchingLocalBookmarks = localBookmarks.filter { localBookmark in let savedString = localBookmark.locationString - + let expectedData = expectedString.data(using: .utf8)! let savedData = savedString.data(using: .utf8)! let expectedDict = try! JSONSerialization.jsonObject(with: expectedData, options: []) as! [String: Any] let savedDict = try! JSONSerialization.jsonObject(with: savedData, options: []) as! [String: Any] - + return (expectedDict as NSDictionary) == (savedDict as NSDictionary) } - + XCTAssertFalse(matchingLocalBookmarks.isEmpty) } - - let remoteBookmarks = self.mockAnnotations.bookmarks[self.fakeBook.identifier]?.compactMap { $0.value } ?? [] + + let remoteBookmarks = self.mockAnnotations.bookmarks[self.fakeBook.identifier]?.compactMap(\.value) ?? [] XCTAssertEqual(remoteBookmarks.count, expectedRemoteBookmarks.count) - + expectedRemoteBookmarks.forEach { expectedBookmark in let expectedString = expectedBookmark.toAudioBookmark().toTPPBookLocation()?.locationString ?? "" - + let matchingRemoteBookmarks = localBookmarks.filter { localBookmark in let savedString = localBookmark.locationString - + let expectedData = expectedString.data(using: .utf8)! let savedData = savedString.data(using: .utf8)! let expectedDict = try! JSONSerialization.jsonObject(with: expectedData, options: []) as! [String: Any] let savedDict = try! JSONSerialization.jsonObject(with: savedData, options: []) as! [String: Any] - + return (expectedDict as NSDictionary) == (savedDict as NSDictionary) } - + XCTAssertFalse(matchingRemoteBookmarks.isEmpty) } - + expectation.fulfill() } - + wait(for: [expectation], timeout: 5.0) } } diff --git a/PalaceTests/AudiobookOptimizationIntegrationTests.swift b/PalaceTests/AudiobookOptimizationIntegrationTests.swift index 4b70bad07..c238b0ea4 100644 --- a/PalaceTests/AudiobookOptimizationIntegrationTests.swift +++ b/PalaceTests/AudiobookOptimizationIntegrationTests.swift @@ -5,194 +5,193 @@ // Copyright © 2024 The Palace Project. All rights reserved. // +import PalaceAudiobookToolkit import XCTest @testable import Palace -import PalaceAudiobookToolkit final class AudiobookOptimizationIntegrationTests: XCTestCase { - - var coordinator: AudiobookOptimizationCoordinator! - var performanceMonitor: AudiobookPerformanceMonitor! - - override func setUp() { - super.setUp() - coordinator = AudiobookOptimizationCoordinator.shared - performanceMonitor = AudiobookPerformanceMonitor.shared - } - - override func tearDown() { - coordinator = nil - performanceMonitor = nil - super.tearDown() - } - - // MARK: - System Integration Tests - - func testOptimizationCoordinatorInitialization() { - // Given: Optimization coordinator - // When: Initialize optimizations - XCTAssertNoThrow(coordinator.initializeOptimizations()) - - // Then: Should complete without errors - XCTAssertNotNil(coordinator) - } - - func testPerformanceMonitorProvideStatus() { - // Given: Performance monitor - // When: Get current status - let status = performanceMonitor.getCurrentPerformanceStatus() - - // Then: Should provide meaningful status - XCTAssertNotNil(status["memoryUsageMB"]) - XCTAssertNotNil(status["networkType"]) - XCTAssertNotNil(status["isLowMemoryDevice"]) - XCTAssertNotNil(status["maxConcurrentDownloads"]) - } - - func testSystemStatusComprehensive() { - // Given: Coordinator - // When: Get system status - let status = coordinator.getSystemStatus() - - // Then: Should provide comprehensive status - XCTAssertNotNil(status["optimizationSystemsActive"]) - XCTAssertNotNil(status["networkType"]) - XCTAssertNotNil(status["estimatedBandwidth"]) - XCTAssertNotNil(status["memoryUsageMB"]) - } - - func testForceOptimizationDoesNotCrash() { - // Given: Performance monitor - // When: Force optimization - XCTAssertNoThrow(performanceMonitor.forceOptimization()) - - // Then: Should complete without crashing - XCTAssertNotNil(performanceMonitor) - } - - // MARK: - Component Integration Tests - - func testAllComponentsSingletons() { - // Given: Multiple references to shared instances - let memoryManager1 = AdaptiveMemoryManager.shared - let memoryManager2 = AdaptiveMemoryManager.shared - let networkAdapter1 = NetworkConditionAdapter.shared - let networkAdapter2 = NetworkConditionAdapter.shared - let streamingManager1 = AdaptiveStreamingManager.shared - let streamingManager2 = AdaptiveStreamingManager.shared - - // Then: Should be same instances (singletons) - XCTAssertTrue(memoryManager1 === memoryManager2) - XCTAssertTrue(networkAdapter1 === networkAdapter2) - XCTAssertTrue(streamingManager1 === streamingManager2) - } - - func testOptimizationSystemsWork() { - // Given: Mock audiobook table of contents - let mockTOC = createMockTableOfContents() - - // When: Apply optimizations - let optimized = coordinator.optimizeTableOfContents(mockTOC) - - // Then: Should return optimized version - XCTAssertNotNil(optimized) - XCTAssertGreaterThanOrEqual(optimized.toc.count, 0) - } - - // MARK: - Performance Validation Tests - - func testMemoryManagerProvidesReasonableValues() { - // Given: Memory manager - let memoryManager = AdaptiveMemoryManager.shared - - // Then: Should provide reasonable configuration values - XCTAssertGreaterThan(memoryManager.audioBufferSize, 0) - XCTAssertLessThan(memoryManager.audioBufferSize, 1024 * 1024) // < 1MB - XCTAssertGreaterThan(memoryManager.maxConcurrentDownloads, 0) - XCTAssertLessThanOrEqual(memoryManager.maxConcurrentDownloads, 10) - XCTAssertGreaterThan(memoryManager.cacheMemoryLimit, 0) - XCTAssertLessThan(memoryManager.cacheMemoryLimit, 100 * 1024 * 1024) // < 100MB - } - - func testNetworkAdapterProvidesValidConfiguration() { - // Given: Network adapter - let networkAdapter = NetworkConditionAdapter.shared - - // When: Get configuration - let config = networkAdapter.currentConfiguration() - - // Then: Should provide valid configuration - XCTAssertGreaterThan(config.httpMaximumConnectionsPerHost, 0) - XCTAssertLessThanOrEqual(config.httpMaximumConnectionsPerHost, 10) - XCTAssertGreaterThan(config.timeoutIntervalForRequest, 0) - XCTAssertLessThan(config.timeoutIntervalForRequest, 120) // < 2 minutes - } - - func testStreamingManagerProvidesValidQuality() { - // Given: Streaming manager - let streamingManager = AdaptiveStreamingManager.shared - - // When: Configure quality - let quality = streamingManager.configureStreamingQuality() - - // Then: Should provide valid quality - XCTAssertTrue(quality.rawValue >= 0) - if quality != .adaptive { - XCTAssertLessThanOrEqual(quality.rawValue, 512) // Reasonable max bitrate - } - } - - // MARK: - Helper Methods - - private func createMockTableOfContents() -> AudiobookTableOfContents { - let manifest = createMockManifest() - let tracks = createMockTracks(for: manifest) - return AudiobookTableOfContents(manifest: manifest, tracks: tracks) - } - - private func createMockManifest() -> Manifest { - let metadata = Manifest.Metadata( - title: "Integration Test Book", - identifier: "integration-test", - language: "en" - ) - - let readingOrder = [ - Manifest.ReadingOrderItem( - href: "chapter1.mp3", - type: "audio/mpeg", - title: "Chapter 1", - duration: 300, - findawayPart: nil, - findawaySequence: nil, - properties: nil - ), - Manifest.ReadingOrderItem( - href: "chapter2.mp3", - type: "audio/mpeg", - title: "Chapter 2", - duration: 400, - findawayPart: nil, - findawaySequence: nil, - properties: nil - ) - ] - - return Manifest( - context: [.other("https://readium.org/webpub-manifest/context.jsonld")], - id: "integration-test", - metadata: metadata, - readingOrder: readingOrder, - toc: nil, - spine: nil, - links: nil, - linksDictionary: nil, - resources: nil, - formatType: nil - ) - } - - private func createMockTracks(for manifest: Manifest) -> Tracks { - return Tracks(manifest: manifest, audiobookID: "integration-test", token: nil) + var coordinator: AudiobookOptimizationCoordinator! + var performanceMonitor: AudiobookPerformanceMonitor! + + override func setUp() { + super.setUp() + coordinator = AudiobookOptimizationCoordinator.shared + performanceMonitor = AudiobookPerformanceMonitor.shared + } + + override func tearDown() { + coordinator = nil + performanceMonitor = nil + super.tearDown() + } + + // MARK: - System Integration Tests + + func testOptimizationCoordinatorInitialization() { + // Given: Optimization coordinator + // When: Initialize optimizations + XCTAssertNoThrow(coordinator.initializeOptimizations()) + + // Then: Should complete without errors + XCTAssertNotNil(coordinator) + } + + func testPerformanceMonitorProvideStatus() { + // Given: Performance monitor + // When: Get current status + let status = performanceMonitor.getCurrentPerformanceStatus() + + // Then: Should provide meaningful status + XCTAssertNotNil(status["memoryUsageMB"]) + XCTAssertNotNil(status["networkType"]) + XCTAssertNotNil(status["isLowMemoryDevice"]) + XCTAssertNotNil(status["maxConcurrentDownloads"]) + } + + func testSystemStatusComprehensive() { + // Given: Coordinator + // When: Get system status + let status = coordinator.getSystemStatus() + + // Then: Should provide comprehensive status + XCTAssertNotNil(status["optimizationSystemsActive"]) + XCTAssertNotNil(status["networkType"]) + XCTAssertNotNil(status["estimatedBandwidth"]) + XCTAssertNotNil(status["memoryUsageMB"]) + } + + func testForceOptimizationDoesNotCrash() { + // Given: Performance monitor + // When: Force optimization + XCTAssertNoThrow(performanceMonitor.forceOptimization()) + + // Then: Should complete without crashing + XCTAssertNotNil(performanceMonitor) + } + + // MARK: - Component Integration Tests + + func testAllComponentsSingletons() { + // Given: Multiple references to shared instances + let memoryManager1 = AdaptiveMemoryManager.shared + let memoryManager2 = AdaptiveMemoryManager.shared + let networkAdapter1 = NetworkConditionAdapter.shared + let networkAdapter2 = NetworkConditionAdapter.shared + let streamingManager1 = AdaptiveStreamingManager.shared + let streamingManager2 = AdaptiveStreamingManager.shared + + // Then: Should be same instances (singletons) + XCTAssertTrue(memoryManager1 === memoryManager2) + XCTAssertTrue(networkAdapter1 === networkAdapter2) + XCTAssertTrue(streamingManager1 === streamingManager2) + } + + func testOptimizationSystemsWork() { + // Given: Mock audiobook table of contents + let mockTOC = createMockTableOfContents() + + // When: Apply optimizations + let optimized = coordinator.optimizeTableOfContents(mockTOC) + + // Then: Should return optimized version + XCTAssertNotNil(optimized) + XCTAssertGreaterThanOrEqual(optimized.toc.count, 0) + } + + // MARK: - Performance Validation Tests + + func testMemoryManagerProvidesReasonableValues() { + // Given: Memory manager + let memoryManager = AdaptiveMemoryManager.shared + + // Then: Should provide reasonable configuration values + XCTAssertGreaterThan(memoryManager.audioBufferSize, 0) + XCTAssertLessThan(memoryManager.audioBufferSize, 1024 * 1024) // < 1MB + XCTAssertGreaterThan(memoryManager.maxConcurrentDownloads, 0) + XCTAssertLessThanOrEqual(memoryManager.maxConcurrentDownloads, 10) + XCTAssertGreaterThan(memoryManager.cacheMemoryLimit, 0) + XCTAssertLessThan(memoryManager.cacheMemoryLimit, 100 * 1024 * 1024) // < 100MB + } + + func testNetworkAdapterProvidesValidConfiguration() { + // Given: Network adapter + let networkAdapter = NetworkConditionAdapter.shared + + // When: Get configuration + let config = networkAdapter.currentConfiguration() + + // Then: Should provide valid configuration + XCTAssertGreaterThan(config.httpMaximumConnectionsPerHost, 0) + XCTAssertLessThanOrEqual(config.httpMaximumConnectionsPerHost, 10) + XCTAssertGreaterThan(config.timeoutIntervalForRequest, 0) + XCTAssertLessThan(config.timeoutIntervalForRequest, 120) // < 2 minutes + } + + func testStreamingManagerProvidesValidQuality() { + // Given: Streaming manager + let streamingManager = AdaptiveStreamingManager.shared + + // When: Configure quality + let quality = streamingManager.configureStreamingQuality() + + // Then: Should provide valid quality + XCTAssertTrue(quality.rawValue >= 0) + if quality != .adaptive { + XCTAssertLessThanOrEqual(quality.rawValue, 512) // Reasonable max bitrate } + } + + // MARK: - Helper Methods + + private func createMockTableOfContents() -> AudiobookTableOfContents { + let manifest = createMockManifest() + let tracks = createMockTracks(for: manifest) + return AudiobookTableOfContents(manifest: manifest, tracks: tracks) + } + + private func createMockManifest() -> Manifest { + let metadata = Manifest.Metadata( + title: "Integration Test Book", + identifier: "integration-test", + language: "en" + ) + + let readingOrder = [ + Manifest.ReadingOrderItem( + href: "chapter1.mp3", + type: "audio/mpeg", + title: "Chapter 1", + duration: 300, + findawayPart: nil, + findawaySequence: nil, + properties: nil + ), + Manifest.ReadingOrderItem( + href: "chapter2.mp3", + type: "audio/mpeg", + title: "Chapter 2", + duration: 400, + findawayPart: nil, + findawaySequence: nil, + properties: nil + ) + ] + + return Manifest( + context: [.other("https://readium.org/webpub-manifest/context.jsonld")], + id: "integration-test", + metadata: metadata, + readingOrder: readingOrder, + toc: nil, + spine: nil, + links: nil, + linksDictionary: nil, + resources: nil, + formatType: nil + ) + } + + private func createMockTracks(for manifest: Manifest) -> Tracks { + Tracks(manifest: manifest, audiobookID: "integration-test", token: nil) + } } diff --git a/PalaceTests/AudiobookTrackerTests.swift b/PalaceTests/AudiobookTrackerTests.swift index 38973bd69..3710cc00a 100644 --- a/PalaceTests/AudiobookTrackerTests.swift +++ b/PalaceTests/AudiobookTrackerTests.swift @@ -6,41 +6,39 @@ // Copyright © 2024 The Palace Project. All rights reserved. // -import XCTest import Combine +import XCTest @testable import Palace +// MARK: - MockDataManager + class MockDataManager: DataManager { - var savedTimeEntries: [TimeEntry] = [] - + func save(time: TimeEntry) { savedTimeEntries.append(time) } - + func removeSynchronizedEntries(ids: [String]) { savedTimeEntries.removeAll { ids.contains($0.id) } } - - func saveStore() { - } - - func loadStore() { - } - - func cleanUpUrls() { - } - - func syncValues() { - } + + func saveStore() {} + + func loadStore() {} + + func cleanUpUrls() {} + + func syncValues() {} } +// MARK: - AudiobookTimeTrackerTests + class AudiobookTimeTrackerTests: XCTestCase { - var sut: AudiobookTimeTracker! var mockDataManager: MockDataManager! var currentDate: Date! - + override func setUp() { super.setUp() mockDataManager = MockDataManager() @@ -52,124 +50,137 @@ class AudiobookTimeTrackerTests: XCTestCase { dataManager: mockDataManager ) } - + override func tearDown() { sut = nil mockDataManager = nil currentDate = nil super.tearDown() } - + func testPlaybackStarted_savesCorrectAggregateTime() { - let expectation = self.expectation(description: "Aggregate time saved") - + let expectation = expectation(description: "Aggregate time saved") + for i in 0..<90 { let simulatedDate = Calendar.current.date(byAdding: .second, value: i, to: currentDate)! sut.receiveValue(simulatedDate) } - + sut = nil - + DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { let totalTimeSaved = self.mockDataManager.savedTimeEntries.reduce(0) { $0 + $1.duration } XCTAssertEqual(totalTimeSaved, 90, "Total time saved should be 90 seconds") expectation.fulfill() } - + wait(for: [expectation], timeout: 3.0) } func testTimeEntries_areLimitedTo60Seconds() { - let expectation = self.expectation(description: "Limit time entry duration to 60 seconds") - + let expectation = expectation(description: "Limit time entry duration to 60 seconds") + for i in 0..<70 { let simulatedDate = Calendar.current.date(byAdding: .second, value: i, to: currentDate)! sut.receiveValue(simulatedDate) } - + sut = nil - + DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) { - XCTAssertGreaterThanOrEqual(self.mockDataManager.savedTimeEntries.count, 2, "There should be at least 2 entries since the playback spanned 2 minutes.") - - XCTAssertLessThanOrEqual(self.mockDataManager.savedTimeEntries.first!.duration, 60, "First entry should be less than or equal to 60 seconds") - XCTAssertLessThanOrEqual(self.mockDataManager.savedTimeEntries.last!.duration, 60, "Last entry should be less than or equal to 60 seconds") - + XCTAssertGreaterThanOrEqual( + self.mockDataManager.savedTimeEntries.count, + 2, + "There should be at least 2 entries since the playback spanned 2 minutes." + ) + + XCTAssertLessThanOrEqual( + self.mockDataManager.savedTimeEntries.first!.duration, + 60, + "First entry should be less than or equal to 60 seconds" + ) + XCTAssertLessThanOrEqual( + self.mockDataManager.savedTimeEntries.last!.duration, + 60, + "Last entry should be less than or equal to 60 seconds" + ) + let total = self.mockDataManager.savedTimeEntries.reduce(0) { $0 + $1.duration } - + XCTAssertEqual(total, 70, "Total should equal 70 seconds") - + expectation.fulfill() } - + wait(for: [expectation], timeout: 5.0) } - - func testTimeEntries_areInUTC() { // Arrange - let expectation = self.expectation(description: "Time entries should be in UTC") - + let expectation = expectation(description: "Time entries should be in UTC") + // Simulate 60 seconds of playback for i in 0..<60 { let simulatedDate = Calendar.current.date(byAdding: .second, value: i, to: currentDate)! sut.receiveValue(simulatedDate) } - + DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) { let firstEntry = self.mockDataManager.savedTimeEntries.first XCTAssertNotNil(firstEntry, "Time entry should exist") XCTAssertTrue(firstEntry?.duringMinute.hasSuffix("Z") ?? false, "Time entry should be in UTC format") expectation.fulfill() } - + wait(for: [expectation], timeout: 5.0) } - + func testPlaybackStopped_stopsTimer() { sut.playbackStarted() - let expectation = self.expectation(description: "Timer stopped") - + let expectation = expectation(description: "Timer stopped") + DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) { self.sut.playbackStopped() let previousDuration = self.sut.timeEntry.duration - + DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) { XCTAssertEqual(self.sut.timeEntry.duration, previousDuration) expectation.fulfill() } } - + wait(for: [expectation], timeout: 10.0) } - + func testSaveCurrentDuration_savesTimeEntryCorrectly() { sut.playbackStarted() - let expectation = self.expectation(description: "Saved Time entry Correctly") + let expectation = expectation(description: "Saved Time entry Correctly") for i in 0..<59 { let simulatedDate = Calendar.current.date(byAdding: .second, value: i, to: currentDate)! sut.receiveValue(simulatedDate) } - + sut = nil - + DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { - - XCTAssertLessThanOrEqual(self.mockDataManager.savedTimeEntries.count, 2, "There should be less than or equal to 2 entries.") + XCTAssertLessThanOrEqual( + self.mockDataManager.savedTimeEntries.count, + 2, + "There should be less than or equal to 2 entries." + ) let total = self.mockDataManager.savedTimeEntries.reduce(0) { $0 + $1.duration } - + XCTAssertEqual(total, 60, "Total should be less than or equal to 60") - + XCTAssertEqual(self.mockDataManager.savedTimeEntries.first?.bookId, "book123") XCTAssertEqual(self.mockDataManager.savedTimeEntries.first?.libraryId, "library123") expectation.fulfill() } - + wait(for: [expectation], timeout: 3.0) } - + func testNoPlayback_savesNoTimeEntry() { sut.playbackStarted() sut.playbackStopped() @@ -179,23 +190,27 @@ class AudiobookTimeTrackerTests: XCTestCase { func testExactMinuteOfPlayback_savesCorrectTimeEntry() { sut.playbackStarted() - let expectation = self.expectation(description: "Saved Time entry Correctly") - + let expectation = expectation(description: "Saved Time entry Correctly") + for i in 0..<59 { let simulatedDate = Calendar.current.date(byAdding: .second, value: i, to: currentDate)! sut.receiveValue(simulatedDate) } - + sut = nil - + DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) { let total = self.mockDataManager.savedTimeEntries.reduce(0) { $0 + $1.duration } - - XCTAssertLessThanOrEqual(self.mockDataManager.savedTimeEntries.count, 2, "There should be less than or equal to 2 entries.") + + XCTAssertLessThanOrEqual( + self.mockDataManager.savedTimeEntries.count, + 2, + "There should be less than or equal to 2 entries." + ) XCTAssertEqual(total, 60, "Time entry should be for 60 seconds") expectation.fulfill() } - + wait(for: [expectation], timeout: 5.0) } } diff --git a/PalaceTests/AudiobookmarkTests.swift b/PalaceTests/AudiobookmarkTests.swift index 5e9ed5121..90965c887 100644 --- a/PalaceTests/AudiobookmarkTests.swift +++ b/PalaceTests/AudiobookmarkTests.swift @@ -10,78 +10,77 @@ import XCTest @testable import Palace final class AudiobookmarkTests: XCTestCase { - func testDecodeEarlyBookmark() throws { let earlyBookmarkJSON = """ - { - "@type": "LocatorAudioBookTime", - "time": 2199000, - "audiobookID": "urn:librarysimplified.org/terms/id/Overdrive ID/faf182e5-2f05-4729-b2cd-139d6bb0b19e", - "title": "Track 1", - "part": 0, - "duration": 3659000, - "chapter": 0 - } - """ - + { + "@type": "LocatorAudioBookTime", + "time": 2199000, + "audiobookID": "urn:librarysimplified.org/terms/id/Overdrive ID/faf182e5-2f05-4729-b2cd-139d6bb0b19e", + "title": "Track 1", + "part": 0, + "duration": 3659000, + "chapter": 0 + } + """ + let decoder = JSONDecoder() let locatorDict = try decoder.decode([String: AnyCodable].self, from: earlyBookmarkJSON.data(using: .utf8)!) let locator = locatorDict.mapValues { $0.value } let bookmark = AudioBookmark.create(locatorData: locator)! - - XCTAssertEqual(bookmark.time, 2199000) + + XCTAssertEqual(bookmark.time, 2_199_000) XCTAssertEqual(bookmark.type.rawValue, "LocatorAudioBookTime") XCTAssertEqual(bookmark.title, "Track 1") XCTAssertEqual(bookmark.part, 0) XCTAssertEqual(bookmark.chapter, "0") } - + func testDecodeNewerBookmark() throws { let newerBookmarkJSON = """ - { - "@type": "LocatorAudioBookTime", - "time": 2199000, - "audiobookID": "urn:librarysimplified.org/terms/id/Overdrive ID/faf182e5-2f05-4729-b2cd-139d6bb0b19e", - "title": "Track 1", - "part": 0, - "duration": 3659000, - "chapter": 0, - "startOffset": 0 - } - """ - + { + "@type": "LocatorAudioBookTime", + "time": 2199000, + "audiobookID": "urn:librarysimplified.org/terms/id/Overdrive ID/faf182e5-2f05-4729-b2cd-139d6bb0b19e", + "title": "Track 1", + "part": 0, + "duration": 3659000, + "chapter": 0, + "startOffset": 0 + } + """ + let decoder = JSONDecoder() let locatorDict = try decoder.decode([String: AnyCodable].self, from: newerBookmarkJSON.data(using: .utf8)!) let locator = locatorDict.mapValues { $0.value } let bookmark = AudioBookmark.create(locatorData: locator)! - - XCTAssertEqual(bookmark.time, 2199000) + + XCTAssertEqual(bookmark.time, 2_199_000) XCTAssertEqual(bookmark.type.rawValue, "LocatorAudioBookTime") XCTAssertEqual(bookmark.title, "Track 1") XCTAssertEqual(bookmark.part, 0) XCTAssertEqual(bookmark.chapter, "0") } - + func testDecodeLocatorAudioBookTime2() throws { let locatorAudioBookTime2JSON = """ - { - "readingOrderItem": "urn:uuid:ddf56790-60a7-413c-9771-7f7dcef2f565-0", - "readingOrderItemOffsetMilliseconds": 15823, - "@type": "LocatorAudioBookTime", - "@version": 2 - } - """ - + { + "readingOrderItem": "urn:uuid:ddf56790-60a7-413c-9771-7f7dcef2f565-0", + "readingOrderItemOffsetMilliseconds": 15823, + "@type": "LocatorAudioBookTime", + "@version": 2 + } + """ + let decoder = JSONDecoder() let locatorDict = try decoder.decode([String: AnyCodable].self, from: locatorAudioBookTime2JSON.data(using: .utf8)!) let locator = locatorDict.mapValues { $0.value } let bookmark = AudioBookmark.create(locatorData: locator)! - + XCTAssertEqual(bookmark.readingOrderItem, "urn:uuid:ddf56790-60a7-413c-9771-7f7dcef2f565-0") XCTAssertEqual(bookmark.readingOrderItemOffsetMilliseconds, 15823) XCTAssertEqual(bookmark.type.rawValue, "LocatorAudioBookTime") } - + func testEncodeAndDecodeBookmark() throws { let locator: [String: Any] = [ "readingOrderItem": "urn:uuid:ddf56790-60a7-413c-9771-7f7dcef2f565-0", @@ -89,11 +88,15 @@ final class AudiobookmarkTests: XCTestCase { "@type": "LocatorAudioBookTime", "@version": 2 ] - let bookmark = AudioBookmark.create(locatorData: locator, timeStamp: "2024-05-28T17:54:51Z", annotationId: "another-annotation-id")! - + let bookmark = AudioBookmark.create( + locatorData: locator, + timeStamp: "2024-05-28T17:54:51Z", + annotationId: "another-annotation-id" + )! + let data = try JSONEncoder().encode(bookmark) let decodedBookmark = try JSONDecoder().decode(AudioBookmark.self, from: data) - + XCTAssertEqual(decodedBookmark.readingOrderItem, "urn:uuid:ddf56790-60a7-413c-9771-7f7dcef2f565-0") XCTAssertEqual(decodedBookmark.readingOrderItemOffsetMilliseconds, 15823) XCTAssertEqual(decodedBookmark.type.rawValue, "LocatorAudioBookTime") diff --git a/PalaceTests/BookPreviewTests.swift b/PalaceTests/BookPreviewTests.swift index 6c91e6756..0b60ff202 100644 --- a/PalaceTests/BookPreviewTests.swift +++ b/PalaceTests/BookPreviewTests.swift @@ -17,7 +17,7 @@ class BookPreviewTests: XCTestCase { let book = TPPBook(dictionary: [ "acquisitions": acquisitions, "preview-url": sampleLink.dictionaryRepresentation(), - "categories" : ["Fantasy"], + "categories": ["Fantasy"], "id": "123", "title": "The Lord of the Rings", "updated": "2020-09-08T09:22:45Z" @@ -26,17 +26,16 @@ class BookPreviewTests: XCTestCase { XCTAssertNotNil(book?.acquisitions) XCTAssertNotNil(book?.previewLink) XCTAssertEqual(book!.previewLink?.hrefURL, sampleLink.hrefURL) - } - + func testOverdriveWebAudiobookExtraction() throws { let acquisitions = [TPPFake.genericAudiobookAcquisition.dictionaryRepresentation()] let sampleLink = TPPFake.overdriveWebAudiobookSample - + let book = TPPBook(dictionary: [ "acquisitions": acquisitions, - "preview-url": sampleLink.dictionaryRepresentation(), - "categories" : ["Fantasy"], + "preview-url": sampleLink.dictionaryRepresentation(), + "categories": ["Fantasy"], "id": "123", "title": "The Lord of the Rings", "updated": "2020-09-08T09:22:45Z" @@ -50,11 +49,11 @@ class BookPreviewTests: XCTestCase { func testOverdriveWaveAudiobookExtraction() throws { let acquisitions = [TPPFake.genericAudiobookAcquisition.dictionaryRepresentation()] let sampleLink = TPPFake.overdriveAudiobookWaveFile - + let book = TPPBook(dictionary: [ "acquisitions": acquisitions, - "preview-url": sampleLink.dictionaryRepresentation(), - "categories" : ["Fantasy"], + "preview-url": sampleLink.dictionaryRepresentation(), + "categories": ["Fantasy"], "id": "123", "title": "The Lord of the Rings", "updated": "2020-09-08T09:22:45Z" @@ -68,11 +67,11 @@ class BookPreviewTests: XCTestCase { func testOverdriveMPEGAudiobookExtraction() throws { let acquisitions = [TPPFake.genericAudiobookAcquisition.dictionaryRepresentation()] let sampleLink = TPPFake.overdriveAudiobookMPEG - + let book = TPPBook(dictionary: [ "acquisitions": acquisitions, - "preview-url": sampleLink.dictionaryRepresentation(), - "categories" : ["Fantasy"], + "preview-url": sampleLink.dictionaryRepresentation(), + "categories": ["Fantasy"], "id": "123", "title": "The Lord of the Rings", "updated": "2020-09-08T09:22:45Z" diff --git a/PalaceTests/Bookmarks/TPPBookmarkSpecTests.swift b/PalaceTests/Bookmarks/TPPBookmarkSpecTests.swift index f838930ed..71bb2d76a 100644 --- a/PalaceTests/Bookmarks/TPPBookmarkSpecTests.swift +++ b/PalaceTests/Bookmarks/TPPBookmarkSpecTests.swift @@ -11,12 +11,9 @@ import XCTest // TODO: SIMPLY-3645 class TPPBookmarkSpecTests: XCTestCase { + override func setUpWithError() throws {} - override func setUpWithError() throws { - } - - override func tearDownWithError() throws { - } + override func tearDownWithError() throws {} func testBookmarkMotivationKeyword() throws { XCTAssert( diff --git a/PalaceTests/ButtonStateTests.swift b/PalaceTests/ButtonStateTests.swift index 7cc2d8c8e..795af682b 100644 --- a/PalaceTests/ButtonStateTests.swift +++ b/PalaceTests/ButtonStateTests.swift @@ -1,4 +1,3 @@ - // // ButtonStateTests.swift // PalaceTests @@ -11,14 +10,14 @@ import XCTest @testable import Palace final class ButtonStateTests: XCTestCase { - private var testAudiobook: TPPBook { TPPBook(dictionary: [ "acquisitions": [TPPFake.genericAudiobookAcquisition.dictionaryRepresentation()], "title": "Tractatus", "categories": ["some cat"], "id": "123", - "updated": "2020-10-06T17:13:51Z"] + "updated": "2020-10-06T17:13:51Z" + ] )! } @@ -28,7 +27,8 @@ final class ButtonStateTests: XCTestCase { "title": "Tractatus", "categories": ["some cat"], "id": "123", - "updated": "2020-10-06T17:13:51Z"] + "updated": "2020-10-06T17:13:51Z" + ] )! } @@ -194,4 +194,3 @@ final class ButtonStateTests: XCTestCase { XCTAssertEqual(Set(expectedButtons), Set(resultButtons)) } } - diff --git a/PalaceTests/ChapterParsingOptimizerTests.swift b/PalaceTests/ChapterParsingOptimizerTests.swift index d5bc1e1ae..6011adb2e 100644 --- a/PalaceTests/ChapterParsingOptimizerTests.swift +++ b/PalaceTests/ChapterParsingOptimizerTests.swift @@ -5,289 +5,288 @@ // Copyright © 2024 The Palace Project. All rights reserved. // +import PalaceAudiobookToolkit import XCTest @testable import Palace -import PalaceAudiobookToolkit final class ChapterParsingOptimizerTests: XCTestCase { - - var optimizer: ChapterParsingOptimizer! - - override func setUp() { - super.setUp() - optimizer = ChapterParsingOptimizer() - } - - override func tearDown() { - optimizer = nil - super.tearDown() - } - - // MARK: - Basic Functionality Tests - - func testOptimizerExists() { - // Given: Optimizer instance - // Then: Should be properly initialized - XCTAssertNotNil(optimizer) - } - - func testOptimizationPreservesBasicFunctionality() { - // Given: Mock table of contents - let mockTOC = createMockTableOfContents() - - // When: Optimize table of contents - let optimized = optimizer.optimizeTableOfContents(mockTOC) - - // Then: Should preserve basic functionality - XCTAssertNotNil(optimized) - XCTAssertGreaterThan(optimized.toc.count, 0, "Should have chapters") - XCTAssertNotNil(optimized.manifest, "Should preserve manifest") - XCTAssertNotNil(optimized.tracks, "Should preserve tracks") - } - - func testOptimizationDoesNotBreakNavigation() { - // Given: Mock table of contents with multiple chapters - let mockTOC = createMockTableOfContentsWithMultipleChapters() - - // When: Optimize table of contents - let optimized = optimizer.optimizeTableOfContents(mockTOC) - - // Then: Navigation should still work - guard optimized.toc.count > 1 else { - XCTFail("Need at least 2 chapters for navigation test") - return - } - - let firstChapter = optimized.toc[0] - let nextChapter = optimized.nextChapter(after: firstChapter) - XCTAssertNotNil(nextChapter, "Navigation should work after optimization") - } - - func testOptimizationPreservesDownloadProgress() { - // Given: Mock table of contents - let mockTOC = createMockTableOfContents() - - // When: Optimize table of contents - let optimized = optimizer.optimizeTableOfContents(mockTOC) - - // Then: Download progress methods should work - guard let firstChapter = optimized.toc.first else { - XCTFail("Should have at least one chapter") - return - } - - let progress = optimized.downloadProgress(for: firstChapter) - XCTAssertGreaterThanOrEqual(progress, 0.0) - XCTAssertLessThanOrEqual(progress, 1.0) - - let overallProgress = optimized.overallDownloadProgress - XCTAssertGreaterThanOrEqual(overallProgress, 0.0) - XCTAssertLessThanOrEqual(overallProgress, 1.0) - } - - // MARK: - Optimization Behavior Tests - - func testOptimizerRespectsSpecialAudiobookTypes() { - // Given: Mock Findaway audiobook (should not be optimized) - let findawayTOC = createMockFindawayTableOfContents() - let originalCount = findawayTOC.toc.count - - // When: Optimize - let optimized = optimizer.optimizeTableOfContents(findawayTOC) - - // Then: Should not optimize Findaway audiobooks - XCTAssertEqual(optimized.toc.count, originalCount, "Findaway audiobooks should not be optimized") - } - - func testOptimizerHandlesEmptyTableOfContents() { - // Given: Empty table of contents - let emptyTOC = createEmptyTableOfContents() - - // When: Optimize - let optimized = optimizer.optimizeTableOfContents(emptyTOC) - - // Then: Should handle gracefully - XCTAssertNotNil(optimized) - XCTAssertEqual(optimized.toc.count, 0, "Empty TOC should remain empty") - } - - // MARK: - Helper Methods - - private func createMockTableOfContents() -> AudiobookTableOfContents { - let manifest = createMockManifest() - let tracks = createMockTracks() - return AudiobookTableOfContents(manifest: manifest, tracks: tracks) - } - - private func createMockTableOfContentsWithMultipleChapters() -> AudiobookTableOfContents { - let manifest = createMockManifestWithMultipleChapters() - let tracks = createMockTracksWithMultipleChapters() - return AudiobookTableOfContents(manifest: manifest, tracks: tracks) - } - - private func createMockFindawayTableOfContents() -> AudiobookTableOfContents { - let manifest = createMockFindawayManifest() - let tracks = createMockTracks() - return AudiobookTableOfContents(manifest: manifest, tracks: tracks) - } - - private func createEmptyTableOfContents() -> AudiobookTableOfContents { - let manifest = createEmptyManifest() - let tracks = createEmptyTracks() - return AudiobookTableOfContents(manifest: manifest, tracks: tracks) - } - - private func createMockManifest() -> Manifest { - let metadata = Manifest.Metadata( - title: "Test Book", - identifier: "test-book", - language: "en" - ) - - let readingOrder = [ - Manifest.ReadingOrderItem( - href: "chapter1.mp3", - type: "audio/mpeg", - title: "Chapter 1", - duration: 300, - findawayPart: nil, - findawaySequence: nil, - properties: nil - ) - ] - - return Manifest( - context: [.other("https://readium.org/webpub-manifest/context.jsonld")], - id: "test-book", - metadata: metadata, - readingOrder: readingOrder, - toc: nil, - spine: nil, - links: nil, - linksDictionary: nil, - resources: nil, - formatType: nil - ) - } - - private func createMockManifestWithMultipleChapters() -> Manifest { - let metadata = Manifest.Metadata( - title: "Test Book with Multiple Chapters", - identifier: "test-book-multi", - language: "en" - ) - - let readingOrder = [ - Manifest.ReadingOrderItem( - href: "chapter1.mp3", - type: "audio/mpeg", - title: "Chapter 1", - duration: 300, - findawayPart: nil, - findawaySequence: nil, - properties: nil - ), - Manifest.ReadingOrderItem( - href: "chapter2.mp3", - type: "audio/mpeg", - title: "Chapter 2", - duration: 400, - findawayPart: nil, - findawaySequence: nil, - properties: nil - ), - Manifest.ReadingOrderItem( - href: "chapter3.mp3", - type: "audio/mpeg", - title: "Chapter 3", - duration: 350, - findawayPart: nil, - findawaySequence: nil, - properties: nil - ) - ] - - return Manifest( - context: [.other("https://readium.org/webpub-manifest/context.jsonld")], - id: "test-book-multi", - metadata: metadata, - readingOrder: readingOrder, - toc: nil, - spine: nil, - links: nil, - linksDictionary: nil, - resources: nil, - formatType: nil - ) - } - - private func createMockFindawayManifest() -> Manifest { - let metadata = Manifest.Metadata( - title: "Test Findaway Book", - identifier: "test-findaway", - language: "en", - drmInformation: Manifest.Metadata.DRMInformation(scheme: "http://librarysimplified.org/terms/drm/scheme/FAE") - ) - - let readingOrder = [ - Manifest.ReadingOrderItem( - href: nil, - type: "audio/mpeg", - title: "Chapter 1", - duration: 1800, - findawayPart: 1, - findawaySequence: 1, - properties: nil - ) - ] - - return Manifest( - context: [.other("https://readium.org/webpub-manifest/context.jsonld")], - id: "test-findaway", - metadata: metadata, - readingOrder: readingOrder, - toc: nil, - spine: nil, - links: nil, - linksDictionary: nil, - resources: nil, - formatType: nil - ) - } - - private func createEmptyManifest() -> Manifest { - let metadata = Manifest.Metadata( - title: "Empty Book", - identifier: "empty-book", - language: "en" - ) - - return Manifest( - context: [.other("https://readium.org/webpub-manifest/context.jsonld")], - id: "empty-book", - metadata: metadata, - readingOrder: [], - toc: nil, - spine: nil, - links: nil, - linksDictionary: nil, - resources: nil, - formatType: nil - ) - } - - private func createMockTracks() -> Tracks { - let manifest = createMockManifest() - return Tracks(manifest: manifest, audiobookID: "test", token: nil) - } - - private func createMockTracksWithMultipleChapters() -> Tracks { - let manifest = createMockManifestWithMultipleChapters() - return Tracks(manifest: manifest, audiobookID: "test-multi", token: nil) + var optimizer: ChapterParsingOptimizer! + + override func setUp() { + super.setUp() + optimizer = ChapterParsingOptimizer() + } + + override func tearDown() { + optimizer = nil + super.tearDown() + } + + // MARK: - Basic Functionality Tests + + func testOptimizerExists() { + // Given: Optimizer instance + // Then: Should be properly initialized + XCTAssertNotNil(optimizer) + } + + func testOptimizationPreservesBasicFunctionality() { + // Given: Mock table of contents + let mockTOC = createMockTableOfContents() + + // When: Optimize table of contents + let optimized = optimizer.optimizeTableOfContents(mockTOC) + + // Then: Should preserve basic functionality + XCTAssertNotNil(optimized) + XCTAssertGreaterThan(optimized.toc.count, 0, "Should have chapters") + XCTAssertNotNil(optimized.manifest, "Should preserve manifest") + XCTAssertNotNil(optimized.tracks, "Should preserve tracks") + } + + func testOptimizationDoesNotBreakNavigation() { + // Given: Mock table of contents with multiple chapters + let mockTOC = createMockTableOfContentsWithMultipleChapters() + + // When: Optimize table of contents + let optimized = optimizer.optimizeTableOfContents(mockTOC) + + // Then: Navigation should still work + guard optimized.toc.count > 1 else { + XCTFail("Need at least 2 chapters for navigation test") + return } - - private func createEmptyTracks() -> Tracks { - let manifest = createEmptyManifest() - return Tracks(manifest: manifest, audiobookID: "empty", token: nil) + + let firstChapter = optimized.toc[0] + let nextChapter = optimized.nextChapter(after: firstChapter) + XCTAssertNotNil(nextChapter, "Navigation should work after optimization") + } + + func testOptimizationPreservesDownloadProgress() { + // Given: Mock table of contents + let mockTOC = createMockTableOfContents() + + // When: Optimize table of contents + let optimized = optimizer.optimizeTableOfContents(mockTOC) + + // Then: Download progress methods should work + guard let firstChapter = optimized.toc.first else { + XCTFail("Should have at least one chapter") + return } + + let progress = optimized.downloadProgress(for: firstChapter) + XCTAssertGreaterThanOrEqual(progress, 0.0) + XCTAssertLessThanOrEqual(progress, 1.0) + + let overallProgress = optimized.overallDownloadProgress + XCTAssertGreaterThanOrEqual(overallProgress, 0.0) + XCTAssertLessThanOrEqual(overallProgress, 1.0) + } + + // MARK: - Optimization Behavior Tests + + func testOptimizerRespectsSpecialAudiobookTypes() { + // Given: Mock Findaway audiobook (should not be optimized) + let findawayTOC = createMockFindawayTableOfContents() + let originalCount = findawayTOC.toc.count + + // When: Optimize + let optimized = optimizer.optimizeTableOfContents(findawayTOC) + + // Then: Should not optimize Findaway audiobooks + XCTAssertEqual(optimized.toc.count, originalCount, "Findaway audiobooks should not be optimized") + } + + func testOptimizerHandlesEmptyTableOfContents() { + // Given: Empty table of contents + let emptyTOC = createEmptyTableOfContents() + + // When: Optimize + let optimized = optimizer.optimizeTableOfContents(emptyTOC) + + // Then: Should handle gracefully + XCTAssertNotNil(optimized) + XCTAssertEqual(optimized.toc.count, 0, "Empty TOC should remain empty") + } + + // MARK: - Helper Methods + + private func createMockTableOfContents() -> AudiobookTableOfContents { + let manifest = createMockManifest() + let tracks = createMockTracks() + return AudiobookTableOfContents(manifest: manifest, tracks: tracks) + } + + private func createMockTableOfContentsWithMultipleChapters() -> AudiobookTableOfContents { + let manifest = createMockManifestWithMultipleChapters() + let tracks = createMockTracksWithMultipleChapters() + return AudiobookTableOfContents(manifest: manifest, tracks: tracks) + } + + private func createMockFindawayTableOfContents() -> AudiobookTableOfContents { + let manifest = createMockFindawayManifest() + let tracks = createMockTracks() + return AudiobookTableOfContents(manifest: manifest, tracks: tracks) + } + + private func createEmptyTableOfContents() -> AudiobookTableOfContents { + let manifest = createEmptyManifest() + let tracks = createEmptyTracks() + return AudiobookTableOfContents(manifest: manifest, tracks: tracks) + } + + private func createMockManifest() -> Manifest { + let metadata = Manifest.Metadata( + title: "Test Book", + identifier: "test-book", + language: "en" + ) + + let readingOrder = [ + Manifest.ReadingOrderItem( + href: "chapter1.mp3", + type: "audio/mpeg", + title: "Chapter 1", + duration: 300, + findawayPart: nil, + findawaySequence: nil, + properties: nil + ) + ] + + return Manifest( + context: [.other("https://readium.org/webpub-manifest/context.jsonld")], + id: "test-book", + metadata: metadata, + readingOrder: readingOrder, + toc: nil, + spine: nil, + links: nil, + linksDictionary: nil, + resources: nil, + formatType: nil + ) + } + + private func createMockManifestWithMultipleChapters() -> Manifest { + let metadata = Manifest.Metadata( + title: "Test Book with Multiple Chapters", + identifier: "test-book-multi", + language: "en" + ) + + let readingOrder = [ + Manifest.ReadingOrderItem( + href: "chapter1.mp3", + type: "audio/mpeg", + title: "Chapter 1", + duration: 300, + findawayPart: nil, + findawaySequence: nil, + properties: nil + ), + Manifest.ReadingOrderItem( + href: "chapter2.mp3", + type: "audio/mpeg", + title: "Chapter 2", + duration: 400, + findawayPart: nil, + findawaySequence: nil, + properties: nil + ), + Manifest.ReadingOrderItem( + href: "chapter3.mp3", + type: "audio/mpeg", + title: "Chapter 3", + duration: 350, + findawayPart: nil, + findawaySequence: nil, + properties: nil + ) + ] + + return Manifest( + context: [.other("https://readium.org/webpub-manifest/context.jsonld")], + id: "test-book-multi", + metadata: metadata, + readingOrder: readingOrder, + toc: nil, + spine: nil, + links: nil, + linksDictionary: nil, + resources: nil, + formatType: nil + ) + } + + private func createMockFindawayManifest() -> Manifest { + let metadata = Manifest.Metadata( + title: "Test Findaway Book", + identifier: "test-findaway", + language: "en", + drmInformation: Manifest.Metadata.DRMInformation(scheme: "http://librarysimplified.org/terms/drm/scheme/FAE") + ) + + let readingOrder = [ + Manifest.ReadingOrderItem( + href: nil, + type: "audio/mpeg", + title: "Chapter 1", + duration: 1800, + findawayPart: 1, + findawaySequence: 1, + properties: nil + ) + ] + + return Manifest( + context: [.other("https://readium.org/webpub-manifest/context.jsonld")], + id: "test-findaway", + metadata: metadata, + readingOrder: readingOrder, + toc: nil, + spine: nil, + links: nil, + linksDictionary: nil, + resources: nil, + formatType: nil + ) + } + + private func createEmptyManifest() -> Manifest { + let metadata = Manifest.Metadata( + title: "Empty Book", + identifier: "empty-book", + language: "en" + ) + + return Manifest( + context: [.other("https://readium.org/webpub-manifest/context.jsonld")], + id: "empty-book", + metadata: metadata, + readingOrder: [], + toc: nil, + spine: nil, + links: nil, + linksDictionary: nil, + resources: nil, + formatType: nil + ) + } + + private func createMockTracks() -> Tracks { + let manifest = createMockManifest() + return Tracks(manifest: manifest, audiobookID: "test", token: nil) + } + + private func createMockTracksWithMultipleChapters() -> Tracks { + let manifest = createMockManifestWithMultipleChapters() + return Tracks(manifest: manifest, audiobookID: "test-multi", token: nil) + } + + private func createEmptyTracks() -> Tracks { + let manifest = createEmptyManifest() + return Tracks(manifest: manifest, audiobookID: "empty", token: nil) + } } diff --git a/PalaceTests/ComprehensiveFileCleanupTests.swift b/PalaceTests/ComprehensiveFileCleanupTests.swift index c5f5ccee1..c2a63638a 100644 --- a/PalaceTests/ComprehensiveFileCleanupTests.swift +++ b/PalaceTests/ComprehensiveFileCleanupTests.swift @@ -8,248 +8,253 @@ import XCTest @testable import Palace +// MARK: - ComprehensiveFileCleanupTests + final class ComprehensiveFileCleanupTests: XCTestCase { - - var downloadCenter: MyBooksDownloadCenter! - var testBookId: String! - var testBook: TPPBook! - - override func setUp() { - super.setUp() - downloadCenter = MyBooksDownloadCenter.shared - testBookId = "test-book-cleanup-\(UUID().uuidString)" - testBook = createMockBook(identifier: testBookId) - } - - override func tearDown() { - // Clean up any test files that might remain - cleanupTestFiles() - - testBook = nil - testBookId = nil - downloadCenter = nil - super.tearDown() - } - - // MARK: - Basic Functionality Tests - - func testCompletelyRemoveAudiobookExists() { - // Given: Download center - // Then: Method should exist and be callable - XCTAssertNoThrow(downloadCenter.completelyRemoveAudiobook(testBook)) - } - - func testFindRemainingFilesExists() { - // Given: Download center - // Then: Method should exist and return array - let remainingFiles = downloadCenter.findRemainingFiles(for: testBookId) - XCTAssertNotNil(remainingFiles) - } - - func testReturnBookWithCompleteCleanupExists() { - // Given: Download center - // Then: Method should exist and be callable - XCTAssertNoThrow(downloadCenter.returnBookWithCompleteCleanup(testBook)) - } - - // MARK: - File Detection Tests - - func testFindRemainingFilesDetectsTestFiles() { - // Given: Create test files with book ID - createTestFilesForBook(testBookId) - - // When: Search for remaining files - let remainingFiles = downloadCenter.findRemainingFiles(for: testBookId) - - // Then: Should find the test files - XCTAssertGreaterThan(remainingFiles.count, 0, "Should find test files containing book ID") - - // Verify at least one file contains the book ID - let containsBookId = remainingFiles.contains { $0.contains(testBookId) } - XCTAssertTrue(containsBookId, "Found files should contain book ID") - } - - func testCleanupRemovesTestFiles() { - // Given: Create test files with book ID - createTestFilesForBook(testBookId) - - // Verify files exist before cleanup - let filesBeforeCleanup = downloadCenter.findRemainingFiles(for: testBookId) - XCTAssertGreaterThan(filesBeforeCleanup.count, 0, "Should have test files before cleanup") - - // When: Perform cleanup - downloadCenter.completelyRemoveAudiobook(testBook) - - // Give cleanup time to complete - let expectation = XCTestExpectation(description: "Cleanup completion") - DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { - expectation.fulfill() - } - wait(for: [expectation], timeout: 2.0) - - // Then: Files should be reduced or removed - let filesAfterCleanup = downloadCenter.findRemainingFiles(for: testBookId) - XCTAssertLessThanOrEqual(filesAfterCleanup.count, filesBeforeCleanup.count, "Cleanup should reduce file count") - } - - // MARK: - Edge Case Tests - - func testCleanupHandlesNonExistentBook() { - // Given: Non-existent book ID - let nonExistentBook = createMockBook(identifier: "non-existent-book-id") - - // When: Attempt cleanup - // Then: Should not crash - XCTAssertNoThrow(downloadCenter.completelyRemoveAudiobook(nonExistentBook)) + var downloadCenter: MyBooksDownloadCenter! + var testBookId: String! + var testBook: TPPBook! + + override func setUp() { + super.setUp() + downloadCenter = MyBooksDownloadCenter.shared + testBookId = "test-book-cleanup-\(UUID().uuidString)" + testBook = createMockBook(identifier: testBookId) + } + + override func tearDown() { + // Clean up any test files that might remain + cleanupTestFiles() + + testBook = nil + testBookId = nil + downloadCenter = nil + super.tearDown() + } + + // MARK: - Basic Functionality Tests + + func testCompletelyRemoveAudiobookExists() { + // Given: Download center + // Then: Method should exist and be callable + XCTAssertNoThrow(downloadCenter.completelyRemoveAudiobook(testBook)) + } + + func testFindRemainingFilesExists() { + // Given: Download center + // Then: Method should exist and return array + let remainingFiles = downloadCenter.findRemainingFiles(for: testBookId) + XCTAssertNotNil(remainingFiles) + } + + func testReturnBookWithCompleteCleanupExists() { + // Given: Download center + // Then: Method should exist and be callable + XCTAssertNoThrow(downloadCenter.returnBookWithCompleteCleanup(testBook)) + } + + // MARK: - File Detection Tests + + func testFindRemainingFilesDetectsTestFiles() { + // Given: Create test files with book ID + createTestFilesForBook(testBookId) + + // When: Search for remaining files + let remainingFiles = downloadCenter.findRemainingFiles(for: testBookId) + + // Then: Should find the test files + XCTAssertGreaterThan(remainingFiles.count, 0, "Should find test files containing book ID") + + // Verify at least one file contains the book ID + let containsBookId = remainingFiles.contains { $0.contains(testBookId) } + XCTAssertTrue(containsBookId, "Found files should contain book ID") + } + + func testCleanupRemovesTestFiles() { + // Given: Create test files with book ID + createTestFilesForBook(testBookId) + + // Verify files exist before cleanup + let filesBeforeCleanup = downloadCenter.findRemainingFiles(for: testBookId) + XCTAssertGreaterThan(filesBeforeCleanup.count, 0, "Should have test files before cleanup") + + // When: Perform cleanup + downloadCenter.completelyRemoveAudiobook(testBook) + + // Give cleanup time to complete + let expectation = XCTestExpectation(description: "Cleanup completion") + DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { + expectation.fulfill() } - - func testFindRemainingFilesHandlesEmptyDirectory() { - // Given: Book ID that has no associated files - let cleanBookId = "clean-book-\(UUID().uuidString)" - - // When: Search for remaining files - let remainingFiles = downloadCenter.findRemainingFiles(for: cleanBookId) - - // Then: Should return empty array without crashing - XCTAssertEqual(remainingFiles.count, 0, "Should find no files for clean book ID") + wait(for: [expectation], timeout: 2.0) + + // Then: Files should be reduced or removed + let filesAfterCleanup = downloadCenter.findRemainingFiles(for: testBookId) + XCTAssertLessThanOrEqual(filesAfterCleanup.count, filesBeforeCleanup.count, "Cleanup should reduce file count") + } + + // MARK: - Edge Case Tests + + func testCleanupHandlesNonExistentBook() { + // Given: Non-existent book ID + let nonExistentBook = createMockBook(identifier: "non-existent-book-id") + + // When: Attempt cleanup + // Then: Should not crash + XCTAssertNoThrow(downloadCenter.completelyRemoveAudiobook(nonExistentBook)) + } + + func testFindRemainingFilesHandlesEmptyDirectory() { + // Given: Book ID that has no associated files + let cleanBookId = "clean-book-\(UUID().uuidString)" + + // When: Search for remaining files + let remainingFiles = downloadCenter.findRemainingFiles(for: cleanBookId) + + // Then: Should return empty array without crashing + XCTAssertEqual(remainingFiles.count, 0, "Should find no files for clean book ID") + } + + // MARK: - Integration Tests + + func testComprehensiveCleanupDoesNotAffectOtherBooks() { + // Given: Files for multiple books + let otherBookId = "other-book-\(UUID().uuidString)" + createTestFilesForBook(testBookId) + createTestFilesForBook(otherBookId) + + // Verify both books have files + let testBookFilesBefore = downloadCenter.findRemainingFiles(for: testBookId) + let otherBookFilesBefore = downloadCenter.findRemainingFiles(for: otherBookId) + XCTAssertGreaterThan(testBookFilesBefore.count, 0) + XCTAssertGreaterThan(otherBookFilesBefore.count, 0) + + // When: Clean up only test book + downloadCenter.completelyRemoveAudiobook(testBook) + + // Give cleanup time to complete + let expectation = XCTestExpectation(description: "Cleanup completion") + DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { + expectation.fulfill() } - - // MARK: - Integration Tests - - func testComprehensiveCleanupDoesNotAffectOtherBooks() { - // Given: Files for multiple books - let otherBookId = "other-book-\(UUID().uuidString)" - createTestFilesForBook(testBookId) - createTestFilesForBook(otherBookId) - - // Verify both books have files - let testBookFilesBefore = downloadCenter.findRemainingFiles(for: testBookId) - let otherBookFilesBefore = downloadCenter.findRemainingFiles(for: otherBookId) - XCTAssertGreaterThan(testBookFilesBefore.count, 0) - XCTAssertGreaterThan(otherBookFilesBefore.count, 0) - - // When: Clean up only test book - downloadCenter.completelyRemoveAudiobook(testBook) - - // Give cleanup time to complete - let expectation = XCTestExpectation(description: "Cleanup completion") - DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { - expectation.fulfill() + wait(for: [expectation], timeout: 2.0) + + // Then: Other book's files should remain + let otherBookFilesAfter = downloadCenter.findRemainingFiles(for: otherBookId) + XCTAssertGreaterThanOrEqual( + otherBookFilesAfter.count, + otherBookFilesBefore.count * 0.8, + "Other book's files should mostly remain" + ) + + // Clean up other book's test files + let otherBook = createMockBook(identifier: otherBookId) + downloadCenter.completelyRemoveAudiobook(otherBook) + } + + // MARK: - Helper Methods + + private func createMockBook(identifier: String) -> TPPBook { + // Create a minimal mock book for testing + // This is a simplified version - in real tests you might need a more complete mock + let book = TPPBook() + book.identifier = identifier + book.title = "Test Book \(identifier)" + return book + } + + private func createTestFilesForBook(_ bookId: String) { + let fileManager = FileManager.default + + // Create test files in various locations that might be used by audiobooks + let testLocations = [ + FileManager.default.temporaryDirectory, + fileManager.urls(for: .cachesDirectory, in: .userDomainMask).first + ].compactMap { $0 } + + for location in testLocations { + let testFiles = [ + location.appendingPathComponent("\(bookId).test"), + location.appendingPathComponent("test_\(bookId).tmp"), + location.appendingPathComponent("\(bookId)_cache.data") + ] + + for testFile in testFiles { + do { + try "test data".write(to: testFile, atomically: true, encoding: .utf8) + } catch { + // Ignore write errors in test setup } - wait(for: [expectation], timeout: 2.0) - - // Then: Other book's files should remain - let otherBookFilesAfter = downloadCenter.findRemainingFiles(for: otherBookId) - XCTAssertGreaterThanOrEqual(otherBookFilesAfter.count, otherBookFilesBefore.count * 0.8, - "Other book's files should mostly remain") - - // Clean up other book's test files - let otherBook = createMockBook(identifier: otherBookId) - downloadCenter.completelyRemoveAudiobook(otherBook) + } } - - // MARK: - Helper Methods - - private func createMockBook(identifier: String) -> TPPBook { - // Create a minimal mock book for testing - // This is a simplified version - in real tests you might need a more complete mock - let book = TPPBook() - book.identifier = identifier - book.title = "Test Book \(identifier)" - return book + + // Create test directories + let testDirectories = [ + FileManager.default.temporaryDirectory.appendingPathComponent("AudiobookCache").appendingPathComponent(bookId), + fileManager.urls(for: .cachesDirectory, in: .userDomainMask).first?.appendingPathComponent(bookId) + ].compactMap { $0 } + + for testDirectory in testDirectories { + do { + try fileManager.createDirectory(at: testDirectory, withIntermediateDirectories: true) + // Add a test file in the directory + let testFile = testDirectory.appendingPathComponent("test.data") + try "test content".write(to: testFile, atomically: true, encoding: .utf8) + } catch { + // Ignore creation errors in test setup + } } - - private func createTestFilesForBook(_ bookId: String) { - let fileManager = FileManager.default - - // Create test files in various locations that might be used by audiobooks - let testLocations = [ - FileManager.default.temporaryDirectory, - fileManager.urls(for: .cachesDirectory, in: .userDomainMask).first - ].compactMap { $0 } - - for location in testLocations { - let testFiles = [ - location.appendingPathComponent("\(bookId).test"), - location.appendingPathComponent("test_\(bookId).tmp"), - location.appendingPathComponent("\(bookId)_cache.data") - ] - - for testFile in testFiles { - do { - try "test data".write(to: testFile, atomically: true, encoding: .utf8) - } catch { - // Ignore write errors in test setup - } - } - } - - // Create test directories - let testDirectories = [ - FileManager.default.temporaryDirectory.appendingPathComponent("AudiobookCache").appendingPathComponent(bookId), - fileManager.urls(for: .cachesDirectory, in: .userDomainMask).first?.appendingPathComponent(bookId) - ].compactMap { $0 } - - for testDirectory in testDirectories { - do { - try fileManager.createDirectory(at: testDirectory, withIntermediateDirectories: true) - // Add a test file in the directory - let testFile = testDirectory.appendingPathComponent("test.data") - try "test content".write(to: testFile, atomically: true, encoding: .utf8) - } catch { - // Ignore creation errors in test setup - } - } + } + + private func cleanupTestFiles() { + // Clean up any test files that might remain after tests + guard let testBookId = testBookId else { + return } - - private func cleanupTestFiles() { - // Clean up any test files that might remain after tests - guard let testBookId = testBookId else { return } - - let fileManager = FileManager.default - let searchLocations = [ - FileManager.default.temporaryDirectory, - fileManager.urls(for: .cachesDirectory, in: .userDomainMask).first, - fileManager.urls(for: .documentDirectory, in: .userDomainMask).first - ].compactMap { $0 } - - for location in searchLocations { - guard let enumerator = fileManager.enumerator( - at: location, - includingPropertiesForKeys: [.nameKey], - options: [.skipsHiddenFiles] - ) else { - continue - } - - for case let fileURL as URL in enumerator { - let fileName = fileURL.lastPathComponent - if fileName.contains(testBookId) { - try? fileManager.removeItem(at: fileURL) - } - } + + let fileManager = FileManager.default + let searchLocations = [ + FileManager.default.temporaryDirectory, + fileManager.urls(for: .cachesDirectory, in: .userDomainMask).first, + fileManager.urls(for: .documentDirectory, in: .userDomainMask).first + ].compactMap { $0 } + + for location in searchLocations { + guard let enumerator = fileManager.enumerator( + at: location, + includingPropertiesForKeys: [.nameKey], + options: [.skipsHiddenFiles] + ) else { + continue + } + + for case let fileURL as URL in enumerator { + let fileName = fileURL.lastPathComponent + if fileName.contains(testBookId) { + try? fileManager.removeItem(at: fileURL) } + } } + } } -// MARK: - AudiobookDataManager Tests +// MARK: - AudiobookDataManagerCleanupTests final class AudiobookDataManagerCleanupTests: XCTestCase { - - func testRemoveTrackingDataExists() { - // Given: AudiobookDataManager - let dataManager = AudiobookDataManager() - - // Then: Method should exist and be callable - XCTAssertNoThrow(dataManager.removeTrackingData(for: "test-book-id")) - } - - func testRemoveTrackingDataHandlesNonExistentBook() { - // Given: AudiobookDataManager - let dataManager = AudiobookDataManager() - - // When: Remove tracking data for non-existent book - // Then: Should not crash - XCTAssertNoThrow(dataManager.removeTrackingData(for: "non-existent-book")) - } + func testRemoveTrackingDataExists() { + // Given: AudiobookDataManager + let dataManager = AudiobookDataManager() + + // Then: Method should exist and be callable + XCTAssertNoThrow(dataManager.removeTrackingData(for: "test-book-id")) + } + + func testRemoveTrackingDataHandlesNonExistentBook() { + // Given: AudiobookDataManager + let dataManager = AudiobookDataManager() + + // When: Remove tracking data for non-existent book + // Then: Should not crash + XCTAssertNoThrow(dataManager.removeTrackingData(for: "non-existent-book")) + } } diff --git a/PalaceTests/Date+NYPLAdditionsTests.swift b/PalaceTests/Date+NYPLAdditionsTests.swift index 1939d300b..0cf31d087 100644 --- a/PalaceTests/Date+NYPLAdditionsTests.swift +++ b/PalaceTests/Date+NYPLAdditionsTests.swift @@ -22,7 +22,7 @@ class Date_NYPLAdditionsTests: XCTestCase { // the first call is several orders of magnitude more expensive _ = date.rfc1123String - self.measure { + measure { (1...4000).forEach { _ in _ = date.rfc1123String } @@ -49,12 +49,12 @@ class Date_NYPLAdditionsTests: XCTestCase { let dateComponents = date?.utcComponents() XCTAssertNotNil(dateComponents) - XCTAssertEqual(dateComponents?.year, 1984); - XCTAssertEqual(dateComponents?.month, 9); - XCTAssertEqual(dateComponents?.day, 8); - XCTAssertEqual(dateComponents?.hour, 8); - XCTAssertEqual(dateComponents?.minute, 23); - XCTAssertEqual(dateComponents?.second, 45); + XCTAssertEqual(dateComponents?.year, 1984) + XCTAssertEqual(dateComponents?.month, 9) + XCTAssertEqual(dateComponents?.day, 8) + XCTAssertEqual(dateComponents?.hour, 8) + XCTAssertEqual(dateComponents?.minute, 23) + XCTAssertEqual(dateComponents?.second, 45) } func testParsesRFC3339DateWithFractionalSecondsCorrectly() { @@ -63,12 +63,12 @@ class Date_NYPLAdditionsTests: XCTestCase { let dateComponents = date?.utcComponents() XCTAssertNotNil(dateComponents) - XCTAssertEqual(dateComponents?.year, 1984); - XCTAssertEqual(dateComponents?.month, 9); - XCTAssertEqual(dateComponents?.day, 8); - XCTAssertEqual(dateComponents?.hour, 8); - XCTAssertEqual(dateComponents?.minute, 23); - XCTAssertEqual(dateComponents?.second, 45); + XCTAssertEqual(dateComponents?.year, 1984) + XCTAssertEqual(dateComponents?.month, 9) + XCTAssertEqual(dateComponents?.day, 8) + XCTAssertEqual(dateComponents?.hour, 8) + XCTAssertEqual(dateComponents?.minute, 23) + XCTAssertEqual(dateComponents?.second, 45) } func testRFC3339RoundTrip() { @@ -80,6 +80,4 @@ class Date_NYPLAdditionsTests: XCTestCase { XCTAssertEqual(dateString, "1984-09-08T08:23:45Z") } - - } diff --git a/PalaceTests/FacetFilteringTests.swift b/PalaceTests/FacetFilteringTests.swift index 3c437bd3f..b442ab607 100644 --- a/PalaceTests/FacetFilteringTests.swift +++ b/PalaceTests/FacetFilteringTests.swift @@ -1,6 +1,8 @@ import XCTest @testable import Palace +// MARK: - FacetFilteringTests + final class FacetFilteringTests: XCTestCase { struct MockNetworkClient: NetworkClient { let dataForURL: [String: Data] @@ -31,7 +33,9 @@ final class FacetFilteringTests: XCTestCase { // When fetch top-level let top = try await api.fetchFeed(at: topURL) XCTAssertNotNil(top) - guard let objcFeed = top?.opdsFeed else { return XCTFail("missing opds feed") } + guard let objcFeed = top?.opdsFeed else { + return XCTFail("missing opds feed") + } let ungrouped = TPPCatalogUngroupedFeed(opdsFeed: objcFeed)! let groups = (ungrouped.facetGroups as? [TPPCatalogFacetGroup]) ?? [] XCTAssertFalse(groups.isEmpty) @@ -51,6 +55,8 @@ final class FacetFilteringTests: XCTestCase { } } +// MARK: - TestResources + private enum TestResources { static func topLevelWithFacetXML(activeFacetHref: String) -> String { """ @@ -77,5 +83,3 @@ private enum TestResources { """ } } - - diff --git a/PalaceTests/GeneralCacheTests.swift b/PalaceTests/GeneralCacheTests.swift index 8f6002dca..e934a527e 100644 --- a/PalaceTests/GeneralCacheTests.swift +++ b/PalaceTests/GeneralCacheTests.swift @@ -1,174 +1,180 @@ import XCTest @testable import Palace +// MARK: - TestValue + private struct TestValue: Codable, Equatable { - let text: String - let number: Int + let text: String + let number: Int } -final class GeneralCacheTests: XCTestCase { - fileprivate var cache: GeneralCache! - - override func setUp() { - super.setUp() - cache = GeneralCache(cacheName: "GeneralCacheTest") - cache.clear() - } - - override func tearDown() { - cache.clear() - super.tearDown() - } - - func testSetAndGetInMemory() { - let value = TestValue(text: "hello", number: 42) - cache.set(value, for: "key1") - let result = cache.get(for: "key1") - XCTAssertEqual(result, value) - } - - func testSetAndGetFromDisk() { - let value = TestValue(text: "disk", number: 99) - cache.set(value, for: "key2") - cache.clearMemory() - let result = cache.get(for: "key2") - XCTAssertEqual(result, value) - } - - func testExpiration() { - let value = TestValue(text: "expire", number: 1) - cache.set(value, for: "key3", expiresIn: 1) - XCTAssertEqual(cache.get(for: "key3"), value) - sleep(2) - XCTAssertNil(cache.get(for: "key3")) - } - - func testRemove() { - let value = TestValue(text: "remove", number: 2) - cache.set(value, for: "key4") - cache.remove(for: "key4") - XCTAssertNil(cache.get(for: "key4")) - } - - func testClear() { - cache.set(TestValue(text: "a", number: 1), for: "a") - cache.set(TestValue(text: "b", number: 2), for: "b") - cache.clear() - XCTAssertNil(cache.get(for: "a")) - XCTAssertNil(cache.get(for: "b")) - } - - // MARK: - CachingMode Tests - func testMemoryOnlyMode() { - let cache = GeneralCache(cacheName: "MemoryOnlyTest", mode: .memoryOnly) - let value = TestValue(text: "mem", number: 1) - cache.set(value, for: "k") - XCTAssertEqual(cache.get(for: "k"), value) - cache.clearMemory() - XCTAssertNil(cache.get(for: "k")) - } - - func testDiskOnlyMode() { - let cache = GeneralCache(cacheName: "DiskOnlyTest", mode: .diskOnly) - let value = TestValue(text: "disk", number: 2) - cache.set(value, for: "k") - XCTAssertEqual(cache.get(for: "k"), value) - cache.remove(for: "k") - XCTAssertNil(cache.get(for: "k")) - } - - func testMemoryAndDiskMode() { - let cache = GeneralCache(cacheName: "MemDiskTest", mode: .memoryAndDisk) - let value = TestValue(text: "both", number: 3) - cache.set(value, for: "k") - XCTAssertEqual(cache.get(for: "k"), value) - cache.clearMemory() - XCTAssertEqual(cache.get(for: "k"), value) - } - - func testNoneMode() { - let cache = GeneralCache(cacheName: "NoneTest", mode: .none) - let value = TestValue(text: "none", number: 4) - cache.set(value, for: "k") - XCTAssertNil(cache.get(for: "k")) - } - - // MARK: - CachePolicy Tests (async) - func testCacheFirstPolicy() async throws { - let cache = GeneralCache(cacheName: "CacheFirstTest") - var fetchCount = 0 - let fetcher: () async throws -> TestValue = { - fetchCount += 1 - return TestValue(text: "fetched", number: 1) - } - let v1 = try await cache.get("k", policy: .cacheFirst, fetcher: fetcher) - XCTAssertEqual(v1, TestValue(text: "fetched", number: 1)) - XCTAssertEqual(fetchCount, 1) - let v2 = try await cache.get("k", policy: .cacheFirst, fetcher: fetcher) - XCTAssertEqual(v2, v1) - XCTAssertEqual(fetchCount, 1) - } +// MARK: - GeneralCacheTests - func testNetworkFirstPolicy() async throws { - let cache = GeneralCache(cacheName: "NetworkFirstTest") - var fetchCount = 0 - let fetcher: () async throws -> TestValue = { - fetchCount += 1 - return TestValue(text: "net", number: fetchCount) - } - let v1 = try await cache.get("k", policy: .networkFirst, fetcher: fetcher) - let v2 = try await cache.get("k", policy: .networkFirst, fetcher: fetcher) - XCTAssertEqual(v1.text, "net") - XCTAssertEqual(v2.text, "net") - XCTAssertEqual(fetchCount, 2) - } - - func testCacheThenNetworkPolicy() async throws { - let cache = GeneralCache(cacheName: "CacheThenNetworkTest") - var fetchCount = 0 - let fetcher: () async throws -> TestValue = { - fetchCount += 1 - return TestValue(text: "ctn", number: fetchCount) - } - let v1 = try await cache.get("k", policy: .cacheThenNetwork, fetcher: fetcher) - XCTAssertEqual(v1, TestValue(text: "ctn", number: 1)) - let v2 = try await cache.get("k", policy: .cacheThenNetwork, fetcher: fetcher) - XCTAssertEqual(v2, v1) - let exp = expectation(description: "Background update") - DispatchQueue.main.asyncAfter(deadline: .now() + 1) { - exp.fulfill() - } - await fulfillment(of: [exp], timeout: 2) - let v3 = cache.get(for: "k") - XCTAssertEqual(v3, TestValue(text: "ctn", number: 2)) - } - - func testTimedCachePolicy() async throws { - let cache = GeneralCache(cacheName: "TimedCacheTest") - var fetchCount = 0 - let fetcher: () async throws -> TestValue = { - fetchCount += 1 - return TestValue(text: "timed", number: fetchCount) - } - let v1 = try await cache.get("k", policy: .timedCache(1), fetcher: fetcher) - XCTAssertEqual(v1, TestValue(text: "timed", number: 1)) - let v2 = try await cache.get("k", policy: .timedCache(1), fetcher: fetcher) - XCTAssertEqual(v2, v1) - sleep(2) - let v3 = try await cache.get("k", policy: .timedCache(1), fetcher: fetcher) - XCTAssertEqual(v3, TestValue(text: "timed", number: 2)) - } - - func testNoCachePolicy() async throws { - let cache = GeneralCache(cacheName: "NoCacheTest") - var fetchCount = 0 - let fetcher: () async throws -> TestValue = { - fetchCount += 1 - return TestValue(text: "no", number: fetchCount) - } - let v1 = try await cache.get("k", policy: .noCache, fetcher: fetcher) - let v2 = try await cache.get("k", policy: .noCache, fetcher: fetcher) - XCTAssertEqual(fetchCount, 2) - XCTAssertNotEqual(v1, v2) - } +final class GeneralCacheTests: XCTestCase { + fileprivate var cache: GeneralCache! + + override func setUp() { + super.setUp() + cache = GeneralCache(cacheName: "GeneralCacheTest") + cache.clear() + } + + override func tearDown() { + cache.clear() + super.tearDown() + } + + func testSetAndGetInMemory() { + let value = TestValue(text: "hello", number: 42) + cache.set(value, for: "key1") + let result = cache.get(for: "key1") + XCTAssertEqual(result, value) + } + + func testSetAndGetFromDisk() { + let value = TestValue(text: "disk", number: 99) + cache.set(value, for: "key2") + cache.clearMemory() + let result = cache.get(for: "key2") + XCTAssertEqual(result, value) + } + + func testExpiration() { + let value = TestValue(text: "expire", number: 1) + cache.set(value, for: "key3", expiresIn: 1) + XCTAssertEqual(cache.get(for: "key3"), value) + sleep(2) + XCTAssertNil(cache.get(for: "key3")) + } + + func testRemove() { + let value = TestValue(text: "remove", number: 2) + cache.set(value, for: "key4") + cache.remove(for: "key4") + XCTAssertNil(cache.get(for: "key4")) + } + + func testClear() { + cache.set(TestValue(text: "a", number: 1), for: "a") + cache.set(TestValue(text: "b", number: 2), for: "b") + cache.clear() + XCTAssertNil(cache.get(for: "a")) + XCTAssertNil(cache.get(for: "b")) + } + + // MARK: - CachingMode Tests + + func testMemoryOnlyMode() { + let cache = GeneralCache(cacheName: "MemoryOnlyTest", mode: .memoryOnly) + let value = TestValue(text: "mem", number: 1) + cache.set(value, for: "k") + XCTAssertEqual(cache.get(for: "k"), value) + cache.clearMemory() + XCTAssertNil(cache.get(for: "k")) + } + + func testDiskOnlyMode() { + let cache = GeneralCache(cacheName: "DiskOnlyTest", mode: .diskOnly) + let value = TestValue(text: "disk", number: 2) + cache.set(value, for: "k") + XCTAssertEqual(cache.get(for: "k"), value) + cache.remove(for: "k") + XCTAssertNil(cache.get(for: "k")) + } + + func testMemoryAndDiskMode() { + let cache = GeneralCache(cacheName: "MemDiskTest", mode: .memoryAndDisk) + let value = TestValue(text: "both", number: 3) + cache.set(value, for: "k") + XCTAssertEqual(cache.get(for: "k"), value) + cache.clearMemory() + XCTAssertEqual(cache.get(for: "k"), value) + } + + func testNoneMode() { + let cache = GeneralCache(cacheName: "NoneTest", mode: .none) + let value = TestValue(text: "none", number: 4) + cache.set(value, for: "k") + XCTAssertNil(cache.get(for: "k")) + } + + // MARK: - CachePolicy Tests (async) + + func testCacheFirstPolicy() async throws { + let cache = GeneralCache(cacheName: "CacheFirstTest") + var fetchCount = 0 + let fetcher: () async throws -> TestValue = { + fetchCount += 1 + return TestValue(text: "fetched", number: 1) + } + let v1 = try await cache.get("k", policy: .cacheFirst, fetcher: fetcher) + XCTAssertEqual(v1, TestValue(text: "fetched", number: 1)) + XCTAssertEqual(fetchCount, 1) + let v2 = try await cache.get("k", policy: .cacheFirst, fetcher: fetcher) + XCTAssertEqual(v2, v1) + XCTAssertEqual(fetchCount, 1) + } + + func testNetworkFirstPolicy() async throws { + let cache = GeneralCache(cacheName: "NetworkFirstTest") + var fetchCount = 0 + let fetcher: () async throws -> TestValue = { + fetchCount += 1 + return TestValue(text: "net", number: fetchCount) + } + let v1 = try await cache.get("k", policy: .networkFirst, fetcher: fetcher) + let v2 = try await cache.get("k", policy: .networkFirst, fetcher: fetcher) + XCTAssertEqual(v1.text, "net") + XCTAssertEqual(v2.text, "net") + XCTAssertEqual(fetchCount, 2) + } + + func testCacheThenNetworkPolicy() async throws { + let cache = GeneralCache(cacheName: "CacheThenNetworkTest") + var fetchCount = 0 + let fetcher: () async throws -> TestValue = { + fetchCount += 1 + return TestValue(text: "ctn", number: fetchCount) + } + let v1 = try await cache.get("k", policy: .cacheThenNetwork, fetcher: fetcher) + XCTAssertEqual(v1, TestValue(text: "ctn", number: 1)) + let v2 = try await cache.get("k", policy: .cacheThenNetwork, fetcher: fetcher) + XCTAssertEqual(v2, v1) + let exp = expectation(description: "Background update") + DispatchQueue.main.asyncAfter(deadline: .now() + 1) { + exp.fulfill() + } + await fulfillment(of: [exp], timeout: 2) + let v3 = cache.get(for: "k") + XCTAssertEqual(v3, TestValue(text: "ctn", number: 2)) + } + + func testTimedCachePolicy() async throws { + let cache = GeneralCache(cacheName: "TimedCacheTest") + var fetchCount = 0 + let fetcher: () async throws -> TestValue = { + fetchCount += 1 + return TestValue(text: "timed", number: fetchCount) + } + let v1 = try await cache.get("k", policy: .timedCache(1), fetcher: fetcher) + XCTAssertEqual(v1, TestValue(text: "timed", number: 1)) + let v2 = try await cache.get("k", policy: .timedCache(1), fetcher: fetcher) + XCTAssertEqual(v2, v1) + sleep(2) + let v3 = try await cache.get("k", policy: .timedCache(1), fetcher: fetcher) + XCTAssertEqual(v3, TestValue(text: "timed", number: 2)) + } + + func testNoCachePolicy() async throws { + let cache = GeneralCache(cacheName: "NoCacheTest") + var fetchCount = 0 + let fetcher: () async throws -> TestValue = { + fetchCount += 1 + return TestValue(text: "no", number: fetchCount) + } + let v1 = try await cache.get("k", policy: .noCache, fetcher: fetcher) + let v2 = try await cache.get("k", policy: .noCache, fetcher: fetcher) + XCTAssertEqual(fetchCount, 2) + XCTAssertNotEqual(v1, v2) + } } diff --git a/PalaceTests/HTTPStubURLProtocol.swift b/PalaceTests/HTTPStubURLProtocol.swift index bf7034be9..b5af9798e 100644 --- a/PalaceTests/HTTPStubURLProtocol.swift +++ b/PalaceTests/HTTPStubURLProtocol.swift @@ -6,29 +6,29 @@ final class HTTPStubURLProtocol: URLProtocol { let headers: [String: String]? let body: Data? } - + private static let handlerQueue = DispatchQueue(label: "HTTPStubURLProtocol.handlerQueue") private static var requestHandlers: [(URLRequest) -> StubbedResponse?] = [] - - override class func canInit(with request: URLRequest) -> Bool { - return true + + override class func canInit(with _: URLRequest) -> Bool { + true } - + override class func canonicalRequest(for request: URLRequest) -> URLRequest { - return request + request } - + override func startLoading() { - let request = self.request + let request = request let response: StubbedResponse? = Self.handler(for: request) - + guard let stub = response else { let notFound = HTTPURLResponse(url: request.url!, statusCode: 501, httpVersion: nil, headerFields: nil)! client?.urlProtocol(self, didReceive: notFound, cacheStoragePolicy: .notAllowed) client?.urlProtocolDidFinishLoading(self) return } - + let url = request.url ?? URL(string: "about:blank")! let httpResponse = HTTPURLResponse( url: url, @@ -36,32 +36,32 @@ final class HTTPStubURLProtocol: URLProtocol { httpVersion: "HTTP/1.1", headerFields: stub.headers )! - + client?.urlProtocol(self, didReceive: httpResponse, cacheStoragePolicy: .notAllowed) if let body = stub.body { client?.urlProtocol(self, didLoad: body) } client?.urlProtocolDidFinishLoading(self) } - - override func stopLoading() { } - + + override func stopLoading() {} + // MARK: - Public API - + static func register(_ handler: @escaping (URLRequest) -> StubbedResponse?) { handlerQueue.sync { requestHandlers.append(handler) } } - + static func reset() { handlerQueue.sync { requestHandlers.removeAll() } } - + private static func handler(for request: URLRequest) -> StubbedResponse? { - return handlerQueue.sync { + handlerQueue.sync { for resolver in requestHandlers.reversed() { if let response = resolver(request) { return response @@ -71,5 +71,3 @@ final class HTTPStubURLProtocol: URLProtocol { } } } - - diff --git a/PalaceTests/Mocks/MockImageCache.swift b/PalaceTests/Mocks/MockImageCache.swift index c65ef7c64..0253c304d 100644 --- a/PalaceTests/Mocks/MockImageCache.swift +++ b/PalaceTests/Mocks/MockImageCache.swift @@ -2,49 +2,49 @@ import UIKit @testable import Palace public final class MockImageCache: ImageCacheType { - private var store: [String: UIImage] = [:] - private var expirations: [String: Date] = [:] - - public private(set) var setKeys: [String] = [] - public private(set) var removedKeys: [String] = [] - public private(set) var cleared: Bool = false - - public var now: Date = Date() - - public func set(_ image: UIImage, for key: String, expiresIn: TimeInterval?) { - store[key] = image - setKeys.append(key) - if let ttl = expiresIn { - expirations[key] = now.addingTimeInterval(ttl) - } else { - expirations[key] = nil - } + private var store: [String: UIImage] = [:] + private var expirations: [String: Date] = [:] + + public private(set) var setKeys: [String] = [] + public private(set) var removedKeys: [String] = [] + public private(set) var cleared: Bool = false + + public var now: Date = .init() + + public func set(_ image: UIImage, for key: String, expiresIn: TimeInterval?) { + store[key] = image + setKeys.append(key) + if let ttl = expiresIn { + expirations[key] = now.addingTimeInterval(ttl) + } else { + expirations[key] = nil } + } - public func get(for key: String) -> UIImage? { - if let exp = expirations[key], exp < now { - store.removeValue(forKey: key) - expirations.removeValue(forKey: key) - return nil - } - return store[key] - } - - public func remove(for key: String) { - store.removeValue(forKey: key) - expirations.removeValue(forKey: key) - removedKeys.append(key) - } - - public func clear() { - store.removeAll() - expirations.removeAll() - cleared = true - } - - public func resetHistory() { - setKeys.removeAll() - removedKeys.removeAll() - cleared = false + public func get(for key: String) -> UIImage? { + if let exp = expirations[key], exp < now { + store.removeValue(forKey: key) + expirations.removeValue(forKey: key) + return nil } + return store[key] + } + + public func remove(for key: String) { + store.removeValue(forKey: key) + expirations.removeValue(forKey: key) + removedKeys.append(key) + } + + public func clear() { + store.removeAll() + expirations.removeAll() + cleared = true + } + + public func resetHistory() { + setKeys.removeAll() + removedKeys.removeAll() + cleared = false + } } diff --git a/PalaceTests/Mocks/NYPLLibraryAccountsProviderMock.swift b/PalaceTests/Mocks/NYPLLibraryAccountsProviderMock.swift index cd07df1e4..aa2ffb722 100644 --- a/PalaceTests/Mocks/NYPLLibraryAccountsProviderMock.swift +++ b/PalaceTests/Mocks/NYPLLibraryAccountsProviderMock.swift @@ -25,7 +25,10 @@ class TPPLibraryAccountMock: NSObject, TPPLibraryAccountsProvider { let feedData = try! Data(contentsOf: feedURL) feed = try! OPDS2CatalogsFeed.fromData(feedData) - tppAccount = Account(publication: feed.catalogs.first(where: { $0.metadata.title == "The New York Public Library" })!, imageCache: MockImageCache()) + tppAccount = Account( + publication: feed.catalogs.first(where: { $0.metadata.title == "The New York Public Library" })!, + imageCache: MockImageCache() + ) super.init() @@ -33,47 +36,53 @@ class TPPLibraryAccountMock: NSObject, TPPLibraryAccountsProvider { } var barcodeAuthentication: AccountDetails.Authentication { - return tppAccount.details!.auths.first { $0.authType == .basic }! + tppAccount.details!.auths.first { $0.authType == .basic }! } var oauthAuthentication: AccountDetails.Authentication { - return tppAccount.details!.auths.first { $0.authType == .oauthIntermediary }! + tppAccount.details!.auths.first { $0.authType == .oauthIntermediary }! } var cleverAuthentication: AccountDetails.Authentication { - return oauthAuthentication + oauthAuthentication } var samlAuthentication: AccountDetails.Authentication { - return tppAccount.details!.auths.first { $0.authType == .saml }! + tppAccount.details!.auths.first { $0.authType == .saml }! } var tppAccountUUID: String { - return tppAccount.uuid + tppAccount.uuid } var currentAccountId: String? { - return tppAccount.uuid + tppAccount.uuid } var currentAccount: Account? { - return tppAccount + tppAccount } func createOPDS2Publication() -> OPDS2Publication { - let link = OPDS2Link(href: "href\(arc4random())", + let link = OPDS2Link( + href: "href\(arc4random())", type: "type\(arc4random())", rel: "rel\(arc4random())", templated: false, displayNames: nil, - descriptions: nil) - let metadata = OPDS2Publication.Metadata(updated: Date(), - description: "OPDS2 metadata", - id: "metadataID", - title: "metadataTitle") - let pub = OPDS2Publication(links: [link], - metadata: metadata, - images: nil) + descriptions: nil + ) + let metadata = OPDS2Publication.Metadata( + updated: Date(), + description: "OPDS2 metadata", + id: "metadataID", + title: "metadataTitle" + ) + let pub = OPDS2Publication( + links: [link], + metadata: metadata, + images: nil + ) return pub } diff --git a/PalaceTests/Mocks/NYPLNetworkExecutorMock.swift b/PalaceTests/Mocks/NYPLNetworkExecutorMock.swift index 5639ce130..cf8e41362 100644 --- a/PalaceTests/Mocks/NYPLNetworkExecutorMock.swift +++ b/PalaceTests/Mocks/NYPLNetworkExecutorMock.swift @@ -9,7 +9,7 @@ import Foundation @testable import Palace -class TPPRequestExecutorMock: TPPRequestExecuting { +class TPPRequestExecutorMock: TPPRequestExecuting { var requestTimeout: TimeInterval = 60 // table of all mock response bodies for given URLs @@ -21,39 +21,54 @@ class TPPRequestExecutorMock: TPPRequestExecuting { responseBodies[userProfileURL] = TPPFake.validUserProfileJson } - func executeRequest(_ req: URLRequest, - enableTokenRefresh: Bool, - completion: @escaping (NYPLResult) -> Void) -> URLSessionDataTask? { - + func executeRequest( + _ req: URLRequest, + enableTokenRefresh _: Bool, + completion: @escaping (NYPLResult) -> Void + ) -> URLSessionDataTask? { DispatchQueue.main.async { guard let url = req.url else { - completion(.failure(NSError(domain: "Unit tests: empty url", - code: 0, userInfo: nil), nil)) + completion(.failure(NSError( + domain: "Unit tests: empty url", + code: 0, + userInfo: nil + ), nil)) return } guard let responseBody = self.responseBodies[url] else { - let httpResponse = HTTPURLResponse(url: url, - statusCode: 404, - httpVersion: "1.1", - headerFields: [ - "Date": "Thu, 04 Feb 2021 02:24:08 GMT", - "Content-Length": "232"]) - - completion(.failure(NSError(domain: "Unit tests: 404", - code: 1, userInfo: nil), - httpResponse)) + let httpResponse = HTTPURLResponse( + url: url, + statusCode: 404, + httpVersion: "1.1", + headerFields: [ + "Date": "Thu, 04 Feb 2021 02:24:08 GMT", + "Content-Length": "232" + ] + ) + + completion(.failure( + NSError( + domain: "Unit tests: 404", + code: 1, + userInfo: nil + ), + httpResponse + )) return } let responseData = responseBody.data(using: .utf8)! - let httpResponse = HTTPURLResponse(url: url, - statusCode: 200, - httpVersion: "1.1", - headerFields: [ - "Content-Type": "vnd.librarysimplified/user-profile+json", - "Date": "Thu, 04 Feb 2021 02:24:56 GMT", - "Content-Length": "754",]) + let httpResponse = HTTPURLResponse( + url: url, + statusCode: 200, + httpVersion: "1.1", + headerFields: [ + "Content-Type": "vnd.librarysimplified/user-profile+json", + "Date": "Thu, 04 Feb 2021 02:24:56 GMT", + "Content-Length": "754" + ] + ) completion(.success(responseData, httpResponse)) } diff --git a/PalaceTests/Mocks/TPPAgeCheckChoiceStorageMock.swift b/PalaceTests/Mocks/TPPAgeCheckChoiceStorageMock.swift index c4c5346ac..ec53bca9d 100644 --- a/PalaceTests/Mocks/TPPAgeCheckChoiceStorageMock.swift +++ b/PalaceTests/Mocks/TPPAgeCheckChoiceStorageMock.swift @@ -11,7 +11,7 @@ import Foundation class TPPAgeCheckChoiceStorageMock: NSObject, TPPAgeCheckChoiceStorage { var userPresentedAgeCheck: Bool - + override init() { userPresentedAgeCheck = false super.init() diff --git a/PalaceTests/Mocks/TPPAnnotationMock.swift b/PalaceTests/Mocks/TPPAnnotationMock.swift index 342ae9721..105ec9711 100644 --- a/PalaceTests/Mocks/TPPAnnotationMock.swift +++ b/PalaceTests/Mocks/TPPAnnotationMock.swift @@ -2,18 +2,26 @@ import Foundation @testable import Palace @testable import PalaceAudiobookToolkit +// MARK: - TestBookmark + struct TestBookmark { var annotationId: String var value: String } +// MARK: - TPPAnnotationMock + class TPPAnnotationMock: NSObject, AnnotationsManager { var savedLocations: [String: [TestBookmark]] = [:] var bookmarks: [String: [TestBookmark]] = [:] - + var syncIsPossibleAndPermitted: Bool { true } - - func postListeningPosition(forBook bookID: String, selectorValue: String, completion: ((AnnotationResponse?) -> Void)?) { + + func postListeningPosition( + forBook bookID: String, + selectorValue: String, + completion: ((AnnotationResponse?) -> Void)? + ) { let annotationId = "\(generateRandomString(length: 8))\(bookID)" var array = savedLocations[bookID] ?? [] array.append(TestBookmark(annotationId: annotationId, value: selectorValue)) @@ -21,26 +29,31 @@ class TPPAnnotationMock: NSObject, AnnotationsManager { let response = AnnotationResponse(serverId: annotationId, timeStamp: Date().ISO8601Format()) completion?(response) } - + func postAudiobookBookmark(forBook bookID: String, selectorValue: String) async throws -> AnnotationResponse? { let annotationId = "\(generateRandomString(length: 8))\(bookID)" bookmarks[bookID]?.append(TestBookmark(annotationId: annotationId, value: selectorValue)) let response = AnnotationResponse(serverId: annotationId, timeStamp: Date().ISO8601Format()) return response } - - func getServerBookmarks(forBook book: TPPBook?, atURL annotationURL: URL?, motivation: Palace.TPPBookmarkSpec.Motivation, completion: @escaping ([Palace.Bookmark]?) -> ()) { + + func getServerBookmarks( + forBook book: TPPBook?, + atURL _: URL?, + motivation: Palace.TPPBookmarkSpec.Motivation, + completion: @escaping ([Palace.Bookmark]?) -> Void + ) { guard let bookID = book?.identifier else { completion([]) return } - + let bookmarks = motivation == .bookmark ? bookmarks[bookID] : savedLocations[bookID] completion(bookmarks?.compactMap { guard let selectorValueData = $0.value.data(using: String.Encoding.utf8) else { return nil } - + if let audiobookmark = try? JSONDecoder().decode(AudioBookmark.self, from: selectorValueData) { return audiobookmark } else { @@ -48,26 +61,26 @@ class TPPAnnotationMock: NSObject, AnnotationsManager { } }) } - - func deleteBookmark(annotationId: String, completionHandler: @escaping (Bool) -> ()) { + + func deleteBookmark(annotationId: String, completionHandler: @escaping (Bool) -> Void) { for (bookId, bookmarksArray) in bookmarks { let filteredBookmarks = bookmarksArray.filter { $0.annotationId != annotationId } bookmarks[bookId] = filteredBookmarks } - + completionHandler(true) } - + func generateRandomString(length: Int) -> String { let letters = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" var randomString = "" - + for _ in 0..([:]) private let bookStateSubject = CurrentValueSubject<(String, TPPBookState), Never>(("", .unregistered)) var isSyncing: Bool = false @@ -19,12 +21,13 @@ class TPPBookRegistryMock: NSObject, TPPBookRegistryProvider { } // MARK: - Mock Data Storage + var registry = [String: TPPBookRegistryRecord]() private var processingBooks = Set() // MARK: - TPPBookRegistryProvider Methods - func coverImage(for book: TPPBook, handler: @escaping (UIImage?) -> Void) { + func coverImage(for _: TPPBook, handler: @escaping (UIImage?) -> Void) { // Simulate fetching a cover image let mockImage = UIImage(systemName: "book.fill") handler(mockImage) @@ -39,12 +42,14 @@ class TPPBookRegistryMock: NSObject, TPPBookRegistryProvider { } func state(for bookIdentifier: String?) -> TPPBookState { - guard let bookIdentifier = bookIdentifier else { return .unregistered } + guard let bookIdentifier = bookIdentifier else { + return .unregistered + } return registry[bookIdentifier]?.state ?? .unregistered } func readiumBookmarks(forIdentifier identifier: String) -> [TPPReadiumBookmark] { - return registry[identifier]?.readiumBookmarks ?? [] + registry[identifier]?.readiumBookmarks ?? [] } func setLocation(_ location: TPPBookLocation?, forIdentifier identifier: String) { @@ -52,7 +57,7 @@ class TPPBookRegistryMock: NSObject, TPPBookRegistryProvider { } func location(forIdentifier identifier: String) -> TPPBookLocation? { - return registry[identifier]?.location + registry[identifier]?.location } func add(_ bookmark: TPPReadiumBookmark, forIdentifier identifier: String) { @@ -63,14 +68,18 @@ class TPPBookRegistryMock: NSObject, TPPBookRegistryProvider { registry[identifier]?.readiumBookmarks?.removeAll { $0 == bookmark } } - func replace(_ oldBookmark: TPPReadiumBookmark, with newBookmark: TPPReadiumBookmark, forIdentifier identifier: String) { + func replace( + _ oldBookmark: TPPReadiumBookmark, + with newBookmark: TPPReadiumBookmark, + forIdentifier identifier: String + ) { if let index = registry[identifier]?.readiumBookmarks?.firstIndex(of: oldBookmark) { registry[identifier]?.readiumBookmarks?[index] = newBookmark } } func genericBookmarksForIdentifier(_ bookIdentifier: String) -> [TPPBookLocation] { - return registry[bookIdentifier]?.genericBookmarks ?? [] + registry[bookIdentifier]?.genericBookmarks ?? [] } func addOrReplaceGenericBookmark(_ location: TPPBookLocation, forIdentifier bookIdentifier: String) { @@ -94,13 +103,24 @@ class TPPBookRegistryMock: NSObject, TPPBookRegistryProvider { registry[bookIdentifier]?.genericBookmarks?.removeAll { $0.isSimilarTo(location) } } - func replaceGenericBookmark(_ oldLocation: TPPBookLocation, with newLocation: TPPBookLocation, forIdentifier bookIdentifier: String) { + func replaceGenericBookmark( + _ oldLocation: TPPBookLocation, + with newLocation: TPPBookLocation, + forIdentifier bookIdentifier: String + ) { if let index = registry[bookIdentifier]?.genericBookmarks?.firstIndex(where: { $0.isSimilarTo(oldLocation) }) { registry[bookIdentifier]?.genericBookmarks?[index] = newLocation } } - func addBook(_ book: TPPBook, location: TPPBookLocation? = nil, state: TPPBookState, fulfillmentId: String? = nil, readiumBookmarks: [TPPReadiumBookmark]? = nil, genericBookmarks: [TPPBookLocation]? = nil) { + func addBook( + _ book: TPPBook, + location: TPPBookLocation? = nil, + state: TPPBookState, + fulfillmentId: String? = nil, + readiumBookmarks: [TPPReadiumBookmark]? = nil, + genericBookmarks: [TPPBookLocation]? = nil + ) { let record = TPPBookRegistryRecord( book: book, location: location, @@ -134,12 +154,16 @@ class TPPBookRegistryMock: NSObject, TPPBookRegistryProvider { } func book(forIdentifier bookIdentifier: String?) -> TPPBook? { - guard let bookIdentifier = bookIdentifier else { return nil } + guard let bookIdentifier = bookIdentifier else { + return nil + } return registry[bookIdentifier]?.book } func fulfillmentId(forIdentifier bookIdentifier: String?) -> String? { - guard let bookIdentifier = bookIdentifier else { return nil } + guard let bookIdentifier = bookIdentifier else { + return nil + } return registry[bookIdentifier]?.fulfillmentId } @@ -147,14 +171,17 @@ class TPPBookRegistryMock: NSObject, TPPBookRegistryProvider { registry[bookIdentifier]?.fulfillmentId = fulfillmentId } - func with(account: String, perform block: (_ registry: TPPBookRegistry) -> Void) { + func with(account _: String, perform _: (_ registry: TPPBookRegistry) -> Void) { // Mock implementation does not support account-specific operations } } +// MARK: TPPBookRegistrySyncing + extension TPPBookRegistryMock: TPPBookRegistrySyncing { // MARK: - Syncing - func reset(_ libraryAccountUUID: String) { + + func reset(_: String) { isSyncing = false registry.removeAll() } diff --git a/PalaceTests/Mocks/TPPCurrentLibraryAccountProviderMock.swift b/PalaceTests/Mocks/TPPCurrentLibraryAccountProviderMock.swift index ebd84d2bd..48550ec44 100644 --- a/PalaceTests/Mocks/TPPCurrentLibraryAccountProviderMock.swift +++ b/PalaceTests/Mocks/TPPCurrentLibraryAccountProviderMock.swift @@ -11,21 +11,25 @@ import Foundation class TPPCurrentLibraryAccountProviderMock: NSObject, TPPCurrentLibraryAccountProvider { var currentAccount: Account? - + override init() { let feedURL = Bundle(for: TPPLibraryAccountMock.self) .url(forResource: "OPDS2CatalogsFeed", withExtension: "json")! let simplyeAuthDocURL = Bundle(for: TPPLibraryAccountMock.self) - .url(forResource: "simplye_authentication_document", withExtension: "json")! - + .url(forResource: "simplye_authentication_document", withExtension: "json")! + let feedData = try! Data(contentsOf: feedURL) let feed = try! OPDS2CatalogsFeed.fromData(feedData) - currentAccount = Account(publication: feed.catalogs.first(where: { $0.metadata.title == "The SimplyE Collection" })!, imageCache: MockImageCache()) - + currentAccount = Account( + publication: feed.catalogs.first(where: { $0.metadata.title == "The SimplyE Collection" })!, + imageCache: MockImageCache() + ) + super.init() - - currentAccount?.authenticationDocument = try! OPDS2AuthenticationDocument.fromData(try Data(contentsOf: simplyeAuthDocURL)) + + currentAccount?.authenticationDocument = try! OPDS2AuthenticationDocument + .fromData(try Data(contentsOf: simplyeAuthDocURL)) } } diff --git a/PalaceTests/Mocks/TPPDRMAuthorizingMock.swift b/PalaceTests/Mocks/TPPDRMAuthorizingMock.swift index 3e8d28352..045c90c73 100644 --- a/PalaceTests/Mocks/TPPDRMAuthorizingMock.swift +++ b/PalaceTests/Mocks/TPPDRMAuthorizingMock.swift @@ -14,15 +14,26 @@ class TPPDRMAuthorizingMock: NSObject, TPPDRMAuthorizing { let deviceID = "drmDeviceID" let userID = "drmUserID" - func isUserAuthorized(_ userID: String!, withDevice device: String!) -> Bool { - return true + func isUserAuthorized(_: String!, withDevice _: String!) -> Bool { + true } - func authorize(withVendorID vendorID: String!, username: String!, password: String!, completion: ((Bool, Error?, String?, String?) -> Void)!) { + func authorize( + withVendorID _: String!, + username _: String!, + password _: String!, + completion: ((Bool, Error?, String?, String?) -> Void)! + ) { completion(true, nil, deviceID, userID) } - func deauthorize(withUsername username: String!, password: String!, userID: String!, deviceID: String!, completion: ((Bool, Error?) -> Void)!) { + func deauthorize( + withUsername _: String!, + password _: String!, + userID _: String!, + deviceID _: String!, + completion: ((Bool, Error?) -> Void)! + ) { completion(true, nil) } } diff --git a/PalaceTests/Mocks/TPPMyBooksDownloadsCenterMock.swift b/PalaceTests/Mocks/TPPMyBooksDownloadsCenterMock.swift index d847cc0f2..578227cc8 100644 --- a/PalaceTests/Mocks/TPPMyBooksDownloadsCenterMock.swift +++ b/PalaceTests/Mocks/TPPMyBooksDownloadsCenterMock.swift @@ -10,6 +10,5 @@ import Foundation @testable import Palace class TPPMyBooksDownloadsCenterMock: TPPBookDownloadsDeleting { - func reset(_ libraryID: String!) { - } + func reset(_: String!) {} } diff --git a/PalaceTests/Mocks/TPPSignInOutBusinessLogicUIDelegateMock.swift b/PalaceTests/Mocks/TPPSignInOutBusinessLogicUIDelegateMock.swift index 11d14a3d2..8e93d4d23 100644 --- a/PalaceTests/Mocks/TPPSignInOutBusinessLogicUIDelegateMock.swift +++ b/PalaceTests/Mocks/TPPSignInOutBusinessLogicUIDelegateMock.swift @@ -10,41 +10,40 @@ import Foundation @testable import Palace class TPPSignInOutBusinessLogicUIDelegateMock: NSObject, TPPSignInOutBusinessLogicUIDelegate { - func businessLogicWillSignOut(_ businessLogic: TPPSignInBusinessLogic) { - } + func businessLogicWillSignOut(_: TPPSignInBusinessLogic) {} - func businessLogic(_ logic: TPPSignInBusinessLogic, - didEncounterSignOutError error: Error?, - withHTTPStatusCode httpStatusCode: Int) { - } + func businessLogic( + _: TPPSignInBusinessLogic, + didEncounterSignOutError _: Error?, + withHTTPStatusCode _: Int + ) {} - func businessLogicDidFinishDeauthorizing(_ logic: TPPSignInBusinessLogic) { - } + func businessLogicDidFinishDeauthorizing(_: TPPSignInBusinessLogic) {} - func businessLogicDidCancelSignIn(_ businessLogic: TPPSignInBusinessLogic) { - } + func businessLogicDidCancelSignIn(_: TPPSignInBusinessLogic) {} var context = "Unit Tests Context" - func businessLogicWillSignIn(_ businessLogic: TPPSignInBusinessLogic) { - } + func businessLogicWillSignIn(_: TPPSignInBusinessLogic) {} - func businessLogicDidCompleteSignIn(_ businessLogic: TPPSignInBusinessLogic) { - } + func businessLogicDidCompleteSignIn(_: TPPSignInBusinessLogic) {} - func businessLogic(_ logic: TPPSignInBusinessLogic, - didEncounterValidationError error: Error?, - userFriendlyErrorTitle title: String?, - andMessage message: String?) { - } + func businessLogic( + _: TPPSignInBusinessLogic, + didEncounterValidationError _: Error?, + userFriendlyErrorTitle _: String?, + andMessage _: String? + ) {} - func dismiss(animated flag: Bool, completion: (() -> Void)?) { + func dismiss(animated _: Bool, completion: (() -> Void)?) { completion?() } - func present(_ viewControllerToPresent: UIViewController, - animated flag: Bool, - completion: (() -> Void)?) { + func present( + _: UIViewController, + animated _: Bool, + completion: (() -> Void)? + ) { completion?() } @@ -52,9 +51,9 @@ class TPPSignInOutBusinessLogicUIDelegateMock: NSObject, TPPSignInOutBusinessLog var pin: String? = "pin" - var usernameTextField: UITextField? = nil + var usernameTextField: UITextField? - var PINTextField: UITextField? = nil + var PINTextField: UITextField? var forceEditability: Bool = false } diff --git a/PalaceTests/Mocks/TPPURLSettingsProviderMock.swift b/PalaceTests/Mocks/TPPURLSettingsProviderMock.swift index afc1762e3..8d5ee9123 100644 --- a/PalaceTests/Mocks/TPPURLSettingsProviderMock.swift +++ b/PalaceTests/Mocks/TPPURLSettingsProviderMock.swift @@ -13,7 +13,6 @@ class TPPURLSettingsProviderMock: NSObject, NYPLUniversalLinksSettings, NYPLFeed var accountMainFeedURL: URL? var universalLinksURL: URL { - return URL(string: "https://example.com/univeral-link-redirect")! + URL(string: "https://example.com/univeral-link-redirect")! } } - diff --git a/PalaceTests/Mocks/TPPUserAccountMock.swift b/PalaceTests/Mocks/TPPUserAccountMock.swift index 3b48eb2f0..ac554e34d 100644 --- a/PalaceTests/Mocks/TPPUserAccountMock.swift +++ b/PalaceTests/Mocks/TPPUserAccountMock.swift @@ -12,7 +12,7 @@ import Foundation class TPPUserAccountMock: TPPUserAccount { override init() { super.init() - print("#### init'ing userAccount \(self.hash)") + print("#### init'ing userAccount \(hash)") } deinit { @@ -20,16 +20,16 @@ class TPPUserAccountMock: TPPUserAccount { } private static var shared = TPPUserAccountMock() - override class func sharedAccount(libraryUUID: String?) -> TPPUserAccount { - return shared + override class func sharedAccount(libraryUUID _: String?) -> TPPUserAccount { + shared } - // MARK:- Variable redefinitions to avoid keychain + // MARK: - Variable redefinitions to avoid keychain var _authDefinition: AccountDetails.Authentication? override var authDefinition: AccountDetails.Authentication? { get { - return _authDefinition + _authDefinition } set { _authDefinition = newValue @@ -39,7 +39,7 @@ class TPPUserAccountMock: TPPUserAccount { var _credentials: TPPCredentials? override var credentials: TPPCredentials? { get { - return _credentials + _credentials } set { _credentials = newValue @@ -48,95 +48,105 @@ class TPPUserAccountMock: TPPUserAccount { private var _authorizationIdentifier: String? override var authorizationIdentifier: String? { - return _authorizationIdentifier + _authorizationIdentifier } + override func setAuthorizationIdentifier(_ identifier: String) { _authorizationIdentifier = identifier } private var _deviceID: String? override var deviceID: String? { - return _deviceID + _deviceID } + override func setDeviceID(_ newValue: String) { _deviceID = newValue } private var _userID: String? override var userID: String? { - return _userID + _userID } + override func setUserID(_ newValue: String) { _userID = newValue } private var _adobeVendor: String? override var adobeVendor: String? { - return _adobeVendor + _adobeVendor } + override func setAdobeVendor(_ newValue: String) { _adobeVendor = newValue } private var _provider: String? override var provider: String? { - return _provider + _provider } + override func setProvider(_ newValue: String) { _provider = newValue } private var _patron: [String: Any]? override var patron: [String: Any]? { - return _patron + _patron } + override func setPatron(_ newValue: [String: Any]) { _patron = newValue } private var _adobeToken: String? override var adobeToken: String? { - return _adobeToken + _adobeToken } + override func setAdobeToken(_ newValue: String) { _adobeToken = newValue } - override func setAdobeToken(_ token: String, patron: [String : Any]) { + + override func setAdobeToken(_ token: String, patron: [String: Any]) { _adobeToken = token _patron = patron } private var _licensor: [String: Any]? override var licensor: [String: Any]? { - return _licensor + _licensor } + override func setLicensor(_ newValue: [String: Any]) { _licensor = newValue } private var _cookies: [HTTPCookie]? override var cookies: [HTTPCookie]? { - return _cookies + _cookies } + override func setCookies(_ newValue: [HTTPCookie]) { _cookies = newValue } override var legacyAuthToken: String? { - return nil + nil } private var _authToken: String? override var authToken: String? { - return _authToken + _authToken } - override func setAuthToken(_ token: String, barcode: String?, pin: String?, expirationDate: Date?) { + override func setAuthToken(_ token: String, barcode _: String?, pin _: String?, expirationDate _: Date?) { _authToken = token } - // MARK:- Clean everything up - + // MARK: - Clean everything up + override func removeAll() { _adobeToken = nil _patron = nil diff --git a/PalaceTests/Mocks/TPPUserAccountProviderMock.swift b/PalaceTests/Mocks/TPPUserAccountProviderMock.swift index 620f273f0..07eefbcab 100644 --- a/PalaceTests/Mocks/TPPUserAccountProviderMock.swift +++ b/PalaceTests/Mocks/TPPUserAccountProviderMock.swift @@ -11,16 +11,16 @@ import Foundation class TPPUserAccountProviderMock: NSObject, TPPUserAccountProvider { private static let userAccountMock = TPPUserAccountMock() - + var needsAuth: Bool - - static func sharedAccount(libraryUUID: String?) -> TPPUserAccount { - return userAccountMock + + static func sharedAccount(libraryUUID _: String?) -> TPPUserAccount { + userAccountMock } - + override init() { needsAuth = false - + super.init() } } diff --git a/PalaceTests/MyBooksDownloadCenterTests.swift b/PalaceTests/MyBooksDownloadCenterTests.swift index 86b76885a..88b3ac230 100644 --- a/PalaceTests/MyBooksDownloadCenterTests.swift +++ b/PalaceTests/MyBooksDownloadCenterTests.swift @@ -4,8 +4,9 @@ import XCTest let testFeedUrl = Bundle(for: OPDS2CatalogsFeedTests.self) .url(forResource: "OPDS2CatalogsFeed", withExtension: "json")! -class MyBooksDownloadCenterTests: XCTestCase { +// MARK: - MyBooksDownloadCenterTests +class MyBooksDownloadCenterTests: XCTestCase { var myBooksDownloadCenter: MyBooksDownloadCenter! var mockUserAccount: TPPUserAccount! var mockReauthenticator: TPPReauthenticatorMock! @@ -25,12 +26,8 @@ class MyBooksDownloadCenterTests: XCTestCase { ) } - override func tearDown() { - super.tearDown() - } - func testBorrowBook() { - let expectation = self.expectation(description: "Book is sent to downloading state") + let expectation = expectation(description: "Book is sent to downloading state") var fulfilled = false NotificationCenter.default.removeObserver(self, name: .TPPMyBooksDownloadCenterDidChange, object: nil) @@ -41,9 +38,11 @@ class MyBooksDownloadCenterTests: XCTestCase { forName: .TPPMyBooksDownloadCenterDidChange, object: nil, queue: nil - ) { notification in + ) { _ in // Ensure fulfill() is only called once - guard !fulfilled else { return } + guard !fulfilled else { + return + } fulfilled = true expectation.fulfill() @@ -52,7 +51,9 @@ class MyBooksDownloadCenterTests: XCTestCase { } } - swizzle(selector: #selector(TPPOPDSFeed.swizzledURL_Success(_:shouldResetCache:useTokenIfAvailable:completionHandler:))) + swizzle(selector: #selector(TPPOPDSFeed + .swizzledURL_Success(_:shouldResetCache:useTokenIfAvailable:completionHandler:) + )) let book = TPPBookMocker.mockBook(distributorType: .AdobeAdept) myBooksDownloadCenter.startBorrow(for: book, attemptDownload: true) @@ -61,16 +62,19 @@ class MyBooksDownloadCenterTests: XCTestCase { } func testBorrowBook_withReauthentication() { - let expectation = self.expectation(description: "Books is sent to downloading state") + let expectation = expectation(description: "Books is sent to downloading state") let notificationObserver = NotificationCenter.default.addObserver( forName: .TPPMyBooksDownloadCenterDidChange, object: nil, - queue: nil) { notification in - expectation.fulfill() - } + queue: nil + ) { _ in + expectation.fulfill() + } - swizzle(selector: #selector(TPPOPDSFeed.swizzledURL_Error(_:shouldResetCache:useTokenIfAvailable:completionHandler:))) + swizzle(selector: #selector(TPPOPDSFeed + .swizzledURL_Error(_:shouldResetCache:useTokenIfAvailable:completionHandler:) + )) let book = TPPBookMocker.mockBook(distributorType: .AdobeAdept) myBooksDownloadCenter.startBorrow(for: book, attemptDownload: true) @@ -84,8 +88,10 @@ class MyBooksDownloadCenterTests: XCTestCase { // Give CI/CD some buffer time before assertion DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { let bookState = self.mockBookRegistry.state(for: book.identifier) - XCTAssertTrue([.downloading, .downloadSuccessful].contains(bookState), - "The book should be in the 'Downloading' or 'Download Successful' state.") + XCTAssertTrue( + [.downloading, .downloadSuccessful].contains(bookState), + "The book should be in the 'Downloading' or 'Download Successful' state." + ) } } @@ -93,10 +99,10 @@ class MyBooksDownloadCenterTests: XCTestCase { let aClass: AnyClass? = object_getClass(TPPOPDSFeed.self) let originalSelector = #selector(TPPOPDSFeed.withURL(_:shouldResetCache:useTokenIfAvailable:completionHandler:)) let swizzledSelector = selector - + let originalMethod = class_getInstanceMethod(aClass, originalSelector) let swizzledMethod = class_getInstanceMethod(TPPOPDSFeed.self, swizzledSelector) - + method_exchangeImplementations(originalMethod!, swizzledMethod!) } } @@ -110,18 +116,20 @@ let mockFeed: TPPOPDSFeed = { extension TPPOPDSFeed { @objc func swizzledURL_Success( - _ url: URL, - shouldResetCache: Bool, - useTokenIfAvailable: Bool, - completionHandler: @escaping (TPPOPDSFeed?, [String: Any]?) -> Void) { + _: URL, + shouldResetCache _: Bool, + useTokenIfAvailable _: Bool, + completionHandler: @escaping (TPPOPDSFeed?, [String: Any]?) -> Void + ) { completionHandler(mockFeed, nil) } @objc func swizzledURL_Error( - _ url: URL, - shouldResetCache: Bool, - useTokenIfAvailable: Bool, - completionHandler: @escaping (TPPOPDSFeed?, [String: Any]?) -> Void) { - completionHandler(nil, ["type": TPPProblemDocument.TypeInvalidCredentials]) - } + _: URL, + shouldResetCache _: Bool, + useTokenIfAvailable _: Bool, + completionHandler: @escaping (TPPOPDSFeed?, [String: Any]?) -> Void + ) { + completionHandler(nil, ["type": TPPProblemDocument.TypeInvalidCredentials]) + } } diff --git a/PalaceTests/NetworkClientTests.swift b/PalaceTests/NetworkClientTests.swift index 6ca1fbf0c..15b293641 100644 --- a/PalaceTests/NetworkClientTests.swift +++ b/PalaceTests/NetworkClientTests.swift @@ -2,32 +2,32 @@ import XCTest @testable import Palace final class NetworkClientTests: XCTestCase { - override func setUp() { - super.setUp() - HTTPStubURLProtocol.reset() - } + override func setUp() { + super.setUp() + HTTPStubURLProtocol.reset() + } - func testGET_Success() async throws { - let expectedBody = Data("{\"ok\":true}".utf8) - HTTPStubURLProtocol.register { req in - guard req.url?.path == "/hello" else { return nil } - return .init(statusCode: 200, headers: ["Content-Type": "application/json"], body: expectedBody) - } + func testGET_Success() async throws { + let expectedBody = Data("{\"ok\":true}".utf8) + HTTPStubURLProtocol.register { req in + guard req.url?.path == "/hello" else { + return nil + } + return .init(statusCode: 200, headers: ["Content-Type": "application/json"], body: expectedBody) + } - // Build a URLSessionConfiguration that uses our stub protocol - let config = URLSessionConfiguration.ephemeral - config.protocolClasses = [HTTPStubURLProtocol.self] + // Build a URLSessionConfiguration that uses our stub protocol + let config = URLSessionConfiguration.ephemeral + config.protocolClasses = [HTTPStubURLProtocol.self] - // Inject executor with custom configuration into the client - let executor = TPPNetworkExecutor(cachingStrategy: .ephemeral, sessionConfiguration: config) - let client = URLSessionNetworkClient(executor: executor) + // Inject executor with custom configuration into the client + let executor = TPPNetworkExecutor(cachingStrategy: .ephemeral, sessionConfiguration: config) + let client = URLSessionNetworkClient(executor: executor) - let url = URL(string: "https://example.com/hello")! - let request = NetworkRequest(method: .GET, url: url) - let response = try await client.send(request) - XCTAssertEqual(response.response.statusCode, 200) - XCTAssertEqual(response.data, expectedBody) - } + let url = URL(string: "https://example.com/hello")! + let request = NetworkRequest(method: .GET, url: url) + let response = try await client.send(request) + XCTAssertEqual(response.response.statusCode, 200) + XCTAssertEqual(response.data, expectedBody) + } } - - diff --git a/PalaceTests/OPDS2CatalogsFeedTests.swift b/PalaceTests/OPDS2CatalogsFeedTests.swift index ead8c152a..ef7f893b7 100644 --- a/PalaceTests/OPDS2CatalogsFeedTests.swift +++ b/PalaceTests/OPDS2CatalogsFeedTests.swift @@ -11,75 +11,86 @@ import XCTest @testable import Palace class OPDS2CatalogsFeedTests: XCTestCase { - - let testFeedUrl = Bundle.init(for: OPDS2CatalogsFeedTests.self) + let testFeedUrl = Bundle(for: OPDS2CatalogsFeedTests.self) .url(forResource: "OPDS2CatalogsFeed", withExtension: "json")! - - let gplAuthUrl = Bundle.init(for: OPDS2CatalogsFeedTests.self) + + let gplAuthUrl = Bundle(for: OPDS2CatalogsFeedTests.self) .url(forResource: "gpl_authentication_document", withExtension: "json")! - let aclAuthUrl = Bundle.init(for: OPDS2CatalogsFeedTests.self) + let aclAuthUrl = Bundle(for: OPDS2CatalogsFeedTests.self) .url(forResource: "acl_authentication_document", withExtension: "json")! - let dplAuthUrl = Bundle.init(for: OPDS2CatalogsFeedTests.self) + let dplAuthUrl = Bundle(for: OPDS2CatalogsFeedTests.self) .url(forResource: "dpl_authentication_document", withExtension: "json")! - let nyplAuthUrl = Bundle.init(for: OPDS2CatalogsFeedTests.self) + let nyplAuthUrl = Bundle(for: OPDS2CatalogsFeedTests.self) .url(forResource: "nypl_authentication_document", withExtension: "json")! - + override func setUp() { - // Put setup code here. This method is called before the invocation of each test method in the class. + // Put setup code here. This method is called before the invocation of each test method in the class. } override func tearDown() { - // Put teardown code here. This method is called after the invocation of each test method in the class. + // Put teardown code here. This method is called after the invocation of each test method in the class. } func testLoadCatalogsFeed() { - do { let data = try Data(contentsOf: testFeedUrl) let feed = try OPDS2CatalogsFeed.fromData(data) - + XCTAssertEqual(feed.catalogs.count, 171) XCTAssertEqual(feed.links.count, 4) - } catch (let error) { + } catch { XCTAssert(false, error.localizedDescription) } } - + // This test will take a while, and shouldn't normally be run because it relies on the network func disabledTestLoadAllAuthenticationDocuments() { var errors: [String] = [] do { let data = try Data(contentsOf: testFeedUrl) let feed = try OPDS2CatalogsFeed.fromData(data) - + XCTAssertEqual(feed.catalogs.count, 171) XCTAssertEqual(feed.links.count, 4) - + for publication in feed.catalogs { do { - let authDocumentUrl = publication.links.first(where: { $0.type == "application/vnd.opds.authentication.v1.0+json" })!.href + let authDocumentUrl = publication.links + .first(where: { $0.type == "application/vnd.opds.authentication.v1.0+json" })!.href let authData = try Data(contentsOf: URL(string: authDocumentUrl)!) - let _ = try OPDS2AuthenticationDocument.fromData(authData) - } catch (let error) { + _ = try OPDS2AuthenticationDocument.fromData(authData) + } catch { errors.append("\(publication.metadata.title): \(error)") } } - } catch (let error) { + } catch { XCTAssert(false, error.localizedDescription) } XCTAssertEqual(errors, []) } - + func testInitAccountsWithPublication() { do { let data = try Data(contentsOf: testFeedUrl) let feed = try OPDS2CatalogsFeed.fromData(data) - - let gpl = Account(publication: feed.catalogs.first(where: { $0.metadata.title == "Glendora Public Library" })!, imageCache: MockImageCache()) - let acl = Account(publication: feed.catalogs.first(where: { $0.metadata.title == "Alameda County Library" })!, imageCache: MockImageCache()) - let dpl = Account(publication: feed.catalogs.first(where: { $0.metadata.title == "Digital Public Library of America" })!, imageCache: MockImageCache()) - let nypl = Account(publication: feed.catalogs.first(where: { $0.metadata.title == "The New York Public Library" })!, imageCache: MockImageCache()) - + + let gpl = Account( + publication: feed.catalogs.first(where: { $0.metadata.title == "Glendora Public Library" })!, + imageCache: MockImageCache() + ) + let acl = Account( + publication: feed.catalogs.first(where: { $0.metadata.title == "Alameda County Library" })!, + imageCache: MockImageCache() + ) + let dpl = Account( + publication: feed.catalogs.first(where: { $0.metadata.title == "Digital Public Library of America" })!, + imageCache: MockImageCache() + ) + let nypl = Account( + publication: feed.catalogs.first(where: { $0.metadata.title == "The New York Public Library" })!, + imageCache: MockImageCache() + ) + XCTAssertEqual(gpl.name, "Glendora Public Library") XCTAssertEqual(gpl.subtitle, "Connecting people to the world of ideas, information, and imagination") XCTAssertEqual(gpl.uuid, "urn:uuid:a7bddadc-91c7-45a3-a642-dfd137480a22") @@ -87,7 +98,7 @@ class OPDS2CatalogsFeedTests: XCTestCase { XCTAssertEqual(gpl.supportEmail!.rawValue, "library@glendoralibrary.org") XCTAssertEqual(gpl.authenticationDocumentUrl, "http://califa108.simplye-ca.org/CAGLEN/authentication_document") XCTAssertNotNil(gpl.logo) - + XCTAssertEqual(acl.name, "Alameda County Library") XCTAssertEqual(acl.subtitle, "Infinite possibilities") XCTAssertEqual(acl.uuid, "urn:uuid:bce4c73c-9d0b-4eac-92e1-1405bcee9367") @@ -95,7 +106,7 @@ class OPDS2CatalogsFeedTests: XCTestCase { XCTAssertEqual(acl.supportEmail!.rawValue, "simplye@aclibrary.org") XCTAssertEqual(acl.authenticationDocumentUrl, "http://acl.simplye-ca.org/CALMDA/authentication_document") XCTAssertNotNil(acl.logo) - + XCTAssertEqual(dpl.name, "Digital Public Library of America") XCTAssertEqual(dpl.subtitle, "Popular books free to download and keep, handpicked by librarians across the US.") XCTAssertEqual(dpl.uuid, "urn:uuid:6b849570-070f-43b4-9dcc-7ebb4bca292e") @@ -103,41 +114,62 @@ class OPDS2CatalogsFeedTests: XCTestCase { XCTAssertEqual(dpl.supportEmail!.rawValue, "ebooks@dp.la") XCTAssertEqual(dpl.authenticationDocumentUrl, "http://openbookshelf.dp.la/OB/authentication_document") XCTAssertNotNil(dpl.logo) - + XCTAssertEqual(nypl.name, "The New York Public Library") - XCTAssertEqual(nypl.subtitle, "Inspiring lifelong learning, advancing knowledge, and strengthening our communities.") + XCTAssertEqual( + nypl.subtitle, + "Inspiring lifelong learning, advancing knowledge, and strengthening our communities." + ) XCTAssertEqual(nypl.uuid, "urn:uuid:065c0c11-0d0f-42a3-82e4-277b18786949") XCTAssertEqual(nypl.catalogUrl, "https://circulation.librarysimplified.org/NYNYPL/") XCTAssertEqual(nypl.supportEmail!.rawValue, "simplyehelp@nypl.org") - XCTAssertEqual(nypl.authenticationDocumentUrl, "https://circulation.librarysimplified.org/NYNYPL/authentication_document") + XCTAssertEqual( + nypl.authenticationDocumentUrl, + "https://circulation.librarysimplified.org/NYNYPL/authentication_document" + ) XCTAssertNotNil(nypl.logo) - - } catch (let error) { + + } catch { XCTAssert(false, error.localizedDescription) } } - + func testAccountSetAuthenticationDocument() { do { let data = try Data(contentsOf: testFeedUrl) let feed = try OPDS2CatalogsFeed.fromData(data) - - let gpl = Account(publication: feed.catalogs.first(where: { $0.metadata.title == "Glendora Public Library" })!, imageCache: MockImageCache()) - let acl = Account(publication: feed.catalogs.first(where: { $0.metadata.title == "Alameda County Library" })!, imageCache: MockImageCache()) - let dpl = Account(publication: feed.catalogs.first(where: { $0.metadata.title == "Digital Public Library of America" })!, imageCache: MockImageCache()) - let nypl = Account(publication: feed.catalogs.first(where: { $0.metadata.title == "The New York Public Library" })!, imageCache: MockImageCache()) - + + let gpl = Account( + publication: feed.catalogs.first(where: { $0.metadata.title == "Glendora Public Library" })!, + imageCache: MockImageCache() + ) + let acl = Account( + publication: feed.catalogs.first(where: { $0.metadata.title == "Alameda County Library" })!, + imageCache: MockImageCache() + ) + let dpl = Account( + publication: feed.catalogs.first(where: { $0.metadata.title == "Digital Public Library of America" })!, + imageCache: MockImageCache() + ) + let nypl = Account( + publication: feed.catalogs.first(where: { $0.metadata.title == "The New York Public Library" })!, + imageCache: MockImageCache() + ) + gpl.authenticationDocument = try OPDS2AuthenticationDocument.fromData(try Data(contentsOf: gplAuthUrl)) acl.authenticationDocument = try OPDS2AuthenticationDocument.fromData(try Data(contentsOf: aclAuthUrl)) dpl.authenticationDocument = try OPDS2AuthenticationDocument.fromData(try Data(contentsOf: dplAuthUrl)) nypl.authenticationDocument = try OPDS2AuthenticationDocument.fromData(try Data(contentsOf: nyplAuthUrl)) - + XCTAssertEqual(gpl.details?.defaultAuth?.needsAuth, true) XCTAssertEqual(gpl.details?.uuid, gpl.uuid) XCTAssertEqual(gpl.details?.supportsReservations, true) XCTAssertEqual(gpl.details?.userProfileUrl, "http://califa108.simplye-ca.org/CAGLEN/patrons/me/") XCTAssertEqual(gpl.details?.supportsSimplyESync, true) - XCTAssertEqual(gpl.details?.signUpUrl, URL(string:"https://catalog.ci.glendora.ca.us/polaris/patronaccount/selfregister.aspx?ctx=3.1033.0.0.1")) + XCTAssertEqual( + gpl.details?.signUpUrl, + URL(string: "https://catalog.ci.glendora.ca.us/polaris/patronaccount/selfregister.aspx?ctx=3.1033.0.0.1") + ) XCTAssertEqual(gpl.details?.supportsCardCreator, false) XCTAssertEqual(gpl.details?.getLicenseURL(.privacyPolicy), URL(string: "http://califa.org/privacy-policy")) XCTAssertEqual(gpl.details?.getLicenseURL(.eula), URL(string: "https://www.librarysimplified.org/EULA/")) @@ -149,7 +181,7 @@ class OPDS2CatalogsFeedTests: XCTestCase { XCTAssertEqual(gpl.details?.defaultAuth?.patronIDKeyboard, .numeric) XCTAssertEqual(gpl.details?.defaultAuth?.pinKeyboard, .numeric) XCTAssertEqual(gpl.details?.defaultAuth?.authPasscodeLength, 99) - + XCTAssertEqual(acl.details?.defaultAuth?.needsAuth, true) XCTAssertEqual(acl.details?.uuid, acl.uuid) XCTAssertEqual(acl.details?.supportsReservations, true) @@ -167,7 +199,7 @@ class OPDS2CatalogsFeedTests: XCTestCase { XCTAssertEqual(acl.details?.defaultAuth?.patronIDKeyboard, .numeric) XCTAssertEqual(acl.details?.defaultAuth?.pinKeyboard, .standard) XCTAssertEqual(acl.details?.defaultAuth?.authPasscodeLength, 99) - + XCTAssertEqual(dpl.details?.auths.count, 0) XCTAssertNil(dpl.details?.defaultAuth) XCTAssertEqual(dpl.details?.uuid, dpl.uuid) @@ -181,31 +213,36 @@ class OPDS2CatalogsFeedTests: XCTestCase { XCTAssertEqual(dpl.details?.getLicenseURL(.contentLicenses), nil) XCTAssertEqual(dpl.details?.getLicenseURL(.acknowledgements), nil) XCTAssertEqual(dpl.details?.mainColor, "cyan") - + XCTAssertEqual(nypl.details?.defaultAuth?.needsAuth, true) XCTAssertEqual(nypl.details?.uuid, nypl.uuid) XCTAssertEqual(nypl.details?.supportsReservations, true) XCTAssertEqual(nypl.details?.userProfileUrl, "https://circulation.librarysimplified.org/NYNYPL/patrons/me/") XCTAssertEqual(nypl.details?.supportsSimplyESync, true) XCTAssertNotNil(nypl.details?.signUpUrl) - XCTAssertEqual(nypl.details?.signUpUrl, - URL(string: "https://patrons.librarysimplified.org/")) + XCTAssertEqual( + nypl.details?.signUpUrl, + URL(string: "https://patrons.librarysimplified.org/") + ) XCTAssert(nypl.details?.supportsCardCreator ?? false) - XCTAssertEqual(nypl.details?.getLicenseURL(.privacyPolicy), - URL(string: "https://www.nypl.org/help/about-nypl/legal-notices/privacy-policy")) + XCTAssertEqual( + nypl.details?.getLicenseURL(.privacyPolicy), + URL(string: "https://www.nypl.org/help/about-nypl/legal-notices/privacy-policy") + ) XCTAssertEqual(nypl.details?.getLicenseURL(.eula), URL(string: "https://librarysimplified.org/EULA/")) - XCTAssertEqual(nypl.details?.getLicenseURL(.contentLicenses), - URL(string: "https://librarysimplified.org/licenses/")) + XCTAssertEqual( + nypl.details?.getLicenseURL(.contentLicenses), + URL(string: "https://librarysimplified.org/licenses/") + ) XCTAssertEqual(nypl.details?.mainColor, "red") XCTAssertEqual(nypl.details?.defaultAuth?.supportsBarcodeScanner, true) XCTAssertEqual(nypl.details?.defaultAuth?.supportsBarcodeDisplay, true) XCTAssertEqual(nypl.details?.defaultAuth?.patronIDKeyboard, .standard) XCTAssertEqual(nypl.details?.defaultAuth?.pinKeyboard, .standard) XCTAssertEqual(nypl.details?.defaultAuth?.authPasscodeLength, 12) - - } catch (let error) { + + } catch { XCTAssert(false, error.localizedDescription) } } - } diff --git a/PalaceTests/OPDSFeedParsingTests.swift b/PalaceTests/OPDSFeedParsingTests.swift index 8b6e503b5..98bb7c393 100644 --- a/PalaceTests/OPDSFeedParsingTests.swift +++ b/PalaceTests/OPDSFeedParsingTests.swift @@ -2,33 +2,31 @@ import XCTest @testable import Palace final class OPDSFeedParsingTests: XCTestCase { - func testParseValidOPDSFeed() { - let bundle = Bundle(for: type(of: self)) - guard let url = bundle.url(forResource: "main", withExtension: "xml") else { - XCTFail("Missing main.xml test resource") - return - } - do { - let data = try Data(contentsOf: url) - XCTAssertNoThrow(try OPDSParser().parseFeed(from: data)) - } catch { - XCTFail("Error loading resource: \(error)") - } + func testParseValidOPDSFeed() { + let bundle = Bundle(for: type(of: self)) + guard let url = bundle.url(forResource: "main", withExtension: "xml") else { + XCTFail("Missing main.xml test resource") + return } + do { + let data = try Data(contentsOf: url) + XCTAssertNoThrow(try OPDSParser().parseFeed(from: data)) + } catch { + XCTFail("Error loading resource: \(error)") + } + } - func testParseInvalidOPDSFeed() { - let bundle = Bundle(for: type(of: self)) - guard let url = bundle.url(forResource: "invalid", withExtension: "xml") else { - XCTFail("Missing invalid.xml test resource") - return - } - do { - let data = try Data(contentsOf: url) - XCTAssertThrowsError(try OPDSParser().parseFeed(from: data)) - } catch { - XCTFail("Error loading resource: \(error)") - } + func testParseInvalidOPDSFeed() { + let bundle = Bundle(for: type(of: self)) + guard let url = bundle.url(forResource: "invalid", withExtension: "xml") else { + XCTFail("Missing invalid.xml test resource") + return + } + do { + let data = try Data(contentsOf: url) + XCTAssertThrowsError(try OPDSParser().parseFeed(from: data)) + } catch { + XCTFail("Error loading resource: \(error)") } + } } - - diff --git a/PalaceTests/String+NYPLAdditionsTests.swift b/PalaceTests/String+NYPLAdditionsTests.swift index 0a1d103c2..1a0943180 100644 --- a/PalaceTests/String+NYPLAdditionsTests.swift +++ b/PalaceTests/String+NYPLAdditionsTests.swift @@ -36,17 +36,21 @@ class String_NYPLAdditionsTests: XCTestCase { } func testBase64Encode() { - let s = ("ynJZEsWMnTudEGg646Tmua" as NSString).fileSystemSafeBase64EncodedString(usingEncoding: String.Encoding.utf8.rawValue) + let s = ("ynJZEsWMnTudEGg646Tmua" as NSString) + .fileSystemSafeBase64EncodedString(usingEncoding: String.Encoding.utf8.rawValue) XCTAssertEqual(s, "eW5KWkVzV01uVHVkRUdnNjQ2VG11YQ") } func testBase64Decode() { - let s = ("eW5KWkVzV01uVHVkRUdnNjQ2VG11YQ" as NSString).fileSystemSafeBase64DecodedString(usingEncoding: String.Encoding.utf8.rawValue) + let s = ("eW5KWkVzV01uVHVkRUdnNjQ2VG11YQ" as NSString) + .fileSystemSafeBase64DecodedString(usingEncoding: String.Encoding.utf8.rawValue) XCTAssertEqual(s, "ynJZEsWMnTudEGg646Tmua") } func testSHA256() { - XCTAssertEqual(("967824¬Ó¨⁄€™®©♟♞♝♜♛♚♙♘♗♖♕♔" as NSString).sha256(), - "269b80eff0cd705e4b1de9fdbb2e1b0bccf30e6124cdc3487e8d74620eedf254") + XCTAssertEqual( + ("967824¬Ó¨⁄€™®©♟♞♝♜♛♚♙♘♗♖♕♔" as NSString).sha256(), + "269b80eff0cd705e4b1de9fdbb2e1b0bccf30e6124cdc3487e8d74620eedf254" + ) } } diff --git a/PalaceTests/TPPAgeCheckTests.swift b/PalaceTests/TPPAgeCheckTests.swift index 294b7c1f9..9e8b1364b 100644 --- a/PalaceTests/TPPAgeCheckTests.swift +++ b/PalaceTests/TPPAgeCheckTests.swift @@ -10,35 +10,34 @@ import XCTest @testable import Palace class TPPAgeCheckTests: XCTestCase { - // Classes/mocks needed for testing var ageCheckChoiceStorageMock: TPPAgeCheckChoiceStorageMock! var userAccountProviderMock: TPPUserAccountProviderMock! var simplyeLibraryAccountProviderMock: TPPCurrentLibraryAccountProviderMock! var ageCheck: TPPAgeCheck! - + // TPPAgeCheck checks the property userAboveAgeLimit in AccountDetails before performing age check // This property is store in UserDefault which can be different value when testing on different machine // And AccountDetails class is final and not protocol so we cannot override/mock it // The workaround here is to store the value of userAboveAgeLimit and restore it after each test // This way we can set whatever value we need in each tests, and no need to worry about the default value on different machine var defaultUserAboveAgeLimit: Bool! - + // Use expectation to test result within closure var expectation: XCTestExpectation! - + override func setUpWithError() throws { try super.setUpWithError() - + ageCheckChoiceStorageMock = TPPAgeCheckChoiceStorageMock() simplyeLibraryAccountProviderMock = TPPCurrentLibraryAccountProviderMock() userAccountProviderMock = TPPUserAccountProviderMock() - + defaultUserAboveAgeLimit = simplyeLibraryAccountProviderMock.currentAccount?.details?.userAboveAgeLimit ?? false simplyeLibraryAccountProviderMock.currentAccount?.details?.userAboveAgeLimit = false - - expectation = self.expectation(description: "AgeChecking") - + + expectation = expectation(description: "AgeChecking") + ageCheck = TPPAgeCheck(ageCheckChoiceStorage: ageCheckChoiceStorageMock) } @@ -47,81 +46,93 @@ class TPPAgeCheckTests: XCTestCase { } func testAge0() throws { - ageCheck.verifyCurrentAccountAgeRequirement(userAccountProvider: userAccountProviderMock, - currentLibraryAccountProvider: simplyeLibraryAccountProviderMock) { [weak self] (aboveAgeLimit) in + ageCheck.verifyCurrentAccountAgeRequirement( + userAccountProvider: userAccountProviderMock, + currentLibraryAccountProvider: simplyeLibraryAccountProviderMock + ) { [weak self] aboveAgeLimit in XCTAssertFalse(aboveAgeLimit) XCTAssertTrue(self?.ageCheckChoiceStorageMock.userPresentedAgeCheck ?? false) self?.expectation.fulfill() } - + let birthYear = Calendar.current.component(.year, from: Date()) ageCheck.didCompleteAgeCheck(birthYear) waitForExpectations(timeout: 1, handler: nil) } - + func testAge12() throws { - ageCheck.verifyCurrentAccountAgeRequirement(userAccountProvider: userAccountProviderMock, - currentLibraryAccountProvider: simplyeLibraryAccountProviderMock) { [weak self] (aboveAgeLimit) in + ageCheck.verifyCurrentAccountAgeRequirement( + userAccountProvider: userAccountProviderMock, + currentLibraryAccountProvider: simplyeLibraryAccountProviderMock + ) { [weak self] aboveAgeLimit in XCTAssertFalse(aboveAgeLimit) XCTAssertTrue(self?.ageCheckChoiceStorageMock.userPresentedAgeCheck ?? false) self?.expectation.fulfill() } - + let birthYear = Calendar.current.component(.year, from: Date()) - 12 ageCheck.didCompleteAgeCheck(birthYear) waitForExpectations(timeout: 1, handler: nil) } func testAge13() throws { - ageCheck.verifyCurrentAccountAgeRequirement(userAccountProvider: userAccountProviderMock, - currentLibraryAccountProvider: simplyeLibraryAccountProviderMock) { [weak self] (aboveAgeLimit) in + ageCheck.verifyCurrentAccountAgeRequirement( + userAccountProvider: userAccountProviderMock, + currentLibraryAccountProvider: simplyeLibraryAccountProviderMock + ) { [weak self] aboveAgeLimit in XCTAssertFalse(aboveAgeLimit) XCTAssertTrue(self?.ageCheckChoiceStorageMock.userPresentedAgeCheck ?? false) self?.expectation.fulfill() } - + let birthYear = Calendar.current.component(.year, from: Date()) - 13 ageCheck.didCompleteAgeCheck(birthYear) waitForExpectations(timeout: 1, handler: nil) } - + func testAge14() throws { - ageCheck.verifyCurrentAccountAgeRequirement(userAccountProvider: userAccountProviderMock, - currentLibraryAccountProvider: simplyeLibraryAccountProviderMock) { [weak self] (aboveAgeLimit) in + ageCheck.verifyCurrentAccountAgeRequirement( + userAccountProvider: userAccountProviderMock, + currentLibraryAccountProvider: simplyeLibraryAccountProviderMock + ) { [weak self] aboveAgeLimit in XCTAssertTrue(aboveAgeLimit) XCTAssertTrue(self?.ageCheckChoiceStorageMock.userPresentedAgeCheck ?? false) self?.expectation.fulfill() } - + let birthYear = Calendar.current.component(.year, from: Date()) - 20 ageCheck.didCompleteAgeCheck(birthYear) waitForExpectations(timeout: 1, handler: nil) } - + func testAge100() throws { - ageCheck.verifyCurrentAccountAgeRequirement(userAccountProvider: userAccountProviderMock, - currentLibraryAccountProvider: simplyeLibraryAccountProviderMock) { [weak self] (aboveAgeLimit) in + ageCheck.verifyCurrentAccountAgeRequirement( + userAccountProvider: userAccountProviderMock, + currentLibraryAccountProvider: simplyeLibraryAccountProviderMock + ) { [weak self] aboveAgeLimit in XCTAssertTrue(aboveAgeLimit) XCTAssertTrue(self?.ageCheckChoiceStorageMock.userPresentedAgeCheck ?? false) self?.expectation.fulfill() } - + let birthYear = Calendar.current.component(.year, from: Date()) - 100 ageCheck.didCompleteAgeCheck(birthYear) waitForExpectations(timeout: 1, handler: nil) } - + func testAgeCheckFailed() throws { // Use an inverted expectation to make sure the completion closure is not executed - self.expectation.isInverted = true - ageCheck.verifyCurrentAccountAgeRequirement(userAccountProvider: userAccountProviderMock, - currentLibraryAccountProvider: simplyeLibraryAccountProviderMock) { [weak self] (aboveAgeLimit) in + expectation.isInverted = true + ageCheck.verifyCurrentAccountAgeRequirement( + userAccountProvider: userAccountProviderMock, + currentLibraryAccountProvider: simplyeLibraryAccountProviderMock + ) { [weak self] _ in self?.expectation.fulfill() } - + ageCheck.didFailAgeCheck() waitForExpectations(timeout: 1, handler: nil) - + XCTAssertFalse(ageCheckChoiceStorageMock.userPresentedAgeCheck) } } diff --git a/PalaceTests/TPPAnnouncementManagerTests.swift b/PalaceTests/TPPAnnouncementManagerTests.swift index cce3a2e2b..8ac3d0410 100644 --- a/PalaceTests/TPPAnnouncementManagerTests.swift +++ b/PalaceTests/TPPAnnouncementManagerTests.swift @@ -3,22 +3,22 @@ import XCTest class TPPAnnouncementManagerTests: XCTestCase { let announcementId = "test_announcement_id" - + override func tearDown() { TPPAnnouncementBusinessLogic.shared.testing_deletePresentedAnnouncement(id: announcementId) } - + func testShouldPresentAnnouncement() { - XCTAssertTrue(TPPAnnouncementBusinessLogic.shared.testing_shouldPresentAnnouncement(id:announcementId)) + XCTAssertTrue(TPPAnnouncementBusinessLogic.shared.testing_shouldPresentAnnouncement(id: announcementId)) } - + func testAddPresentedAnnouncement() { TPPAnnouncementBusinessLogic.shared.addPresentedAnnouncement(id: announcementId) - XCTAssertFalse(TPPAnnouncementBusinessLogic.shared.testing_shouldPresentAnnouncement(id:announcementId)) + XCTAssertFalse(TPPAnnouncementBusinessLogic.shared.testing_shouldPresentAnnouncement(id: announcementId)) } - + func testDeletePresentedAnnouncement() { TPPAnnouncementBusinessLogic.shared.testing_deletePresentedAnnouncement(id: announcementId) - XCTAssertTrue(TPPAnnouncementBusinessLogic.shared.testing_shouldPresentAnnouncement(id:announcementId)) + XCTAssertTrue(TPPAnnouncementBusinessLogic.shared.testing_shouldPresentAnnouncement(id: announcementId)) } } diff --git a/PalaceTests/TPPBookCreationTests.swift b/PalaceTests/TPPBookCreationTests.swift index ec70dda6e..e1a24872a 100644 --- a/PalaceTests/TPPBookCreationTests.swift +++ b/PalaceTests/TPPBookCreationTests.swift @@ -15,14 +15,14 @@ class TPPBookCreationTests: XCTestCase { override func setUpWithError() throws { try super.setUpWithError() - self.opdsEntry = TPPFake.opdsEntry - self.opdsEntryMinimal = TPPFake.opdsEntryMinimal + opdsEntry = TPPFake.opdsEntry + opdsEntryMinimal = TPPFake.opdsEntryMinimal } override func tearDownWithError() throws { try super.tearDownWithError() - self.opdsEntry = nil - self.opdsEntryMinimal = nil + opdsEntry = nil + opdsEntryMinimal = nil } func testBookCreationViaDictionary() throws { @@ -30,7 +30,7 @@ class TPPBookCreationTests: XCTestCase { let book = TPPBook(dictionary: [ "acquisitions": acquisitions, - "categories" : ["Fantasy"], + "categories": ["Fantasy"], "id": "666", "title": "The Lord of the Rings", "updated": "2020-09-08T09:22:45Z" @@ -46,15 +46,15 @@ class TPPBookCreationTests: XCTestCase { let bookNoUpdatedDate = TPPBook(dictionary: [ "acquisitions": acquisitions, - "categories" : ["Fantasy"], + "categories": ["Fantasy"], "id": "666", - "title": "The Lord of the Rings", + "title": "The Lord of the Rings" ]) XCTAssertNil(bookNoUpdatedDate) let bookNoTitle = TPPBook(dictionary: [ "acquisitions": acquisitions, - "categories" : ["Fantasy"], + "categories": ["Fantasy"], "id": "666", "updated": "2020-09-08T09:22:45Z" ]) @@ -62,7 +62,7 @@ class TPPBookCreationTests: XCTestCase { let bookNoId = TPPBook(dictionary: [ "acquisitions": acquisitions, - "categories" : ["Fantasy"], + "categories": ["Fantasy"], "title": "The Lord of the Rings", "updated": "2020-09-08T09:22:45Z" ]) @@ -100,33 +100,34 @@ class TPPBookCreationTests: XCTestCase { // for completeness only. This test is not strictly necessary because the // member-wise initializer is not public func testBookCreationViaMemberWiseInitializer() { - let book = TPPBook(acquisitions: opdsEntry.acquisitions, - authors: nil, - categoryStrings: ["Test String 1", "Test String 2"], - distributor: nil, - identifier: "666", - imageURL: nil, - imageThumbnailURL: nil, - published: nil, - publisher: nil, - subtitle: nil, - summary: nil, - title: "The Lord of the Rings", - updated: Date(), - annotationsURL: nil, - analyticsURL: nil, - alternateURL: nil, - relatedWorksURL: nil, - previewLink: nil, - seriesURL: nil, - revokeURL: nil, - reportURL: nil, - timeTrackingURL: nil, - contributors: nil, - bookDuration: nil, - imageCache: MockImageCache() + let book = TPPBook( + acquisitions: opdsEntry.acquisitions, + authors: nil, + categoryStrings: ["Test String 1", "Test String 2"], + distributor: nil, + identifier: "666", + imageURL: nil, + imageThumbnailURL: nil, + published: nil, + publisher: nil, + subtitle: nil, + summary: nil, + title: "The Lord of the Rings", + updated: Date(), + annotationsURL: nil, + analyticsURL: nil, + alternateURL: nil, + relatedWorksURL: nil, + previewLink: nil, + seriesURL: nil, + revokeURL: nil, + reportURL: nil, + timeTrackingURL: nil, + contributors: nil, + bookDuration: nil, + imageCache: MockImageCache() ) - + XCTAssertNotNil(book) XCTAssertNotNil(book.acquisitions) XCTAssertNotNil(book.categoryStrings) diff --git a/PalaceTests/TPPBookMock.swift b/PalaceTests/TPPBookMock.swift index 82ac65dee..a4a6cd73f 100644 --- a/PalaceTests/TPPBookMock.swift +++ b/PalaceTests/TPPBookMock.swift @@ -9,6 +9,8 @@ import Foundation @testable import Palace +// MARK: - DistributorType + enum DistributorType: String { case OPDSCatalog = "application/atom+xml;type=entry;profile=opds-catalog" case AdobeAdept = "application/vnd.adobe.adept+xml" @@ -25,28 +27,30 @@ enum DistributorType: String { case AudiobookLCP = "application/audiobook+lcp" case AudiobookZip = "application/audiobook+zip" case Biblioboard = "application/json" - + static func randomIdentifier() -> String { - return UUID().uuidString + UUID().uuidString } } -struct TPPBookMocker { +// MARK: - TPPBookMocker + +enum TPPBookMocker { static func mockBook(distributorType: DistributorType) -> TPPBook { let configType = distributorType.rawValue - + // Randomly generated values for other fields let identifier = DistributorType.randomIdentifier() let emptyUrl = URL(string: "http://example.com/\(identifier)")! - + let fakeAcquisition = TPPOPDSAcquisition( relation: .generic, type: configType, hrefURL: emptyUrl, indirectAcquisitions: [TPPOPDSIndirectAcquisition](), - availability: TPPOPDSAcquisitionAvailabilityUnlimited.init() + availability: TPPOPDSAcquisitionAvailabilityUnlimited() ) - + let fakeBook = TPPBook( acquisitions: [fakeAcquisition], authors: [TPPBookAuthor(authorName: "Author \(identifier)", relatedBooksURL: nil)], @@ -55,12 +59,12 @@ struct TPPBookMocker { identifier: identifier, imageURL: emptyUrl, imageThumbnailURL: emptyUrl, - published: Date.init(), + published: Date(), publisher: "Publisher \(identifier)", subtitle: "Subtitle \(identifier)", summary: "Summary \(identifier)", title: "Title \(identifier)", - updated: Date.init(), + updated: Date(), annotationsURL: emptyUrl, analyticsURL: emptyUrl, alternateURL: emptyUrl, @@ -74,7 +78,7 @@ struct TPPBookMocker { bookDuration: nil, imageCache: MockImageCache() ) - + return fakeBook } } diff --git a/PalaceTests/TPPBookStateTests.swift b/PalaceTests/TPPBookStateTests.swift index ca9fbd9e8..13eb2ad15 100644 --- a/PalaceTests/TPPBookStateTests.swift +++ b/PalaceTests/TPPBookStateTests.swift @@ -3,43 +3,60 @@ import XCTest @testable import Palace class TPPBookStateTests: XCTestCase { - - func testInitWithString() { - XCTAssertEqual(TPPBookState.unregistered, TPPBookState.init(UnregisteredKey)) - XCTAssertEqual(TPPBookState.downloadNeeded, TPPBookState.init(DownloadNeededKey)) - XCTAssertEqual(TPPBookState.downloading, TPPBookState.init(DownloadingKey)) - XCTAssertEqual(TPPBookState.downloadFailed, TPPBookState.init(DownloadFailedKey)) - XCTAssertEqual(TPPBookState.downloadSuccessful, TPPBookState.init(DownloadSuccessfulKey)) - XCTAssertEqual(TPPBookState.holding, TPPBookState.init(HoldingKey)) - XCTAssertEqual(TPPBookState.used, TPPBookState.init(UsedKey)) - XCTAssertEqual(TPPBookState.unsupported, TPPBookState.init(UnsupportedKey)) - XCTAssertEqual(nil, TPPBookState.init("InvalidKey")) - } - - func testStringValue() { - XCTAssertEqual(TPPBookState.unregistered.stringValue(), UnregisteredKey) - XCTAssertEqual(TPPBookState.downloadNeeded.stringValue(), DownloadNeededKey) - XCTAssertEqual(TPPBookState.downloading.stringValue(), DownloadingKey) - XCTAssertEqual(TPPBookState.downloadFailed.stringValue(), DownloadFailedKey) - XCTAssertEqual(TPPBookState.downloadSuccessful.stringValue(), DownloadSuccessfulKey) - XCTAssertEqual(TPPBookState.holding.stringValue(), HoldingKey) - XCTAssertEqual(TPPBookState.used.stringValue(), UsedKey) - XCTAssertEqual(TPPBookState.unsupported.stringValue(), UnsupportedKey) - } - - func testBookStateFromString() { - XCTAssertEqual(TPPBookState.unregistered.rawValue, TPPBookStateHelper.bookState(fromString: UnregisteredKey)?.intValue) - XCTAssertEqual(TPPBookState.downloadNeeded.rawValue, TPPBookStateHelper.bookState(fromString: DownloadNeededKey)?.intValue) - XCTAssertEqual(TPPBookState.downloading.rawValue, TPPBookStateHelper.bookState(fromString: DownloadingKey)?.intValue) - XCTAssertEqual(TPPBookState.downloadFailed.rawValue, TPPBookStateHelper.bookState(fromString: DownloadFailedKey)?.intValue) - XCTAssertEqual(TPPBookState.downloadSuccessful.rawValue, TPPBookStateHelper.bookState(fromString: DownloadSuccessfulKey)?.intValue) - XCTAssertEqual(TPPBookState.holding.rawValue, TPPBookStateHelper.bookState(fromString: HoldingKey)?.intValue) - XCTAssertEqual(TPPBookState.used.rawValue, TPPBookStateHelper.bookState(fromString: UsedKey)?.intValue) - XCTAssertEqual(TPPBookState.unsupported.rawValue, TPPBookStateHelper.bookState(fromString: UnsupportedKey)?.intValue) - XCTAssertNil(TPPBookStateHelper.bookState(fromString: "InvalidString")) - } - - func testAllBookState() { - XCTAssertEqual(TPPBookStateHelper.allBookStates(), TPPBookState.allCases.map{ $0.rawValue }) - } + func testInitWithString() { + XCTAssertEqual(TPPBookState.unregistered, TPPBookState(UnregisteredKey)) + XCTAssertEqual(TPPBookState.downloadNeeded, TPPBookState(DownloadNeededKey)) + XCTAssertEqual(TPPBookState.downloading, TPPBookState(DownloadingKey)) + XCTAssertEqual(TPPBookState.downloadFailed, TPPBookState(DownloadFailedKey)) + XCTAssertEqual(TPPBookState.downloadSuccessful, TPPBookState(DownloadSuccessfulKey)) + XCTAssertEqual(TPPBookState.holding, TPPBookState(HoldingKey)) + XCTAssertEqual(TPPBookState.used, TPPBookState(UsedKey)) + XCTAssertEqual(TPPBookState.unsupported, TPPBookState(UnsupportedKey)) + XCTAssertEqual(nil, TPPBookState("InvalidKey")) + } + + func testStringValue() { + XCTAssertEqual(TPPBookState.unregistered.stringValue(), UnregisteredKey) + XCTAssertEqual(TPPBookState.downloadNeeded.stringValue(), DownloadNeededKey) + XCTAssertEqual(TPPBookState.downloading.stringValue(), DownloadingKey) + XCTAssertEqual(TPPBookState.downloadFailed.stringValue(), DownloadFailedKey) + XCTAssertEqual(TPPBookState.downloadSuccessful.stringValue(), DownloadSuccessfulKey) + XCTAssertEqual(TPPBookState.holding.stringValue(), HoldingKey) + XCTAssertEqual(TPPBookState.used.stringValue(), UsedKey) + XCTAssertEqual(TPPBookState.unsupported.stringValue(), UnsupportedKey) + } + + func testBookStateFromString() { + XCTAssertEqual( + TPPBookState.unregistered.rawValue, + TPPBookStateHelper.bookState(fromString: UnregisteredKey)?.intValue + ) + XCTAssertEqual( + TPPBookState.downloadNeeded.rawValue, + TPPBookStateHelper.bookState(fromString: DownloadNeededKey)?.intValue + ) + XCTAssertEqual( + TPPBookState.downloading.rawValue, + TPPBookStateHelper.bookState(fromString: DownloadingKey)?.intValue + ) + XCTAssertEqual( + TPPBookState.downloadFailed.rawValue, + TPPBookStateHelper.bookState(fromString: DownloadFailedKey)?.intValue + ) + XCTAssertEqual( + TPPBookState.downloadSuccessful.rawValue, + TPPBookStateHelper.bookState(fromString: DownloadSuccessfulKey)?.intValue + ) + XCTAssertEqual(TPPBookState.holding.rawValue, TPPBookStateHelper.bookState(fromString: HoldingKey)?.intValue) + XCTAssertEqual(TPPBookState.used.rawValue, TPPBookStateHelper.bookState(fromString: UsedKey)?.intValue) + XCTAssertEqual( + TPPBookState.unsupported.rawValue, + TPPBookStateHelper.bookState(fromString: UnsupportedKey)?.intValue + ) + XCTAssertNil(TPPBookStateHelper.bookState(fromString: "InvalidString")) + } + + func testAllBookState() { + XCTAssertEqual(TPPBookStateHelper.allBookStates(), TPPBookState.allCases.map(\.rawValue)) + } } diff --git a/PalaceTests/TPPCachingTests.swift b/PalaceTests/TPPCachingTests.swift index 1af3ea97a..667750c13 100644 --- a/PalaceTests/TPPCachingTests.swift +++ b/PalaceTests/TPPCachingTests.swift @@ -20,8 +20,9 @@ class TPPCachingTests: XCTestCase { statusCode: 200, httpVersion: "HTTP/1.1", headerFields: [ - "Cache-Control" : "public, no-transform, max-age: 43200, s-maxage: 21600" - ])! + "Cache-Control": "public, no-transform, max-age: 43200, s-maxage: 21600" + ] + )! let expiresDate = Date().addingTimeInterval(43200) sufficientHeadersResponse = HTTPURLResponse( @@ -29,17 +30,19 @@ class TPPCachingTests: XCTestCase { statusCode: 200, httpVersion: "HTTP/1.1", headerFields: [ - "cache-control" : "public, no-transform, max-age: 43200, s-maxage: 21600", + "cache-control": "public, no-transform, max-age: 43200, s-maxage: 21600", "Expires": expiresDate.rfc1123String - ])! + ] + )! missingMaxAgeResponse = HTTPURLResponse( url: URL(string: "https://example.com/test")!, statusCode: 200, httpVersion: "HTTP/1.1", headerFields: [ - "CACHE-CONTROL" : "public; s-max-age=666", - ]) + "CACHE-CONTROL": "public; s-max-age=666" + ] + ) } override func tearDown() { @@ -58,9 +61,10 @@ class TPPCachingTests: XCTestCase { statusCode: 200, httpVersion: "HTTP/1.1", headerFields: [ - "EXPIRES" : Date().rfc1123String, + "EXPIRES": Date().rfc1123String, "etag": "23bad3" - ])! + ] + )! XCTAssertTrue(sufficientHeadersResponse2.hasSufficientCachingHeaders) let sufficientHeadersResponse3 = HTTPURLResponse( @@ -68,9 +72,10 @@ class TPPCachingTests: XCTestCase { statusCode: 200, httpVersion: "HTTP/1.1", headerFields: [ - "Last-Modified" : Date().rfc1123String, + "Last-Modified": Date().rfc1123String, "etag": "23bad3" - ])! + ] + )! XCTAssertTrue(sufficientHeadersResponse3.hasSufficientCachingHeaders) let insufficientHeadersResponse = HTTPURLResponse( @@ -78,8 +83,9 @@ class TPPCachingTests: XCTestCase { statusCode: 200, httpVersion: "HTTP/1.1", headerFields: [ - "Expires" : Date().rfc1123String, - ])! + "Expires": Date().rfc1123String + ] + )! XCTAssertFalse(insufficientHeadersResponse.hasSufficientCachingHeaders) } @@ -92,8 +98,9 @@ class TPPCachingTests: XCTestCase { statusCode: 200, httpVersion: "HTTP/1.1", headerFields: [ - "CACHE-CONTROL" : " mAx-Age=666", - ]) + "CACHE-CONTROL": " mAx-Age=666" + ] + ) XCTAssertEqual(differentCapitalizationResponse?.cacheControlMaxAge, 666) let malformedResponse = HTTPURLResponse( @@ -101,8 +108,9 @@ class TPPCachingTests: XCTestCase { statusCode: 200, httpVersion: "HTTP/1.1", headerFields: [ - "cache-control" : " max-age=", - ]) + "cache-control": " max-age=" + ] + ) XCTAssertNil(malformedResponse?.cacheControlMaxAge) let malformedNumberResponse = HTTPURLResponse( @@ -110,8 +118,9 @@ class TPPCachingTests: XCTestCase { statusCode: 200, httpVersion: "HTTP/1.1", headerFields: [ - "Cache-Control" : " max-age=x1,2", - ]) + "Cache-Control": " max-age=x1,2" + ] + ) XCTAssertNil(malformedNumberResponse?.cacheControlMaxAge) } @@ -126,7 +135,8 @@ class TPPCachingTests: XCTestCase { url: URL(string: "https://example.com/test")!, statusCode: 200, httpVersion: "HTTP/1.1", - headerFields: nil)! + headerFields: nil + )! XCTAssertFalse(noCachingResp.hasSufficientCachingHeaders) XCTAssertTrue(noCachingResp.modifyingCacheHeaders().hasSufficientCachingHeaders) @@ -134,7 +144,8 @@ class TPPCachingTests: XCTestCase { url: URL(string: "https://example.com/test")!, statusCode: 400, httpVersion: "HTTP/1.1", - headerFields: nil)! + headerFields: nil + )! XCTAssertFalse(failedResp.hasSufficientCachingHeaders) XCTAssertFalse(failedResp.modifyingCacheHeaders().hasSufficientCachingHeaders) } diff --git a/PalaceTests/TPPFake.swift b/PalaceTests/TPPFake.swift index afd74ac77..1fab6cc6c 100644 --- a/PalaceTests/TPPFake.swift +++ b/PalaceTests/TPPFake.swift @@ -19,22 +19,22 @@ class TPPFake { availability: TPPOPDSAcquisitionAvailabilityUnlimited() ) } - + class var genericAudiobookAcquisition: TPPOPDSAcquisition { TPPOPDSAcquisition( relation: .generic, type: "application/audiobook+json", - hrefURL: URL(string:"https://market.feedbooks.com/item/3877422/preview")!, + hrefURL: URL(string: "https://market.feedbooks.com/item/3877422/preview")!, indirectAcquisitions: [TPPOPDSIndirectAcquisition](), availability: TPPOPDSAcquisitionAvailabilityUnlimited() ) } - + class var genericSample: TPPOPDSAcquisition { TPPOPDSAcquisition( relation: .sample, type: "application/epub+zip", - hrefURL: URL(string:"https://market.feedbooks.com/item/3877422/preview")!, + hrefURL: URL(string: "https://market.feedbooks.com/item/3877422/preview")!, indirectAcquisitions: [], availability: TPPOPDSAcquisitionAvailabilityUnlimited() ) @@ -44,47 +44,47 @@ class TPPFake { TPPOPDSAcquisition( relation: .sample, type: "application/audiobook+json", - hrefURL: URL(string:"https://market.feedbooks.com/item/3877422/preview")!, + hrefURL: URL(string: "https://market.feedbooks.com/item/3877422/preview")!, indirectAcquisitions: [], availability: TPPOPDSAcquisitionAvailabilityUnlimited() ) } - + class var overdriveWebAudiobookSample: TPPOPDSAcquisition { TPPOPDSAcquisition( relation: .preview, type: "text/html", - hrefURL: URL(string:"https://market.feedbooks.com/item/3877422/preview")!, + hrefURL: URL(string: "https://market.feedbooks.com/item/3877422/preview")!, indirectAcquisitions: [], availability: TPPOPDSAcquisitionAvailabilityUnlimited() ) } - + class var overdriveAudiobookWaveFile: TPPOPDSAcquisition { TPPOPDSAcquisition( relation: .sample, type: "audio/x-ms-wma", - hrefURL: URL(string:"https://market.feedbooks.com/item/3877422/preview")!, + hrefURL: URL(string: "https://market.feedbooks.com/item/3877422/preview")!, indirectAcquisitions: [], availability: TPPOPDSAcquisitionAvailabilityUnlimited() ) } - + class var overdriveAudiobookMPEG: TPPOPDSAcquisition { TPPOPDSAcquisition( relation: .sample, type: "audio/mpeg", - hrefURL: URL(string:"https://market.feedbooks.com/item/3877422/preview")!, + hrefURL: URL(string: "https://market.feedbooks.com/item/3877422/preview")!, indirectAcquisitions: [], availability: TPPOPDSAcquisitionAvailabilityUnlimited() ) } - + class var genericPreview: TPPOPDSAcquisition { TPPOPDSAcquisition( relation: .preview, type: "application/epub+zip", - hrefURL: URL(string:"https://market.feedbooks.com/item/3877422/preview")!, + hrefURL: URL(string: "https://market.feedbooks.com/item/3877422/preview")!, indirectAcquisitions: [], availability: TPPOPDSAcquisitionAvailabilityUnlimited() ) @@ -92,8 +92,10 @@ class TPPFake { class var opdsEntry: TPPOPDSEntry { let bundle = Bundle(for: TPPFake.self) - let url = bundle.url(forResource: "NYPLOPDSAcquisitionPathEntry", - withExtension: "xml")! + let url = bundle.url( + forResource: "NYPLOPDSAcquisitionPathEntry", + withExtension: "xml" + )! let xml = try! TPPXML(data: Data(contentsOf: url)) let entry = TPPOPDSEntry(xml: xml) return entry! @@ -101,8 +103,10 @@ class TPPFake { class var opdsEntryMinimal: TPPOPDSEntry { let bundle = Bundle(for: TPPFake.self) - let url = bundle.url(forResource: "NYPLOPDSAcquisitionPathEntryMinimal", - withExtension: "xml")! + let url = bundle.url( + forResource: "NYPLOPDSAcquisitionPathEntryMinimal", + withExtension: "xml" + )! return try! TPPOPDSEntry(xml: TPPXML(data: Data(contentsOf: url))) } @@ -128,5 +132,4 @@ class TPPFake { } } """ - } diff --git a/PalaceTests/TPPJWKConversionTest.swift b/PalaceTests/TPPJWKConversionTest.swift index 74eb2bc95..afd73efac 100644 --- a/PalaceTests/TPPJWKConversionTest.swift +++ b/PalaceTests/TPPJWKConversionTest.swift @@ -10,7 +10,6 @@ import XCTest @testable import Palace class TPPJWKConversionTest: XCTestCase { - var jwkResponseData: Data! var expectedPublicKeyData: Data! @@ -41,5 +40,4 @@ class TPPJWKConversionTest: XCTestCase { XCTAssertNotNil(publicKeyData) XCTAssertEqual(publicKeyData!, expectedPublicKeyData) } - } diff --git a/PalaceTests/TPPOPDSAcquisitionPathTests.swift b/PalaceTests/TPPOPDSAcquisitionPathTests.swift index 4a87558e5..4aacc731d 100644 --- a/PalaceTests/TPPOPDSAcquisitionPathTests.swift +++ b/PalaceTests/TPPOPDSAcquisitionPathTests.swift @@ -3,21 +3,24 @@ import XCTest @testable import Palace class TPPOPDSAcquisitionPathTests: XCTestCase { - let acquisitions: [TPPOPDSAcquisition] = try! TPPOPDSEntry(xml: TPPXML(data: - Data.init(contentsOf: - Bundle.init(for: TPPOPDSAcquisitionPathTests.self) - .url(forResource: "NYPLOPDSAcquisitionPathEntry", withExtension: "xml")!))) - .acquisitions; + Data(contentsOf: + Bundle(for: TPPOPDSAcquisitionPathTests.self) + .url(forResource: "NYPLOPDSAcquisitionPathEntry", withExtension: "xml")! + ) + ) + ) + .acquisitions func testSimplifiedAdeptEpubAcquisition() { - let acquisitionPaths: Array = + let acquisitionPaths: [TPPOPDSAcquisitionPath] = TPPOPDSAcquisitionPath.supportedAcquisitionPaths( forAllowedTypes: TPPOPDSAcquisitionPath.supportedTypes(), allowedRelations: [.borrow, .openAccess], - acquisitions: acquisitions) + acquisitions: acquisitions + ) XCTAssert(acquisitionPaths.count == 2) @@ -27,24 +30,26 @@ class TPPOPDSAcquisitionPathTests: XCTestCase { "application/vnd.adobe.adept+xml", "application/epub+zip" ]) - + XCTAssert(acquisitionPaths[1].relation == TPPOPDSAcquisitionRelation.borrow) XCTAssert(acquisitionPaths[1].types == [ "application/atom+xml;type=entry;profile=opds-catalog", "application/pdf" - ]) + ]) } - + func testSampleLinkInAcquisitions() { // TPPOPDSAcquisitionPathEntryWithSampleLink.xml contains a sample link let bundle = Bundle(for: TPPOPDSAcquisitionPathTests.self) - let acquisitionWithSampleData = try! Data(contentsOf: bundle.url(forResource: "TPPOPDSAcquisitionPathEntryWithSampleLink", withExtension: "xml")!) + let acquisitionWithSampleData = try! Data(contentsOf: bundle.url( + forResource: "TPPOPDSAcquisitionPathEntryWithSampleLink", + withExtension: "xml" + )!) let entryWithSample = TPPOPDSEntry(xml: TPPXML(data: acquisitionWithSampleData))! let bookWithSample = TPPBook(entry: entryWithSample) XCTAssertNotNil(bookWithSample) XCTAssert(bookWithSample?.defaultAcquisition?.relation != TPPOPDSAcquisitionRelation.sample) XCTAssertNotNil(bookWithSample?.sampleAcquisition) XCTAssert(bookWithSample?.sampleAcquisition?.relation == TPPOPDSAcquisitionRelation.sample) - } } diff --git a/PalaceTests/TPPOpenSearchDescriptionTests.swift b/PalaceTests/TPPOpenSearchDescriptionTests.swift index ed7921899..076e849c6 100644 --- a/PalaceTests/TPPOpenSearchDescriptionTests.swift +++ b/PalaceTests/TPPOpenSearchDescriptionTests.swift @@ -7,7 +7,7 @@ // import XCTest -//@testable import Palace +// @testable import Palace class TPPOpenSearchDescriptionTests: XCTestCase { var searchDescr: TPPOpenSearchDescription! @@ -25,6 +25,11 @@ class TPPOpenSearchDescriptionTests: XCTestCase { func testOPDSURLSearch() { let searchURL = searchDescr.opdsurl(forSearching: "Arnold Schönberg & +etc") XCTAssertNotNil(searchURL) - XCTAssertEqual(searchURL, URL(string: "https://circulation.librarysimplified.org/NYNYPL/search/?entrypoint=All&q=Arnold%20Sch%C3%B6nberg%20%26%20%2Betc")!) + XCTAssertEqual( + searchURL, + URL( + string: "https://circulation.librarysimplified.org/NYNYPL/search/?entrypoint=All&q=Arnold%20Sch%C3%B6nberg%20%26%20%2Betc" + )! + ) } } diff --git a/PalaceTests/TPPReaderBookmarksBusinessLogicTests.swift b/PalaceTests/TPPReaderBookmarksBusinessLogicTests.swift index 760df9e59..9d58f9bba 100644 --- a/PalaceTests/TPPReaderBookmarksBusinessLogicTests.swift +++ b/PalaceTests/TPPReaderBookmarksBusinessLogicTests.swift @@ -6,216 +6,256 @@ // Copyright © 2020 NYPL Labs. All rights reserved. // -import XCTest import ReadiumShared +import XCTest @testable import Palace class TPPReaderBookmarksBusinessLogicTests: XCTestCase { - var bookmarkBusinessLogic: TPPReaderBookmarksBusinessLogic! - var bookRegistryMock: TPPBookRegistryMock! - var libraryAccountMock: TPPLibraryAccountMock! - var bookmarkCounter: Int = 0 - let bookIdentifier = "fakeEpub" - - override func setUpWithError() throws { - try super.setUpWithError() - - let emptyUrl = URL.init(fileURLWithPath: "") - let fakeAcquisition = TPPOPDSAcquisition.init( - relation: .generic, - type: "application/epub+zip", - hrefURL: emptyUrl, - indirectAcquisitions: [TPPOPDSIndirectAcquisition](), - availability: TPPOPDSAcquisitionAvailabilityUnlimited.init() - ) - - let fakeBook = TPPBook.init( - acquisitions: [fakeAcquisition], - authors: [TPPBookAuthor](), - categoryStrings: [String](), - distributor: "", - identifier: bookIdentifier, - imageURL: emptyUrl, - imageThumbnailURL: emptyUrl, - published: Date.init(), - publisher: "", - subtitle: "", - summary: "", - title: "", - updated: Date.init(), - annotationsURL: emptyUrl, - analyticsURL: emptyUrl, - alternateURL: emptyUrl, - relatedWorksURL: emptyUrl, - previewLink: fakeAcquisition, - seriesURL: emptyUrl, - revokeURL: emptyUrl, - reportURL: emptyUrl, - timeTrackingURL: emptyUrl, - contributors: [:], - bookDuration: nil, - imageCache: MockImageCache() - ) - - bookRegistryMock = TPPBookRegistryMock() - bookRegistryMock.addBook(fakeBook, location: nil, state: .downloadSuccessful, fulfillmentId: nil, readiumBookmarks: nil, genericBookmarks: nil) - libraryAccountMock = TPPLibraryAccountMock() - let manifest = Manifest(metadata: Metadata(title: "fakeMetadata")) - let pub = Publication(manifest: manifest) - bookmarkBusinessLogic = TPPReaderBookmarksBusinessLogic( - book: fakeBook, - r2Publication: pub, - drmDeviceID: "fakeDeviceID", - bookRegistryProvider: bookRegistryMock, - currentLibraryAccountProvider: libraryAccountMock) - bookmarkCounter = 0 - } + var bookmarkBusinessLogic: TPPReaderBookmarksBusinessLogic! + var bookRegistryMock: TPPBookRegistryMock! + var libraryAccountMock: TPPLibraryAccountMock! + var bookmarkCounter: Int = 0 + let bookIdentifier = "fakeEpub" - override func tearDownWithError() throws { - try super.tearDownWithError() - bookmarkBusinessLogic = nil - libraryAccountMock = nil - bookRegistryMock?.registry = [:] - bookRegistryMock = nil - bookmarkCounter = 0 - } + override func setUpWithError() throws { + try super.setUpWithError() + + let emptyUrl = URL(fileURLWithPath: "") + let fakeAcquisition = TPPOPDSAcquisition( + relation: .generic, + type: "application/epub+zip", + hrefURL: emptyUrl, + indirectAcquisitions: [TPPOPDSIndirectAcquisition](), + availability: TPPOPDSAcquisitionAvailabilityUnlimited() + ) + + let fakeBook = TPPBook( + acquisitions: [fakeAcquisition], + authors: [TPPBookAuthor](), + categoryStrings: [String](), + distributor: "", + identifier: bookIdentifier, + imageURL: emptyUrl, + imageThumbnailURL: emptyUrl, + published: Date(), + publisher: "", + subtitle: "", + summary: "", + title: "", + updated: Date(), + annotationsURL: emptyUrl, + analyticsURL: emptyUrl, + alternateURL: emptyUrl, + relatedWorksURL: emptyUrl, + previewLink: fakeAcquisition, + seriesURL: emptyUrl, + revokeURL: emptyUrl, + reportURL: emptyUrl, + timeTrackingURL: emptyUrl, + contributors: [:], + bookDuration: nil, + imageCache: MockImageCache() + ) + + bookRegistryMock = TPPBookRegistryMock() + bookRegistryMock.addBook( + fakeBook, + location: nil, + state: .downloadSuccessful, + fulfillmentId: nil, + readiumBookmarks: nil, + genericBookmarks: nil + ) + libraryAccountMock = TPPLibraryAccountMock() + let manifest = Manifest(metadata: Metadata(title: "fakeMetadata")) + let pub = Publication(manifest: manifest) + bookmarkBusinessLogic = TPPReaderBookmarksBusinessLogic( + book: fakeBook, + r2Publication: pub, + drmDeviceID: "fakeDeviceID", + bookRegistryProvider: bookRegistryMock, + currentLibraryAccountProvider: libraryAccountMock + ) + bookmarkCounter = 0 + } + + override func tearDownWithError() throws { + try super.tearDownWithError() + bookmarkBusinessLogic = nil + libraryAccountMock = nil + bookRegistryMock?.registry = [:] + bookRegistryMock = nil + bookmarkCounter = 0 + } - // MARK: - Test updateLocalBookmarks - - func testUpdateLocalBookmarksWithNoLocalBookmarks() throws { - var serverBookmarks = [TPPReadiumBookmark]() - - // Make sure BookRegistry contains no bookmark - XCTAssertEqual(bookRegistryMock.readiumBookmarks(forIdentifier: bookIdentifier).count, 0) - - guard let firstBookmark = newBookmark(href: "Intro", - chapter: "1", - progressWithinChapter: 0.1, - progressWithinBook: 0.1) else { - XCTFail("Failed to create new bookmark") - return - } - serverBookmarks.append(firstBookmark) - - bookmarkBusinessLogic.updateLocalBookmarks(serverBookmarks: serverBookmarks, - localBookmarks: bookRegistryMock.readiumBookmarks(forIdentifier: bookIdentifier), - bookmarksFailedToUpload: [TPPReadiumBookmark]()) { - XCTAssertEqual(self.bookRegistryMock.readiumBookmarks(forIdentifier: self.bookIdentifier).count, 1) - } + // MARK: - Test updateLocalBookmarks + + func testUpdateLocalBookmarksWithNoLocalBookmarks() throws { + var serverBookmarks = [TPPReadiumBookmark]() + + // Make sure BookRegistry contains no bookmark + XCTAssertEqual(bookRegistryMock.readiumBookmarks(forIdentifier: bookIdentifier).count, 0) + + guard let firstBookmark = newBookmark( + href: "Intro", + chapter: "1", + progressWithinChapter: 0.1, + progressWithinBook: 0.1 + ) else { + XCTFail("Failed to create new bookmark") + return } - - func testUpdateLocalBookmarksWithDuplicatedLocalBookmarks() throws { - var serverBookmarks = [TPPReadiumBookmark]() - - // Make sure BookRegistry contains no bookmark - XCTAssertEqual(bookRegistryMock.readiumBookmarks(forIdentifier: bookIdentifier).count, 0) - - guard let firstBookmark = newBookmark(href: "Intro", - chapter: "1", - progressWithinChapter: 0.1, - progressWithinBook: 0.1), - let secondBookmark = newBookmark(href: "Intro", - chapter: "1", - progressWithinChapter: 0.2, - progressWithinBook: 0.1) else { - XCTFail("Failed to create new bookmark") - return - } - - serverBookmarks.append(firstBookmark) - serverBookmarks.append(secondBookmark) - bookRegistryMock.add(firstBookmark, forIdentifier: bookIdentifier) + serverBookmarks.append(firstBookmark) + + bookmarkBusinessLogic.updateLocalBookmarks( + serverBookmarks: serverBookmarks, + localBookmarks: bookRegistryMock + .readiumBookmarks(forIdentifier: bookIdentifier), + bookmarksFailedToUpload: [TPPReadiumBookmark]() + ) { XCTAssertEqual(self.bookRegistryMock.readiumBookmarks(forIdentifier: self.bookIdentifier).count, 1) + } + } - // There are one duplicated bookmark and one non-synced (server) bookmark - bookmarkBusinessLogic.updateLocalBookmarks(serverBookmarks: serverBookmarks, - localBookmarks: bookRegistryMock.readiumBookmarks(forIdentifier: self.bookIdentifier), - bookmarksFailedToUpload: [TPPReadiumBookmark]()) { - XCTAssertEqual(self.bookRegistryMock.readiumBookmarks(forIdentifier: self.bookIdentifier).count, 2) - } + func testUpdateLocalBookmarksWithDuplicatedLocalBookmarks() throws { + var serverBookmarks = [TPPReadiumBookmark]() + + // Make sure BookRegistry contains no bookmark + XCTAssertEqual(bookRegistryMock.readiumBookmarks(forIdentifier: bookIdentifier).count, 0) + + guard let firstBookmark = newBookmark( + href: "Intro", + chapter: "1", + progressWithinChapter: 0.1, + progressWithinBook: 0.1 + ), + let secondBookmark = newBookmark( + href: "Intro", + chapter: "1", + progressWithinChapter: 0.2, + progressWithinBook: 0.1 + ) + else { + XCTFail("Failed to create new bookmark") + return } - - func testUpdateLocalBookmarksWithExtraLocalBookmarks() throws { - var serverBookmarks = [TPPReadiumBookmark]() - - // Make sure BookRegistry contains no bookmark - XCTAssertEqual(bookRegistryMock.readiumBookmarks(forIdentifier: bookIdentifier).count, 0) - - guard let firstBookmark = newBookmark(href: "Intro", - chapter: "1", - progressWithinChapter: 0.1, - progressWithinBook: 0.1), - let secondBookmark = newBookmark(href: "Intro", - chapter: "1", - progressWithinChapter: 0.2, - progressWithinBook: 0.1) else { - XCTFail("Failed to create new bookmark") - return - } - - serverBookmarks.append(firstBookmark) - bookRegistryMock.add(firstBookmark, forIdentifier: bookIdentifier) - bookRegistryMock.add(secondBookmark, forIdentifier: bookIdentifier) + + serverBookmarks.append(firstBookmark) + serverBookmarks.append(secondBookmark) + bookRegistryMock.add(firstBookmark, forIdentifier: bookIdentifier) + XCTAssertEqual(bookRegistryMock.readiumBookmarks(forIdentifier: bookIdentifier).count, 1) + + // There are one duplicated bookmark and one non-synced (server) bookmark + bookmarkBusinessLogic.updateLocalBookmarks( + serverBookmarks: serverBookmarks, + localBookmarks: bookRegistryMock + .readiumBookmarks(forIdentifier: bookIdentifier), + bookmarksFailedToUpload: [TPPReadiumBookmark]() + ) { XCTAssertEqual(self.bookRegistryMock.readiumBookmarks(forIdentifier: self.bookIdentifier).count, 2) + } + } + + func testUpdateLocalBookmarksWithExtraLocalBookmarks() throws { + var serverBookmarks = [TPPReadiumBookmark]() - // There are one duplicated bookmark and one (local) bookmark, there should be 2 bookmarks - bookmarkBusinessLogic.updateLocalBookmarks(serverBookmarks: serverBookmarks, - localBookmarks: bookRegistryMock.readiumBookmarks(forIdentifier: self.bookIdentifier), - bookmarksFailedToUpload: [TPPReadiumBookmark]()) { - XCTAssertEqual(self.bookRegistryMock.readiumBookmarks(forIdentifier: self.bookIdentifier).count, 2) - } + // Make sure BookRegistry contains no bookmark + XCTAssertEqual(bookRegistryMock.readiumBookmarks(forIdentifier: bookIdentifier).count, 0) + + guard let firstBookmark = newBookmark( + href: "Intro", + chapter: "1", + progressWithinChapter: 0.1, + progressWithinBook: 0.1 + ), + let secondBookmark = newBookmark( + href: "Intro", + chapter: "1", + progressWithinChapter: 0.2, + progressWithinBook: 0.1 + ) + else { + XCTFail("Failed to create new bookmark") + return } - - func testUpdateLocalBookmarksWithFailedUploadBookmarks() throws { - var serverBookmarks = [TPPReadiumBookmark]() - - // Make sure BookRegistry contains no bookmark - XCTAssertEqual(bookRegistryMock.readiumBookmarks(forIdentifier: bookIdentifier).count, 0) - - guard let firstBookmark = newBookmark(href: "Intro", - chapter: "1", - progressWithinChapter: 0.1, - progressWithinBook: 0.1), - let secondBookmark = newBookmark(href: "Intro", - chapter: "1", - progressWithinChapter: 0.2, - progressWithinBook: 0.1) else { - XCTFail("Failed to create new bookmark") - return - } - - serverBookmarks.append(firstBookmark) - bookRegistryMock.add(firstBookmark, forIdentifier: bookIdentifier) - XCTAssertEqual(self.bookRegistryMock.readiumBookmarks(forIdentifier: self.bookIdentifier).count, 1) - - // There are one duplicated bookmark and one failed-to-upload bookmark - bookmarkBusinessLogic.updateLocalBookmarks(serverBookmarks: serverBookmarks, - localBookmarks: bookRegistryMock.readiumBookmarks(forIdentifier: self.bookIdentifier), - bookmarksFailedToUpload: [secondBookmark]) { - XCTAssertEqual(self.bookRegistryMock.readiumBookmarks(forIdentifier: self.bookIdentifier).count, 2) - } + + serverBookmarks.append(firstBookmark) + bookRegistryMock.add(firstBookmark, forIdentifier: bookIdentifier) + bookRegistryMock.add(secondBookmark, forIdentifier: bookIdentifier) + XCTAssertEqual(bookRegistryMock.readiumBookmarks(forIdentifier: bookIdentifier).count, 2) + + // There are one duplicated bookmark and one (local) bookmark, there should be 2 bookmarks + bookmarkBusinessLogic.updateLocalBookmarks( + serverBookmarks: serverBookmarks, + localBookmarks: bookRegistryMock + .readiumBookmarks(forIdentifier: bookIdentifier), + bookmarksFailedToUpload: [TPPReadiumBookmark]() + ) { + XCTAssertEqual(self.bookRegistryMock.readiumBookmarks(forIdentifier: self.bookIdentifier).count, 2) } + } - // MARK: Helper - - func newBookmark(href: String, - chapter: String, - progressWithinChapter: Float, - progressWithinBook: Float, - device: String? = nil) -> TPPReadiumBookmark? { - // Annotation id needs to be unique - bookmarkCounter += 1 - return TPPReadiumBookmark(annotationId: "fakeAnnotationID\(bookmarkCounter)", - href: href, - chapter: chapter, - page: nil, - location: nil, - progressWithinChapter: progressWithinChapter, - progressWithinBook: progressWithinBook, - readingOrderItem: nil, - readingOrderItemOffsetMilliseconds: 0, - time:nil, - device:device) - + func testUpdateLocalBookmarksWithFailedUploadBookmarks() throws { + var serverBookmarks = [TPPReadiumBookmark]() + + // Make sure BookRegistry contains no bookmark + XCTAssertEqual(bookRegistryMock.readiumBookmarks(forIdentifier: bookIdentifier).count, 0) + + guard let firstBookmark = newBookmark( + href: "Intro", + chapter: "1", + progressWithinChapter: 0.1, + progressWithinBook: 0.1 + ), + let secondBookmark = newBookmark( + href: "Intro", + chapter: "1", + progressWithinChapter: 0.2, + progressWithinBook: 0.1 + ) + else { + XCTFail("Failed to create new bookmark") + return } + + serverBookmarks.append(firstBookmark) + bookRegistryMock.add(firstBookmark, forIdentifier: bookIdentifier) + XCTAssertEqual(bookRegistryMock.readiumBookmarks(forIdentifier: bookIdentifier).count, 1) + + // There are one duplicated bookmark and one failed-to-upload bookmark + bookmarkBusinessLogic.updateLocalBookmarks( + serverBookmarks: serverBookmarks, + localBookmarks: bookRegistryMock + .readiumBookmarks(forIdentifier: bookIdentifier), + bookmarksFailedToUpload: [secondBookmark] + ) { + XCTAssertEqual(self.bookRegistryMock.readiumBookmarks(forIdentifier: self.bookIdentifier).count, 2) + } + } + + // MARK: Helper + + func newBookmark( + href: String, + chapter: String, + progressWithinChapter: Float, + progressWithinBook: Float, + device: String? = nil + ) -> TPPReadiumBookmark? { + // Annotation id needs to be unique + bookmarkCounter += 1 + return TPPReadiumBookmark( + annotationId: "fakeAnnotationID\(bookmarkCounter)", + href: href, + chapter: chapter, + page: nil, + location: nil, + progressWithinChapter: progressWithinChapter, + progressWithinBook: progressWithinBook, + readingOrderItem: nil, + readingOrderItemOffsetMilliseconds: 0, + time: nil, + device: device + ) + } } diff --git a/PalaceTests/TPPReauthenticatorMock.swift b/PalaceTests/TPPReauthenticatorMock.swift index afe749ccd..e360ef878 100644 --- a/PalaceTests/TPPReauthenticatorMock.swift +++ b/PalaceTests/TPPReauthenticatorMock.swift @@ -9,12 +9,16 @@ import Foundation @testable import Palace +// MARK: - TPPReauthenticatorMock + @objc class TPPReauthenticatorMock: NSObject, Reauthenticator { var reauthenticationPerformed: Bool = false - @objc func authenticateIfNeeded(_ user: TPPUserAccount, - usingExistingCredentials: Bool, - authenticationCompletion: (()-> Void)?) { + @objc func authenticateIfNeeded( + _ user: TPPUserAccount, + usingExistingCredentials _: Bool, + authenticationCompletion: (() -> Void)? + ) { reauthenticationPerformed = true user.credentials = TPPCredentials(authToken: "Token", barcode: "barcode", pin: "pin") authenticationCompletion?() @@ -22,7 +26,7 @@ import Foundation } extension TPPCredentials { - init?(authToken: String? = nil, barcode: String? = nil, pin: String? = nil, expirationDate: Date? = nil) { + init?(authToken: String? = nil, barcode: String? = nil, pin: String? = nil, expirationDate _: Date? = nil) { if let authToken = authToken { self = .token(authToken: authToken, barcode: barcode, pin: pin) } else if let barcode = barcode, let pin = pin { diff --git a/PalaceTests/TPPSignInBusinessLogicTests.swift b/PalaceTests/TPPSignInBusinessLogicTests.swift index 7f4fb7e73..cb9ba1322 100644 --- a/PalaceTests/TPPSignInBusinessLogicTests.swift +++ b/PalaceTests/TPPSignInBusinessLogicTests.swift @@ -29,7 +29,8 @@ class TPPSignInBusinessLogicTests: XCTestCase { userAccountProvider: TPPUserAccountMock.self, networkExecutor: TPPRequestExecutorMock(), uiDelegate: uiDelegate, - drmAuthorizer: drmAuthorizer) + drmAuthorizer: drmAuthorizer + ) } override func tearDownWithError() throws { @@ -49,13 +50,15 @@ class TPPSignInBusinessLogicTests: XCTestCase { XCTAssertNotEqual(user.PIN, "newPIN") // test - businessLogic.updateUserAccount(forDRMAuthorization: true, - withBarcode: "newBarcode", - pin: "newPIN", - authToken: nil, - expirationDate: nil, - patron: nil, - cookies: nil) + businessLogic.updateUserAccount( + forDRMAuthorization: true, + withBarcode: "newBarcode", + pin: "newPIN", + authToken: nil, + expirationDate: nil, + patron: nil, + cookies: nil + ) // verification XCTAssertEqual(user.barcode, "newBarcode") @@ -70,13 +73,15 @@ class TPPSignInBusinessLogicTests: XCTestCase { businessLogic.selectedAuthentication = libraryAccountMock.barcodeAuthentication // test - businessLogic.updateUserAccount(forDRMAuthorization: true, - withBarcode: "newBarcode", - pin: "newPIN", - authToken: nil, - expirationDate: nil, - patron: nil, - cookies: nil) + businessLogic.updateUserAccount( + forDRMAuthorization: true, + withBarcode: "newBarcode", + pin: "newPIN", + authToken: nil, + expirationDate: nil, + patron: nil, + cookies: nil + ) // verification XCTAssertEqual(user.barcode, "newBarcode") @@ -93,13 +98,15 @@ class TPPSignInBusinessLogicTests: XCTestCase { let patron = ["name": "ciccio"] // test - businessLogic.updateUserAccount(forDRMAuthorization: true, - withBarcode: nil, - pin: nil, - authToken: "some-great-token", - expirationDate: nil, - patron: patron, - cookies: nil) + businessLogic.updateUserAccount( + forDRMAuthorization: true, + withBarcode: nil, + pin: nil, + authToken: "some-great-token", + expirationDate: nil, + patron: patron, + cookies: nil + ) // verification XCTAssertEqual(user.authToken, "some-great-token") @@ -123,13 +130,15 @@ class TPPSignInBusinessLogicTests: XCTestCase { ])!] // test - businessLogic.updateUserAccount(forDRMAuthorization: true, - withBarcode: nil, - pin: nil, - authToken: "some-great-token", - expirationDate: nil, - patron: patron, - cookies: cookies) + businessLogic.updateUserAccount( + forDRMAuthorization: true, + withBarcode: nil, + pin: nil, + authToken: "some-great-token", + expirationDate: nil, + patron: patron, + cookies: cookies + ) // verification XCTAssertEqual(user.authToken, "some-great-token") diff --git a/PalaceTests/UIColor+NYPLAdditionsTests.swift b/PalaceTests/UIColor+NYPLAdditionsTests.swift index ab2084462..d26dfad14 100644 --- a/PalaceTests/UIColor+NYPLAdditionsTests.swift +++ b/PalaceTests/UIColor+NYPLAdditionsTests.swift @@ -15,5 +15,4 @@ class UIColor_NYPLAdditionsTests: XCTestCase { XCTAssertEqual(color.javascriptHexString(), "#A63BCC") } - } diff --git a/PalaceTests/URLSession+Stubbing.swift b/PalaceTests/URLSession+Stubbing.swift index 31f83cd16..01823c14b 100644 --- a/PalaceTests/URLSession+Stubbing.swift +++ b/PalaceTests/URLSession+Stubbing.swift @@ -1,12 +1,9 @@ import Foundation extension URLSession { - static func stubbedSession() -> URLSession { - let config = URLSessionConfiguration.ephemeral - config.protocolClasses = [HTTPStubURLProtocol.self] - return URLSession(configuration: config) - } + static func stubbedSession() -> URLSession { + let config = URLSessionConfiguration.ephemeral + config.protocolClasses = [HTTPStubURLProtocol.self] + return URLSession(configuration: config) + } } - - - diff --git a/PalaceTests/UserProfileDocumentTests.swift b/PalaceTests/UserProfileDocumentTests.swift index 0a53929ab..405afcd1b 100644 --- a/PalaceTests/UserProfileDocumentTests.swift +++ b/PalaceTests/UserProfileDocumentTests.swift @@ -6,7 +6,7 @@ class UserProfileDocumentTests: XCTestCase { let validJson = TPPFake.validUserProfileJson let dataCorruptedJson = "lll" - + let extraPropertyJson = """ { "simplified:authorization_identifier": "23333999999915", @@ -33,7 +33,7 @@ class UserProfileDocumentTests: XCTestCase { "extra_property": false } """ - + let keyNotFoundJson = """ { "simplified:authorization_identifier": "23333999999915", @@ -55,29 +55,29 @@ class UserProfileDocumentTests: XCTestCase { } } """ - + let mismatchTypeJson = """ - { - "simplified:authorization_identifier": "23333999999915", - "drm": [ - { - "drm:vendor": "NYPL", - "drm:scheme": "http://librarysimplified.org/terms/drm/scheme/ACS", - "drm:clientToken": true - } - ], - "links": [ - { - "href": "https://circulation.librarysimplified.org/NYNYPL/AdobeAuth/devices", - "rel": 123 - } - ], - "simplified:authorization_expires": "2025-05-01T00:00:00Z", - "settings": { - "simplified:synchronize_annotations": "true" + { + "simplified:authorization_identifier": "23333999999915", + "drm": [ + { + "drm:vendor": "NYPL", + "drm:scheme": "http://librarysimplified.org/terms/drm/scheme/ACS", + "drm:clientToken": true } + ], + "links": [ + { + "href": "https://circulation.librarysimplified.org/NYNYPL/AdobeAuth/devices", + "rel": 123 + } + ], + "simplified:authorization_expires": "2025-05-01T00:00:00Z", + "settings": { + "simplified:synchronize_annotations": "true" } - """ + } + """ let valueNotFoundJson = """ { @@ -171,7 +171,7 @@ class UserProfileDocumentTests: XCTestCase { XCTAssertNotNil(data) do { let pDoc = try UserProfileDocument.fromData(data!) - + XCTAssert(pDoc.authorizationIdentifier == "23333999999915") XCTAssertNotNil(pDoc.authorizationExpires) print(pDoc.authorizationExpires!) @@ -185,7 +185,7 @@ class UserProfileDocumentTests: XCTestCase { XCTAssert(drms[0].clientToken == "someToken") XCTAssertNil(drms[0].serverToken) } - + // Test Links XCTAssertNotNil(pDoc.links) if let links = pDoc.links { @@ -195,7 +195,7 @@ class UserProfileDocumentTests: XCTestCase { XCTAssertNil(links[0].type) XCTAssertNil(links[0].templated) } - + // Test Settings XCTAssertNotNil(pDoc.settings) XCTAssertTrue(pDoc.settings?.synchronizeAnnotations ?? false) @@ -203,13 +203,13 @@ class UserProfileDocumentTests: XCTestCase { XCTAssert(false, error.localizedDescription) } } - + func testParseJSONExtraProperty() { let data = extraPropertyJson.data(using: .utf8) XCTAssertNotNil(data) do { let pDoc = try UserProfileDocument.fromData(data!) - + XCTAssert(pDoc.authorizationIdentifier == "23333999999915") XCTAssertNotNil(pDoc.authorizationExpires) print(pDoc.authorizationExpires!) @@ -223,7 +223,7 @@ class UserProfileDocumentTests: XCTestCase { XCTAssert(drms[0].clientToken == "someToken") XCTAssertNil(drms[0].serverToken) } - + // Test Links XCTAssertNotNil(pDoc.links) if let links = pDoc.links { @@ -233,7 +233,7 @@ class UserProfileDocumentTests: XCTestCase { XCTAssertNil(links[0].type) XCTAssertNil(links[0].templated) } - + // Test Settings XCTAssertNotNil(pDoc.settings) XCTAssertTrue(pDoc.settings?.synchronizeAnnotations ?? false) @@ -241,18 +241,18 @@ class UserProfileDocumentTests: XCTestCase { XCTAssert(false, error.localizedDescription) } } - + func testParseJSONInvalid() { let data = dataCorruptedJson.data(using: .utf8) XCTAssertNotNil(data) - + do { - let _ = try UserProfileDocument.fromData(data!) + _ = try UserProfileDocument.fromData(data!) XCTAssert(false) } catch { let err = error as NSError XCTAssertEqual(err.code, NSCoderReadCorruptError) - + guard let customErrorCode = err.userInfo[UserProfileDocument.parseErrorKey] as? Int else { XCTFail() return @@ -260,18 +260,18 @@ class UserProfileDocumentTests: XCTestCase { XCTAssertEqual(customErrorCode, TPPErrorCode.parseProfileDataCorrupted.rawValue) } } - + func testParseJSONMissingProperty() { let data = keyNotFoundJson.data(using: .utf8) XCTAssertNotNil(data) - + do { - let _ = try UserProfileDocument.fromData(data!) + _ = try UserProfileDocument.fromData(data!) XCTAssert(false) } catch { let err = error as NSError XCTAssertEqual(err.code, NSCoderValueNotFoundError) - + guard let customErrorCode = err.userInfo[UserProfileDocument.parseErrorKey] as? Int else { XCTFail() return @@ -279,18 +279,18 @@ class UserProfileDocumentTests: XCTestCase { XCTAssertEqual(customErrorCode, TPPErrorCode.parseProfileKeyNotFound.rawValue) } } - + func testParseJSONTypeMismatch() { let data = mismatchTypeJson.data(using: .utf8) XCTAssertNotNil(data) - + do { - let _ = try UserProfileDocument.fromData(data!) + _ = try UserProfileDocument.fromData(data!) XCTAssert(false) } catch { let err = error as NSError XCTAssertEqual(err.code, NSCoderReadCorruptError) - + guard let customErrorCode = err.userInfo[UserProfileDocument.parseErrorKey] as? Int else { XCTFail() return @@ -298,18 +298,18 @@ class UserProfileDocumentTests: XCTestCase { XCTAssertEqual(customErrorCode, TPPErrorCode.parseProfileTypeMismatch.rawValue) } } - + func testParseJSONNilValue() { let data = valueNotFoundJson.data(using: .utf8) XCTAssertNotNil(data) - + do { - let _ = try UserProfileDocument.fromData(data!) + _ = try UserProfileDocument.fromData(data!) XCTAssert(false) } catch { let err = error as NSError XCTAssertEqual(err.code, NSCoderValueNotFoundError) - + guard let customErrorCode = err.userInfo[UserProfileDocument.parseErrorKey] as? Int else { XCTFail() return diff --git a/ios-audiobooktoolkit b/ios-audiobooktoolkit index 9a28691c0..afeb2f17d 160000 --- a/ios-audiobooktoolkit +++ b/ios-audiobooktoolkit @@ -1 +1 @@ -Subproject commit 9a28691c0326fddb22f6b736bc8db7a4e800dc00 +Subproject commit afeb2f17d639933c75dc1b6058ed729bc37de714 diff --git a/scripts/add-swiftlint-buildphase.sh b/scripts/add-swiftlint-buildphase.sh new file mode 100755 index 000000000..eb9b59916 --- /dev/null +++ b/scripts/add-swiftlint-buildphase.sh @@ -0,0 +1,145 @@ +#!/bin/bash + +# Add SwiftLint Run Script Build Phase to Xcode project +# This script will modify the Palace.xcodeproj to include SwiftLint in the build process + +set -euo pipefail + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +NC='\033[0m' # No Color + +# Get script directory +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="$(dirname "$SCRIPT_DIR")" +PROJECT_FILE="$PROJECT_ROOT/Palace.xcodeproj/project.pbxproj" + +# Check if project file exists +check_project_file() { + if [[ ! -f "$PROJECT_FILE" ]]; then + echo -e "${RED}❌ Palace.xcodeproj/project.pbxproj not found${NC}" + exit 1 + fi +} + +# Function to backup project file +backup_project_file() { + local backup_file="${PROJECT_FILE}.backup-$(date +%Y%m%d-%H%M%S)" + cp "$PROJECT_FILE" "$backup_file" + echo -e "${BLUE}📦 Backed up project file to: ${backup_file##*/}${NC}" +} + +# Function to check if SwiftLint build phase already exists +check_existing_build_phase() { + if grep -q "SwiftLint" "$PROJECT_FILE" 2>/dev/null; then + echo -e "${YELLOW}⚠️ SwiftLint build phase may already exist${NC}" + echo "Please check your Xcode project build phases manually." + return 0 + fi + return 1 +} + +# Function to show manual instructions +show_manual_instructions() { + echo "" + echo -e "${BLUE}📋 Manual Setup Instructions:${NC}" + echo "=============================================" + echo "" + echo "1. Open Palace.xcodeproj in Xcode" + echo "2. Select the 'Palace' target" + echo "3. Go to 'Build Phases' tab" + echo "4. Click the '+' button and choose 'New Run Script Phase'" + echo "5. Name the run script phase 'SwiftLint'" + echo "6. Add this shell script:" + echo "" + echo " # SwiftLint Build Phase" + echo " if which swiftlint > /dev/null; then" + echo " swiftlint" + echo " else" + echo " echo \"warning: SwiftLint not installed, download from https://github.com/realm/SwiftLint\"" + echo " fi" + echo "" + echo "7. Move the SwiftLint phase to run after 'Compile Sources'" + echo "8. Build your project to see SwiftLint warnings and errors in Xcode" + echo "" + echo -e "${GREEN}✅ That's it! SwiftLint will now run on every build.${NC}" + echo "" +} + +# Function to attempt automatic setup (experimental) +attempt_automatic_setup() { + echo -e "${YELLOW}⚠️ Attempting automatic setup (experimental)...${NC}" + echo "This will try to add a SwiftLint build phase to your project." + echo "" + + # Check if we can find the main target + local main_target_uuid + main_target_uuid=$(grep -A 5 "isa = PBXNativeTarget" "$PROJECT_FILE" | grep -B 5 "name = Palace" | head -1 | awk '{print $1}') + + if [[ -z "$main_target_uuid" ]]; then + echo -e "${RED}❌ Could not find Palace target UUID${NC}" + return 1 + fi + + echo -e "${GREEN}✅ Found Palace target: $main_target_uuid${NC}" + + # This is complex to do reliably without a proper Xcode project parser + echo -e "${YELLOW}⚠️ Automatic setup is complex and error-prone for .pbxproj files${NC}" + echo "Falling back to manual instructions..." + return 1 +} + +# Main function +main() { + echo -e "${GREEN}🎯 Palace Project - Add SwiftLint Build Phase${NC}" + echo "==============================================" + echo "" + + # Check prerequisites + check_project_file + + # Check if SwiftLint is installed + if ! command -v swiftlint >/dev/null 2>&1; then + echo -e "${RED}❌ SwiftLint not installed${NC}" + echo "Please run: ./scripts/install-linting-tools.sh" + exit 1 + fi + + echo -e "${GREEN}✅ SwiftLint found: $(swiftlint version)${NC}" + + # Check if build phase already exists + if check_existing_build_phase; then + echo "If you need to update the build phase, please do so manually in Xcode." + show_manual_instructions + exit 0 + fi + + # Backup project file + backup_project_file + + # Try automatic setup + if ! attempt_automatic_setup; then + show_manual_instructions + exit 0 + fi + + echo -e "${GREEN}🎉 SwiftLint build phase setup complete!${NC}" +} + +# Show usage if help requested +if [[ "${1:-}" == "-h" ]] || [[ "${1:-}" == "--help" ]]; then + echo "Usage: $0" + echo "" + echo "Add SwiftLint Run Script Build Phase to Palace.xcodeproj" + echo "" + echo "This script will provide instructions to manually add SwiftLint to your Xcode build process." + echo "Due to the complexity of .pbxproj files, manual setup is recommended." + echo "" + exit 0 +fi + +# Run main function +main "$@" diff --git a/scripts/format-code.sh b/scripts/format-code.sh new file mode 100755 index 000000000..0022373f6 --- /dev/null +++ b/scripts/format-code.sh @@ -0,0 +1,217 @@ +#!/bin/bash + +# Format Swift code using SwiftFormat for the Palace Project +# This script formats all Swift files according to the project's style guide + +set -euo pipefail + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +NC='\033[0m' # No Color + +# Get script directory +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="$(dirname "$SCRIPT_DIR")" + +# Function to check if SwiftFormat is available +check_swiftformat() { + if ! command -v swiftformat >/dev/null 2>&1; then + echo -e "${RED}❌ SwiftFormat not found in PATH${NC}" + echo "Please install SwiftFormat first:" + echo " ./scripts/install-linting-tools.sh" + echo " or" + echo " brew install swiftformat" + exit 1 + fi +} + +# Function to format files +format_files() { + local target_paths=("$@") + local config_file="$PROJECT_ROOT/.swiftformat" + + echo -e "${BLUE}🔧 Formatting Swift files...${NC}" + + if [[ ! -f "$config_file" ]]; then + echo -e "${YELLOW}⚠️ No .swiftformat config found, using default settings${NC}" + config_file="" + else + echo -e "${GREEN}📋 Using config: $config_file${NC}" + fi + + local format_count=0 + local error_count=0 + + for path in "${target_paths[@]}"; do + if [[ -d "$PROJECT_ROOT/$path" ]] || [[ -f "$PROJECT_ROOT/$path" ]]; then + echo -e "${BLUE} Formatting: $path${NC}" + + local cmd_args=() + if [[ -n "$config_file" ]]; then + cmd_args+=("--config" "$config_file") + fi + cmd_args+=("$PROJECT_ROOT/$path") + + if swiftformat "${cmd_args[@]}" 2>/dev/null; then + ((format_count++)) + else + echo -e "${RED} ❌ Error formatting: $path${NC}" + ((error_count++)) + fi + else + echo -e "${YELLOW} ⚠️ Path not found: $path${NC}" + fi + done + + echo "" + if [[ $error_count -eq 0 ]]; then + echo -e "${GREEN}✅ Formatting complete! Processed $format_count locations.${NC}" + else + echo -e "${YELLOW}⚠️ Formatting complete with $error_count errors. Processed $format_count locations.${NC}" + fi +} + +# Function to show diff preview +preview_changes() { + echo -e "${BLUE}🔍 Preview mode: showing potential changes...${NC}" + + local config_file="$PROJECT_ROOT/.swiftformat" + local preview_args=("--dryrun") + + if [[ -f "$config_file" ]]; then + preview_args+=("--config" "$config_file") + fi + + # Add target directories + preview_args+=( + "$PROJECT_ROOT/Palace" + "$PROJECT_ROOT/PalaceTests" + "$PROJECT_ROOT/PalaceUIKit" + "$PROJECT_ROOT/ios-audiobooktoolkit/PalaceAudiobookToolkit" + "$PROJECT_ROOT/ios-audiobooktoolkit/PalaceAudiobookToolkitTests" + "$PROJECT_ROOT/ios-audiobook-overdrive/OverdriveProcessor" + ) + + swiftformat "${preview_args[@]}" || true +} + +# Function to format specific file +format_single_file() { + local file_path="$1" + local config_file="$PROJECT_ROOT/.swiftformat" + + if [[ ! -f "$file_path" ]]; then + echo -e "${RED}❌ File not found: $file_path${NC}" + exit 1 + fi + + if [[ ! "$file_path" =~ \.swift$ ]]; then + echo -e "${RED}❌ File is not a Swift file: $file_path${NC}" + exit 1 + fi + + echo -e "${BLUE}🔧 Formatting single file: $file_path${NC}" + + local cmd_args=() + if [[ -f "$config_file" ]]; then + cmd_args+=("--config" "$config_file") + fi + cmd_args+=("$file_path") + + if swiftformat "${cmd_args[@]}"; then + echo -e "${GREEN}✅ File formatted successfully!${NC}" + else + echo -e "${RED}❌ Error formatting file${NC}" + exit 1 + fi +} + +# Function to show usage +show_usage() { + echo "Usage: $0 [OPTIONS]" + echo "" + echo "Format Swift code using SwiftFormat" + echo "" + echo "OPTIONS:" + echo " -h, --help Show this help message" + echo " -p, --preview Preview changes without applying them" + echo " -f, --file Format a specific file" + echo " -a, --all Format all Swift files in the project (default)" + echo "" + echo "EXAMPLES:" + echo " $0 # Format all files" + echo " $0 --preview # Preview all changes" + echo " $0 --file Palace/Book/TPPBook.swift # Format specific file" + echo "" +} + +# Main function +main() { + cd "$PROJECT_ROOT" + + echo -e "${GREEN}🎯 Palace Project - Code Formatter${NC}" + echo "=================================" + echo "" + + # Check if SwiftFormat is installed + check_swiftformat + + # Default target paths + local target_paths=( + "Palace" + "PalaceTests" + "PalaceUIKit" + "ios-audiobooktoolkit/PalaceAudiobookToolkit" + "ios-audiobooktoolkit/PalaceAudiobookToolkitTests" + "ios-audiobook-overdrive/OverdriveProcessor" + ) + + # Parse command line arguments + while [[ $# -gt 0 ]]; do + case $1 in + -h|--help) + show_usage + exit 0 + ;; + -p|--preview) + preview_changes + exit 0 + ;; + -f|--file) + if [[ $# -lt 2 ]]; then + echo -e "${RED}❌ --file requires a file path${NC}" + exit 1 + fi + format_single_file "$2" + exit 0 + ;; + -a|--all) + # This is the default behavior + shift + ;; + *) + echo -e "${RED}❌ Unknown option: $1${NC}" + show_usage + exit 1 + ;; + esac + shift + done + + # Format all target paths + format_files "${target_paths[@]}" + + echo "" + echo -e "${GREEN}🎉 Code formatting complete!${NC}" + echo "" + echo "💡 Tips:" + echo " • Run 'git diff' to see what was changed" + echo " • Run './scripts/lint-code.sh' to check for any remaining issues" + echo " • Consider setting up a pre-commit hook to format automatically" +} + +# Run main function with all arguments +main "$@" diff --git a/scripts/gradual-linting-setup.sh b/scripts/gradual-linting-setup.sh new file mode 100755 index 000000000..002db7086 --- /dev/null +++ b/scripts/gradual-linting-setup.sh @@ -0,0 +1,349 @@ +#!/bin/bash + +# Gradual Linting Setup for Palace Project +# This script helps migrate an existing codebase to linting standards gradually + +set -euo pipefail + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +NC='\033[0m' # No Color + +# Get script directory +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="$(dirname "$SCRIPT_DIR")" + +# Function to create a baseline configuration +create_baseline_config() { + echo -e "${BLUE}📊 Creating gradual migration SwiftLint configuration...${NC}" + + local baseline_config="$PROJECT_ROOT/.swiftlint-migration.yml" + + cat > "$baseline_config" << 'EOF' +# SwiftLint Migration Configuration for Palace Project +# This is a more lenient configuration for gradual adoption + +# Paths to include for linting +included: + - Palace + - PalaceTests + - PalaceUIKit + - ios-audiobooktoolkit/PalaceAudiobookToolkit + - ios-audiobooktoolkit/PalaceAudiobookToolkitTests + - ios-audiobook-overdrive/OverdriveProcessor + +# Paths to exclude from linting +excluded: + - Carthage + - readium-sdk + - readium-shared-js + - adept-ios + - adobe-content-filter + - adobe-rmsdk + - ios-tenprintcover + - mobile-bookmark-spec + - build + - DerivedData + - fastlane + - scripts + - "*.generated.swift" + +# Start with only the most critical rules +disabled_rules: + - todo + - line_length + - function_body_length + - type_body_length + - file_length + - cyclomatic_complexity + - function_parameter_count + - opening_brace # Common formatting issue - fix with SwiftFormat + - trailing_closure # Style preference - can be ignored initially + - unused_optional_binding # Not critical for functionality + +# Focus on critical opt-in rules only +opt_in_rules: + - empty_string + - force_unwrapping # Critical for crash prevention + - implicitly_unwrapped_optional # Critical for crash prevention + - legacy_random + - redundant_nil_coalescing + - unused_import + +# More lenient configurations +line_length: + warning: 150 + error: 200 + ignores_urls: true + ignores_function_declarations: true + ignores_comments: true + +function_body_length: + warning: 100 + error: 200 + +type_body_length: + warning: 500 + error: 1000 + +file_length: + warning: 1000 + error: 2000 + ignore_comment_only_lines: true + +cyclomatic_complexity: + warning: 25 + error: 50 + +# Set a much higher warning threshold for migration +warning_threshold: 500 + +# Custom reporter +reporter: "xcode" +EOF + + echo -e "${GREEN}✅ Created migration config: .swiftlint-migration.yml${NC}" +} + +# Function to auto-fix easy issues +auto_fix_formatting() { + echo -e "${BLUE}🔧 Auto-fixing formatting issues with SwiftFormat...${NC}" + + # First, format all the code to fix spacing issues + "$PROJECT_ROOT/scripts/format-code.sh" --all + + echo -e "${GREEN}✅ Formatting complete${NC}" +} + +# Function to run linting with migration config +run_migration_lint() { + echo -e "${BLUE}🔍 Running linting with migration configuration...${NC}" + + local migration_config="$PROJECT_ROOT/.swiftlint-migration.yml" + + if [[ ! -f "$migration_config" ]]; then + echo -e "${RED}❌ Migration config not found. Run with --create-config first.${NC}" + return 1 + fi + + # Run SwiftLint with the migration config + swiftlint --config "$migration_config" || true +} + +# Function to generate a focused report +generate_focused_report() { + echo -e "${BLUE}📋 Generating focused linting report...${NC}" + + local migration_config="$PROJECT_ROOT/.swiftlint-migration.yml" + local report_file="$PROJECT_ROOT/linting-report.txt" + + if [[ ! -f "$migration_config" ]]; then + echo -e "${RED}❌ Migration config not found${NC}" + return 1 + fi + + # Generate report focusing on critical issues + echo "Palace Project Linting Report - $(date)" > "$report_file" + echo "=============================================" >> "$report_file" + echo "" >> "$report_file" + + # Count violations by type + echo "Critical Issues (crash-prone):" >> "$report_file" + swiftlint --config "$migration_config" --reporter json 2>/dev/null | jq -r '.[] | select(.severity == "error") | .reason' | sort | uniq -c | sort -rn >> "$report_file" 2>/dev/null || echo "No critical issues found" >> "$report_file" + + echo "" >> "$report_file" + echo "Most Common Warnings:" >> "$report_file" + swiftlint --config "$migration_config" --reporter json 2>/dev/null | jq -r '.[] | select(.severity == "warning") | .reason' | sort | uniq -c | sort -rn | head -10 >> "$report_file" 2>/dev/null || echo "Analysis requires jq tool" >> "$report_file" + + echo "" >> "$report_file" + echo "Files with most issues:" >> "$report_file" + swiftlint --config "$migration_config" --reporter json 2>/dev/null | jq -r '.[].file' | sort | uniq -c | sort -rn | head -10 >> "$report_file" 2>/dev/null || echo "Analysis requires jq tool" >> "$report_file" + + echo -e "${GREEN}✅ Report saved to: linting-report.txt${NC}" +} + +# Function to create a phased migration plan +create_migration_plan() { + echo -e "${BLUE}📋 Creating phased migration plan...${NC}" + + cat > "$PROJECT_ROOT/LINTING_MIGRATION_PLAN.md" << 'EOF' +# Linting Migration Plan for Palace Project + +## Overview +This document outlines a phased approach to introduce linting to the Palace project without overwhelming the development process. + +## Phase 1: Critical Issues Only (Current) +**Goal**: Fix issues that could cause crashes or serious bugs +**Duration**: 1-2 weeks + +### Configuration +- Use `.swiftlint-migration.yml` +- Focus on force unwrapping, implicitly unwrapped optionals +- Disable most style rules + +### Steps +1. Run `./scripts/gradual-linting-setup.sh --auto-fix` +2. Fix critical issues one file at a time +3. Run `./scripts/gradual-linting-setup.sh --report` weekly + +## Phase 2: Code Quality Rules (Week 3-4) +**Goal**: Improve code maintainability +**Duration**: 2 weeks + +### Enable Additional Rules +- `function_body_length` (with higher limits) +- `type_body_length` (with higher limits) +- `cyclomatic_complexity` (with higher limits) + +### Steps +1. Update `.swiftlint-migration.yml` to include quality rules +2. Address largest/most complex files first +3. Refactor incrementally + +## Phase 3: Style Consistency (Week 5-6) +**Goal**: Ensure consistent code style +**Duration**: 2 weeks + +### Enable Style Rules +- `opening_brace` +- `trailing_closure` +- `line_length` (with project-appropriate limits) + +### Steps +1. Run SwiftFormat to auto-fix most issues +2. Enable style rules gradually +3. Fix remaining manual issues + +## Phase 4: Full Rule Set (Week 7+) +**Goal**: Complete linting coverage +**Duration**: Ongoing + +### Final Configuration +- Switch to full `.swiftlint.yml` +- Enable all appropriate rules +- Lower thresholds to final values + +### Maintenance +- New code follows all rules +- Legacy code improved opportunistically +- Regular linting in CI/CD + +## Daily Workflow During Migration + +### For New Code +- Always run linting on new/modified files +- Follow full standards for new code + +### For Existing Code +- Fix issues in files you're already modifying +- Don't create separate "linting only" PRs for now + +### Commands +```bash +# Check current migration status +./scripts/gradual-linting-setup.sh --report + +# Fix formatting issues automatically +./scripts/gradual-linting-setup.sh --auto-fix + +# Lint with current migration rules +./scripts/gradual-linting-setup.sh --lint +``` + +## Success Metrics +- [ ] Phase 1: Zero critical errors (force unwrapping, etc.) +- [ ] Phase 2: Functions < 100 lines, classes < 500 lines +- [ ] Phase 3: Consistent formatting across codebase +- [ ] Phase 4: < 50 total linting violations + +## Tips for Success +1. **Start small**: Fix one file completely rather than partial fixes across many files +2. **Auto-fix first**: Let SwiftFormat handle formatting automatically +3. **Focus on value**: Prioritize rules that prevent bugs over style preferences +4. **Team alignment**: Ensure all developers understand the migration plan +EOF + + echo -e "${GREEN}✅ Created migration plan: LINTING_MIGRATION_PLAN.md${NC}" +} + +# Function to show usage +show_usage() { + echo "Usage: $0 [OPTION]" + echo "" + echo "Gradual linting setup for Palace Project" + echo "" + echo "OPTIONS:" + echo " --create-config Create migration configuration files" + echo " --auto-fix Auto-fix formatting issues with SwiftFormat" + echo " --lint Run linting with migration configuration" + echo " --report Generate focused linting report" + echo " --plan Create migration plan document" + echo " --full-setup Run complete gradual setup" + echo " -h, --help Show this help message" + echo "" + echo "EXAMPLES:" + echo " $0 --full-setup # Complete initial setup" + echo " $0 --auto-fix # Fix formatting issues" + echo " $0 --lint # Check with migration rules" + echo "" +} + +# Main function +main() { + cd "$PROJECT_ROOT" + + echo -e "${GREEN}🎯 Palace Project - Gradual Linting Setup${NC}" + echo "==========================================" + echo "" + + case "${1:-}" in + --create-config) + create_baseline_config + ;; + --auto-fix) + auto_fix_formatting + ;; + --lint) + run_migration_lint + ;; + --report) + generate_focused_report + ;; + --plan) + create_migration_plan + ;; + --full-setup) + echo -e "${BLUE}Running complete gradual setup...${NC}" + create_baseline_config + create_migration_plan + auto_fix_formatting + echo "" + echo -e "${GREEN}🎉 Gradual setup complete!${NC}" + echo "" + echo "Next steps:" + echo "1. Review LINTING_MIGRATION_PLAN.md" + echo "2. Run: ./scripts/gradual-linting-setup.sh --lint" + echo "3. Start with critical issues only" + ;; + -h|--help) + show_usage + exit 0 + ;; + "") + echo -e "${YELLOW}⚠️ No option specified. Use --help for usage.${NC}" + show_usage + exit 1 + ;; + *) + echo -e "${RED}❌ Unknown option: $1${NC}" + show_usage + exit 1 + ;; + esac +} + +# Run main function +main "$@" diff --git a/scripts/install-linting-tools.sh b/scripts/install-linting-tools.sh new file mode 100755 index 000000000..00d4457b9 --- /dev/null +++ b/scripts/install-linting-tools.sh @@ -0,0 +1,147 @@ +#!/bin/bash + +# Install SwiftLint and SwiftFormat for the Palace Project +# This script will install via Homebrew if available, otherwise provide instructions + +set -euo pipefail + +echo "🔧 Installing SwiftLint and SwiftFormat..." + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' # No Color + +# Function to check if a command exists +command_exists() { + command -v "$1" >/dev/null 2>&1 +} + +# Function to install via Homebrew +install_via_homebrew() { + echo -e "${GREEN}Installing via Homebrew...${NC}" + + if ! command_exists brew; then + echo -e "${RED}Homebrew not found. Please install Homebrew first:${NC}" + echo " /bin/bash -c \"\$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)\"" + return 1 + fi + + echo "Installing SwiftLint..." + brew install swiftlint + + echo "Installing SwiftFormat..." + brew install swiftformat + + return 0 +} + +# Function to install via Swift Package Manager (SPM) +install_via_spm() { + echo -e "${YELLOW}Installing via Swift Package Manager (requires Xcode)...${NC}" + + # Create a temporary directory for the SPM package + TEMP_DIR=$(mktemp -d) + cd "$TEMP_DIR" + + # Create Package.swift + cat > Package.swift << 'EOF' +// swift-tools-version:5.5 +import PackageDescription + +let package = Package( + name: "PalaceLintingTools", + platforms: [.macOS(.v10_15)], + dependencies: [ + .package(url: "https://github.com/realm/SwiftLint", from: "0.54.0"), + .package(url: "https://github.com/nicklockwood/SwiftFormat", from: "0.52.0") + ], + targets: [ + .executableTarget( + name: "PalaceLintingTools", + dependencies: [] + ) + ] +) +EOF + + # Create main.swift (dummy executable) + mkdir -p Sources/PalaceLintingTools + echo 'print("Palace Linting Tools installed")' > Sources/PalaceLintingTools/main.swift + + # Build to install the tools + swift build --product SwiftLint + swift build --product SwiftFormat + + # Copy binaries to /usr/local/bin (requires admin) + echo -e "${YELLOW}Installing binaries to /usr/local/bin (may require sudo)...${NC}" + sudo cp .build/debug/SwiftLint /usr/local/bin/ 2>/dev/null || cp .build/debug/SwiftLint ~/bin/ 2>/dev/null || echo -e "${RED}Could not install SwiftLint binary${NC}" + sudo cp .build/debug/SwiftFormat /usr/local/bin/ 2>/dev/null || cp .build/debug/SwiftFormat ~/bin/ 2>/dev/null || echo -e "${RED}Could not install SwiftFormat binary${NC}" + + # Cleanup + cd - > /dev/null + rm -rf "$TEMP_DIR" +} + +# Main installation logic +main() { + echo "🎯 Palace Project - Linting Tools Installation" + echo "=============================================" + + # Check if tools are already installed + if command_exists swiftlint && command_exists swiftformat; then + echo -e "${GREEN}✅ SwiftLint and SwiftFormat are already installed!${NC}" + echo "SwiftLint version: $(swiftlint version)" + echo "SwiftFormat version: $(swiftformat --version)" + return 0 + fi + + # Try Homebrew first + if install_via_homebrew; then + echo -e "${GREEN}✅ Successfully installed via Homebrew!${NC}" + else + echo -e "${YELLOW}⚠️ Homebrew installation failed. Trying Swift Package Manager...${NC}" + if install_via_spm; then + echo -e "${GREEN}✅ Successfully installed via Swift Package Manager!${NC}" + else + echo -e "${RED}❌ Installation failed. Please install manually:${NC}" + echo "" + echo "Option 1 - Homebrew:" + echo " brew install swiftlint swiftformat" + echo "" + echo "Option 2 - Download binaries:" + echo " SwiftLint: https://github.com/realm/SwiftLint/releases" + echo " SwiftFormat: https://github.com/nicklockwood/SwiftFormat/releases" + echo "" + return 1 + fi + fi + + # Verify installation + echo "" + echo "🔍 Verifying installation..." + + if command_exists swiftlint; then + echo -e "${GREEN}✅ SwiftLint: $(swiftlint version)${NC}" + else + echo -e "${RED}❌ SwiftLint not found in PATH${NC}" + fi + + if command_exists swiftformat; then + echo -e "${GREEN}✅ SwiftFormat: $(swiftformat --version)${NC}" + else + echo -e "${RED}❌ SwiftFormat not found in PATH${NC}" + fi + + echo "" + echo -e "${GREEN}🎉 Installation complete!${NC}" + echo "" + echo "Next steps:" + echo "1. Run './scripts/format-code.sh' to format your entire codebase" + echo "2. Run './scripts/lint-code.sh' to lint your code" + echo "3. The Xcode build phase will automatically run SwiftLint on builds" +} + +# Run main function +main "$@" diff --git a/scripts/lint-code.sh b/scripts/lint-code.sh new file mode 100755 index 000000000..11d64147c --- /dev/null +++ b/scripts/lint-code.sh @@ -0,0 +1,290 @@ +#!/bin/bash + +# Lint Swift code using SwiftLint for the Palace Project +# This script checks all Swift files for style and potential issues + +set -euo pipefail + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +NC='\033[0m' # No Color + +# Get script directory +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="$(dirname "$SCRIPT_DIR")" + +# Function to check if SwiftLint is available +check_swiftlint() { + if ! command -v swiftlint >/dev/null 2>&1; then + echo -e "${RED}❌ SwiftLint not found in PATH${NC}" + echo "Please install SwiftLint first:" + echo " ./scripts/install-linting-tools.sh" + echo " or" + echo " brew install swiftlint" + exit 1 + fi +} + +# Function to run linting +run_lint() { + local config_file="$PROJECT_ROOT/.swiftlint.yml" + local should_fix="$1" + local specific_paths=("${@:2}") + + echo -e "${BLUE}🔍 Running SwiftLint...${NC}" + + if [[ ! -f "$config_file" ]]; then + echo -e "${YELLOW}⚠️ No .swiftlint.yml config found, using default settings${NC}" + config_file="" + else + echo -e "${GREEN}📋 Using config: $config_file${NC}" + fi + + local cmd_args=() + + # Add config file if it exists + if [[ -n "$config_file" ]]; then + cmd_args+=("--config" "$config_file") + fi + + # Add auto-correct flag if requested + if [[ "$should_fix" == "true" ]]; then + cmd_args+=("--fix") + echo -e "${YELLOW}🔧 Auto-fix mode enabled${NC}" + fi + + # Add specific paths if provided, otherwise use current directory + if [[ ${#specific_paths[@]} -gt 0 ]]; then + for path in "${specific_paths[@]}"; do + if [[ -d "$PROJECT_ROOT/$path" ]] || [[ -f "$PROJECT_ROOT/$path" ]]; then + cmd_args+=("$PROJECT_ROOT/$path") + else + echo -e "${YELLOW}⚠️ Path not found: $path${NC}" + fi + done + else + cmd_args+=("$PROJECT_ROOT") + fi + + echo "" + echo -e "${BLUE}Running: swiftlint ${cmd_args[*]##--config*}${NC}" + echo "" + + # Run SwiftLint + local exit_code=0 + swiftlint "${cmd_args[@]}" || exit_code=$? + + echo "" + case $exit_code in + 0) + echo -e "${GREEN}✅ No linting issues found!${NC}" + ;; + 1) + echo -e "${YELLOW}⚠️ Linting completed with warnings${NC}" + ;; + 2) + echo -e "${RED}❌ Linting failed with errors${NC}" + ;; + 3) + echo -e "${RED}❌ SwiftLint encountered an internal error${NC}" + ;; + *) + echo -e "${RED}❌ SwiftLint exited with code: $exit_code${NC}" + ;; + esac + + return $exit_code +} + +# Function to show linting rules +show_rules() { + echo -e "${BLUE}📋 Available SwiftLint rules:${NC}" + echo "" + swiftlint rules +} + +# Function to generate baseline +generate_baseline() { + local baseline_file="$PROJECT_ROOT/.swiftlint-baseline.json" + + echo -e "${BLUE}📊 Generating SwiftLint baseline...${NC}" + echo "This will create a baseline of current issues to focus on new problems." + echo "" + + local config_file="$PROJECT_ROOT/.swiftlint.yml" + local cmd_args=() + + if [[ -f "$config_file" ]]; then + cmd_args+=("--config" "$config_file") + fi + + cmd_args+=("--reporter" "json") + cmd_args+=("$PROJECT_ROOT") + + if swiftlint "${cmd_args[@]}" > "$baseline_file" 2>/dev/null; then + echo -e "${GREEN}✅ Baseline saved to: $baseline_file${NC}" + echo "Add this to your .swiftlint.yml:" + echo "baseline: .swiftlint-baseline.json" + else + echo -e "${RED}❌ Failed to generate baseline${NC}" + return 1 + fi +} + +# Function to analyze specific file +analyze_file() { + local file_path="$1" + + if [[ ! -f "$file_path" ]]; then + echo -e "${RED}❌ File not found: $file_path${NC}" + exit 1 + fi + + if [[ ! "$file_path" =~ \.swift$ ]]; then + echo -e "${RED}❌ File is not a Swift file: $file_path${NC}" + exit 1 + fi + + echo -e "${BLUE}🔍 Analyzing single file: $file_path${NC}" + echo "" + + local config_file="$PROJECT_ROOT/.swiftlint.yml" + local cmd_args=() + + if [[ -f "$config_file" ]]; then + cmd_args+=("--config" "$config_file") + fi + + cmd_args+=("$file_path") + + run_lint "false" "$file_path" +} + +# Function to show usage +show_usage() { + echo "Usage: $0 [OPTIONS]" + echo "" + echo "Lint Swift code using SwiftLint" + echo "" + echo "OPTIONS:" + echo " -h, --help Show this help message" + echo " -f, --fix Auto-fix issues that can be corrected automatically" + echo " -r, --rules Show all available linting rules" + echo " -b, --baseline Generate a baseline file for existing issues" + echo " --file FILE Analyze a specific file" + echo " --strict Treat warnings as errors (exit code 1 on warnings)" + echo "" + echo "EXAMPLES:" + echo " $0 # Lint all files" + echo " $0 --fix # Lint and auto-fix issues" + echo " $0 --file Palace/Book/TPPBook.swift # Lint specific file" + echo " $0 --rules # Show available rules" + echo " $0 --baseline # Generate baseline" + echo "" +} + +# Main function +main() { + cd "$PROJECT_ROOT" + + echo -e "${GREEN}🎯 Palace Project - Code Linter${NC}" + echo "===============================" + echo "" + + # Check if SwiftLint is installed + check_swiftlint + + local should_fix="false" + local show_rules_flag="false" + local generate_baseline_flag="false" + local analyze_single_file="" + local strict_mode="false" + + # Parse command line arguments + while [[ $# -gt 0 ]]; do + case $1 in + -h|--help) + show_usage + exit 0 + ;; + -f|--fix) + should_fix="true" + shift + ;; + -r|--rules) + show_rules_flag="true" + shift + ;; + -b|--baseline) + generate_baseline_flag="true" + shift + ;; + --file) + if [[ $# -lt 2 ]]; then + echo -e "${RED}❌ --file requires a file path${NC}" + exit 1 + fi + analyze_single_file="$2" + shift 2 + ;; + --strict) + strict_mode="true" + shift + ;; + *) + echo -e "${RED}❌ Unknown option: $1${NC}" + show_usage + exit 1 + ;; + esac + done + + # Handle specific actions + if [[ "$show_rules_flag" == "true" ]]; then + show_rules + exit 0 + fi + + if [[ "$generate_baseline_flag" == "true" ]]; then + generate_baseline + exit 0 + fi + + if [[ -n "$analyze_single_file" ]]; then + analyze_file "$analyze_single_file" + exit $? + fi + + # Run linting on the entire project + echo -e "${GREEN}SwiftLint version: $(swiftlint version)${NC}" + echo "" + + local exit_code=0 + run_lint "$should_fix" || exit_code=$? + + # Handle strict mode + if [[ "$strict_mode" == "true" && $exit_code -eq 1 ]]; then + echo -e "${RED}💥 Strict mode: treating warnings as errors${NC}" + exit_code=2 + fi + + echo "" + if [[ $exit_code -eq 0 ]]; then + echo -e "${GREEN}🎉 Linting complete!${NC}" + else + echo -e "${YELLOW}💡 Tips:${NC}" + echo " • Run '$0 --fix' to auto-correct fixable issues" + echo " • Run './scripts/format-code.sh' to format your code first" + echo " • Check .swiftlint.yml to customize rules" + fi + + echo "" + + exit $exit_code +} + +# Run main function with all arguments +main "$@" diff --git a/scripts/quick-lint-fix.sh b/scripts/quick-lint-fix.sh new file mode 100755 index 000000000..286abb863 --- /dev/null +++ b/scripts/quick-lint-fix.sh @@ -0,0 +1,224 @@ +#!/bin/bash + +# Quick Linting Fix for Palace Project +# This script provides a practical approach to handle linting in a large codebase + +set -euo pipefail + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +NC='\033[0m' # No Color + +# Get script directory +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="$(dirname "$SCRIPT_DIR")" + +# Function to switch to migration config temporarily +use_migration_config() { + local original_config="$PROJECT_ROOT/.swiftlint.yml" + local migration_config="$PROJECT_ROOT/.swiftlint-migration.yml" + local backup_config="$PROJECT_ROOT/.swiftlint.yml.backup" + + if [[ -f "$migration_config" ]]; then + # Backup original if it exists + if [[ -f "$original_config" ]]; then + cp "$original_config" "$backup_config" + fi + # Use migration config + cp "$migration_config" "$original_config" + echo -e "${BLUE}📋 Switched to migration configuration${NC}" + return 0 + else + echo -e "${RED}❌ Migration config not found. Run gradual setup first.${NC}" + return 1 + fi +} + +# Function to restore original config +restore_original_config() { + local original_config="$PROJECT_ROOT/.swiftlint.yml" + local backup_config="$PROJECT_ROOT/.swiftlint.yml.backup" + + if [[ -f "$backup_config" ]]; then + mv "$backup_config" "$original_config" + echo -e "${BLUE}📋 Restored original configuration${NC}" + fi +} + +# Function to format specific directories +format_by_directory() { + echo -e "${BLUE}🔧 Formatting code by directory (safer approach)...${NC}" + + local dirs=( + "Palace/Audiobooks" + "Palace/Book" + "Palace/CatalogUI" + "Palace/ErrorHandling" + "Palace/MyBooks" + "Palace/Settings" + "ios-audiobooktoolkit/PalaceAudiobookToolkit/Player" + "ios-audiobooktoolkit/PalaceAudiobookToolkit/UI" + ) + + for dir in "${dirs[@]}"; do + if [[ -d "$PROJECT_ROOT/$dir" ]]; then + echo -e "${BLUE} Formatting: $dir${NC}" + swiftformat "$PROJECT_ROOT/$dir" --config "$PROJECT_ROOT/.swiftformat" || echo -e "${YELLOW} Warning: Some files in $dir had issues${NC}" + fi + done +} + +# Function to run targeted linting +run_targeted_linting() { + echo -e "${BLUE}🔍 Running targeted linting on key directories...${NC}" + + # Focus on the most important directories first + local priority_dirs=( + "Palace/Audiobooks" + "Palace/Book" + "Palace/MyBooks" + ) + + for dir in "${priority_dirs[@]}"; do + if [[ -d "$PROJECT_ROOT/$dir" ]]; then + echo -e "${BLUE}📁 Linting: $dir${NC}" + swiftlint --path "$PROJECT_ROOT/$dir" || echo -e "${YELLOW} Issues found in $dir${NC}" + echo "" + fi + done +} + +# Function to show current status +show_status() { + echo -e "${BLUE}📊 Current Linting Status${NC}" + echo "=========================" + echo "" + + # Count Swift files + local swift_files + swift_files=$(find "$PROJECT_ROOT/Palace" "$PROJECT_ROOT/ios-audiobooktoolkit/PalaceAudiobookToolkit" -name "*.swift" 2>/dev/null | wc -l | tr -d ' ') + echo "Swift files in main codebase: $swift_files" + + # Quick lint count on a subset + echo "" + echo "Sample linting status (Palace/Audiobooks directory):" + if [[ -d "$PROJECT_ROOT/Palace/Audiobooks" ]]; then + swiftlint --path "$PROJECT_ROOT/Palace/Audiobooks" 2>/dev/null | tail -1 || echo "Unable to get quick status" + fi +} + +# Function to provide recommendations +show_recommendations() { + echo "" + echo -e "${GREEN}💡 Recommendations for Managing Linting${NC}" + echo "========================================" + echo "" + echo "🎯 IMMEDIATE ACTIONS (Today):" + echo "1. Use migration config: ./scripts/quick-lint-fix.sh --use-migration" + echo "2. Format key directories: ./scripts/quick-lint-fix.sh --format-priority" + echo "3. Fix only new/modified files for now" + echo "" + echo "📅 THIS WEEK:" + echo "1. Set up Xcode integration for new code warnings" + echo "2. Focus on fixing critical issues (force unwrapping, crashes)" + echo "3. Run linting on files you're already working on" + echo "" + echo "🔄 ONGOING STRATEGY:" + echo "1. Always lint new code with full rules" + echo "2. Fix legacy code opportunistically (when you touch it)" + echo "3. Gradually tighten rules as violations decrease" + echo "" + echo "📋 XCODE INTEGRATION:" + echo "Add this to your Xcode build phases for NEW CODE warnings only:" + echo "" + echo "# SwiftLint - New Files Only" + echo 'if which swiftlint > /dev/null; then' + echo ' # Only lint files changed in the last commit' + echo ' git diff --name-only HEAD~1 HEAD | grep "\.swift$" | xargs swiftlint --config .swiftlint-migration.yml' + echo 'else' + echo ' echo "warning: SwiftLint not installed"' + echo 'fi' + echo "" +} + +# Function to show usage +show_usage() { + echo "Usage: $0 [OPTION]" + echo "" + echo "Quick linting fixes for Palace Project" + echo "" + echo "OPTIONS:" + echo " --use-migration Switch to migration configuration" + echo " --restore-config Restore original configuration" + echo " --format-priority Format priority directories only" + echo " --lint-priority Lint priority directories only" + echo " --status Show current linting status" + echo " --recommendations Show management recommendations" + echo " -h, --help Show this help message" + echo "" + echo "EXAMPLES:" + echo " $0 --use-migration # Switch to lenient config" + echo " $0 --format-priority # Format key directories" + echo " $0 --status # Check current status" + echo "" +} + +# Main function +main() { + cd "$PROJECT_ROOT" + + echo -e "${GREEN}🎯 Palace Project - Quick Lint Fix${NC}" + echo "==================================" + echo "" + + case "${1:-}" in + --use-migration) + use_migration_config + ;; + --restore-config) + restore_original_config + ;; + --format-priority) + format_by_directory + ;; + --lint-priority) + run_targeted_linting + ;; + --status) + show_status + ;; + --recommendations) + show_recommendations + ;; + -h|--help) + show_usage + exit 0 + ;; + "") + echo -e "${YELLOW}⚠️ No option specified. Showing status and recommendations.${NC}" + echo "" + show_status + show_recommendations + ;; + *) + echo -e "${RED}❌ Unknown option: $1${NC}" + show_usage + exit 1 + ;; + esac +} + +# Cleanup function +cleanup() { + # Always try to restore original config if script is interrupted + restore_original_config 2>/dev/null || true +} + +# Set up cleanup trap +trap cleanup EXIT + +# Run main function +main "$@"